prashna 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
prashna/__init__.py ADDED
File without changes
prashna/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m prashna` runs the `prashna` command."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
prashna/auth.py ADDED
@@ -0,0 +1,198 @@
1
+ """Admin auth: password hashing + signed session cookie.
2
+
3
+ Single-admin model for now (local self-host). Multi-user/multi-org is a
4
+ future expansion; keep this module the seam where that change lands.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import secrets as pysecrets
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import bcrypt
14
+ from fastapi import Cookie, Depends, HTTPException, Response
15
+ from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
16
+ from sqlalchemy import select
17
+ from sqlalchemy.orm import Session as DbSession
18
+
19
+ from . import models as m
20
+ from .db import DB_PATH, get_db
21
+
22
+ COOKIE_NAME = "prashna_admin"
23
+ SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
24
+ # bcrypt silently truncates inputs beyond 72 bytes; reject longer to avoid
25
+ # users believing a long passphrase is fully protecting them.
26
+ MAX_PASSWORD_BYTES = 72
27
+
28
+ # Operator-free state lives beside the database, so a backup of the data
29
+ # folder carries it: the signing key (UC-X-07) and, until an admin exists,
30
+ # the one-time setup token (UC-A-01).
31
+ DATA_DIR = DB_PATH.parent
32
+ SECRET_KEY_FILE = DATA_DIR / "secret-key"
33
+ SETUP_TOKEN_FILE = DATA_DIR / "setup-token"
34
+ # Generated values carry a fixed prefix so a scanner (scripts/check_pii.py)
35
+ # recognises a leaked key or token in any file, whatever it is called.
36
+ SECRET_KEY_PREFIX = "prashna_sk_" # pragma: allowlist secret
37
+ SETUP_TOKEN_PREFIX = "prashna_st_"
38
+
39
+
40
+ def _write_private(path: Path, value: str) -> None:
41
+ """Create `path` readable only by this user; fail if it already exists."""
42
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
43
+ with os.fdopen(fd, "w") as fh:
44
+ fh.write(value + "\n")
45
+
46
+
47
+ def _read_or_create(path: Path, make) -> str:
48
+ try:
49
+ value = path.read_text().strip()
50
+ if value:
51
+ return value
52
+ except FileNotFoundError:
53
+ pass
54
+ value = make()
55
+ try:
56
+ _write_private(path, value)
57
+ except FileExistsError: # another start won the race; use its value
58
+ return path.read_text().strip()
59
+ return value
60
+
61
+
62
+ def _resolve_secret(env_value: str | None, key_file: Path) -> tuple[str, bool]:
63
+ """Return (signing_key, is_ephemeral) (UC-X-07).
64
+
65
+ PRASHNA_SECRET_KEY wins. Otherwise the key is read from, or created in,
66
+ `key_file`, so sessions survive restarts with no operator step. Only if
67
+ that file can't be used do we fall back to a process-lifetime key
68
+ (ephemeral), which startup warns about.
69
+ """
70
+ if env_value:
71
+ return env_value, False
72
+ try:
73
+ return _read_or_create(key_file, lambda: SECRET_KEY_PREFIX + pysecrets.token_urlsafe(48)), False
74
+ except OSError:
75
+ return SECRET_KEY_PREFIX + pysecrets.token_urlsafe(32), True
76
+
77
+
78
+ _secret, SECRET_KEY_IS_EPHEMERAL = _resolve_secret(os.environ.get("PRASHNA_SECRET_KEY"), SECRET_KEY_FILE)
79
+ _signer = TimestampSigner(_secret)
80
+
81
+
82
+ def new_signer() -> TimestampSigner:
83
+ """A signer over the same app secret — for one-off signed tokens like
84
+ the GitHub OAuth `state` (CSRF) that aren't session cookies."""
85
+ return TimestampSigner(_secret)
86
+
87
+
88
+ def hash_password(pw: str) -> str:
89
+ pw_bytes = pw.encode("utf-8")
90
+ if len(pw_bytes) > MAX_PASSWORD_BYTES:
91
+ raise HTTPException(400, f"password must be at most {MAX_PASSWORD_BYTES} bytes")
92
+ return bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
93
+
94
+
95
+ def verify_password(pw: str, hashed: str) -> bool:
96
+ try:
97
+ return bcrypt.checkpw(pw.encode("utf-8"), hashed.encode("utf-8"))
98
+ except (ValueError, TypeError):
99
+ return False
100
+
101
+
102
+ def issue_session(response: Response, user_id: int, session_version: int, *, secure: bool) -> None:
103
+ # Cookie value is "user_id:session_version". The session_version comes
104
+ # from the User row; bumping it on password change invalidates older
105
+ # cookies even though they remain validly signed.
106
+ token = _signer.sign(f"{user_id}:{session_version}").decode()
107
+ response.set_cookie(
108
+ COOKIE_NAME,
109
+ token,
110
+ max_age=SESSION_MAX_AGE,
111
+ httponly=True,
112
+ samesite="lax",
113
+ secure=secure,
114
+ )
115
+
116
+
117
+ def clear_session(response: Response) -> None:
118
+ response.delete_cookie(COOKIE_NAME)
119
+
120
+
121
+ def _parse_cookie(cookie: str | None) -> tuple[int, int] | None:
122
+ """Return (user_id, session_version) from a signed cookie, or None."""
123
+ if not cookie:
124
+ return None
125
+ try:
126
+ raw = _signer.unsign(cookie, max_age=SESSION_MAX_AGE).decode()
127
+ except (BadSignature, SignatureExpired):
128
+ return None
129
+ # Backwards-compat for older cookies (no session_version field).
130
+ if ":" in raw:
131
+ try:
132
+ uid_str, ver_str = raw.split(":", 1)
133
+ return int(uid_str), int(ver_str)
134
+ except ValueError:
135
+ return None
136
+ try:
137
+ return int(raw), 1
138
+ except ValueError:
139
+ return None
140
+
141
+
142
+ def current_admin(
143
+ prashna_admin: Annotated[str | None, Cookie()] = None,
144
+ db: DbSession = Depends(get_db),
145
+ ) -> m.User:
146
+ parsed = _parse_cookie(prashna_admin)
147
+ if parsed is None:
148
+ raise HTTPException(401, "Not authenticated")
149
+ uid, ver = parsed
150
+ u = db.get(m.User, uid)
151
+ if u is None or u.session_version != ver:
152
+ raise HTTPException(401, "Not authenticated")
153
+ return u
154
+
155
+
156
+ def optional_admin(
157
+ prashna_admin: Annotated[str | None, Cookie()] = None,
158
+ db: DbSession = Depends(get_db),
159
+ ) -> m.User | None:
160
+ parsed = _parse_cookie(prashna_admin)
161
+ if parsed is None:
162
+ return None
163
+ uid, ver = parsed
164
+ u = db.get(m.User, uid)
165
+ if u is None or u.session_version != ver:
166
+ return None
167
+ return u
168
+
169
+
170
+ def admin_exists(db: DbSession) -> bool:
171
+ return db.scalar(select(m.User.id).limit(1)) is not None
172
+
173
+
174
+ # ---------- one-time first-admin setup token (UC-A-01) ----------
175
+
176
+ def setup_token(db: DbSession) -> str | None:
177
+ """The token that authorizes creating the first admin, or None once an
178
+ admin exists. PRASHNA_SETUP_TOKEN wins; otherwise a random token is kept
179
+ in SETUP_TOKEN_FILE and reused across restarts until setup completes."""
180
+ if admin_exists(db):
181
+ retire_setup_token()
182
+ return None
183
+ env_token = os.environ.get("PRASHNA_SETUP_TOKEN", "").strip()
184
+ if env_token:
185
+ return env_token
186
+ return _read_or_create(SETUP_TOKEN_FILE, lambda: SETUP_TOKEN_PREFIX + pysecrets.token_urlsafe(24))
187
+
188
+
189
+ def setup_token_matches(db: DbSession, supplied: object) -> bool:
190
+ expected = setup_token(db)
191
+ return bool(expected) and isinstance(supplied, str) and pysecrets.compare_digest(supplied, expected)
192
+
193
+
194
+ def retire_setup_token() -> None:
195
+ try:
196
+ SETUP_TOKEN_FILE.unlink()
197
+ except FileNotFoundError:
198
+ pass
prashna/backup.py ADDED
@@ -0,0 +1,194 @@
1
+ """Create and restore portable Prashna data archives.
2
+
3
+ This module is intentionally runnable inside the published container so a
4
+ Docker-only installation does not need Python or sqlite tooling on the host.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import io
11
+ import json
12
+ import os
13
+ import shutil
14
+ import sqlite3
15
+ import tarfile
16
+ import tempfile
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path, PurePosixPath
19
+
20
+ ARCHIVE_FORMAT = 1
21
+
22
+
23
+ def _db_path() -> Path:
24
+ return Path(os.environ.get("PRASHNA_DB_PATH", "/data/prashna.db"))
25
+
26
+
27
+ def _uploads_path() -> Path:
28
+ return Path(os.environ.get("PRASHNA_UPLOADS_DIR", "/app/uploads"))
29
+
30
+
31
+ def _add_bytes(archive: tarfile.TarFile, name: str, payload: bytes) -> None:
32
+ info = tarfile.TarInfo(name)
33
+ info.size = len(payload)
34
+ info.mode = 0o600
35
+ info.mtime = 0
36
+ archive.addfile(info, io.BytesIO(payload))
37
+
38
+
39
+ def create_archive(destination: Path) -> Path:
40
+ """Create a consistent SQLite + uploads archive at *destination*."""
41
+ source_db = _db_path()
42
+ uploads = _uploads_path()
43
+ if not source_db.is_file():
44
+ raise RuntimeError(f"database not found: {source_db}")
45
+ destination = destination.resolve()
46
+ destination.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ with tempfile.TemporaryDirectory(prefix="prashna-backup-") as temp:
49
+ snapshot = Path(temp) / "prashna.db"
50
+ with sqlite3.connect(source_db) as source, sqlite3.connect(snapshot) as target:
51
+ source.backup(target)
52
+
53
+ manifest = {
54
+ "format": ARCHIVE_FORMAT,
55
+ "created_at": datetime.now(UTC).isoformat(),
56
+ "database": "prashna.db",
57
+ "uploads": "uploads/",
58
+ }
59
+ with tarfile.open(destination, "w:gz", format=tarfile.PAX_FORMAT) as archive:
60
+ _add_bytes(
61
+ archive,
62
+ "manifest.json",
63
+ (json.dumps(manifest, sort_keys=True) + "\n").encode(),
64
+ )
65
+ archive.add(snapshot, arcname="prashna.db", recursive=False)
66
+ if uploads.is_dir():
67
+ for path in sorted(uploads.rglob("*")):
68
+ if path.is_symlink() or not path.is_file():
69
+ continue
70
+ relative = path.relative_to(uploads).as_posix()
71
+ archive.add(path, arcname=f"uploads/{relative}", recursive=False)
72
+ return destination
73
+
74
+
75
+ def _extract_checked(archive_path: Path, destination: Path) -> None:
76
+ seen: set[str] = set()
77
+ with tarfile.open(archive_path, "r:gz") as archive:
78
+ for member in archive.getmembers():
79
+ name = PurePosixPath(member.name)
80
+ if (
81
+ member.name in seen
82
+ or name.is_absolute()
83
+ or ".." in name.parts
84
+ or member.issym()
85
+ or member.islnk()
86
+ or not (
87
+ member.name in {"manifest.json", "prashna.db"}
88
+ or (name.parts and name.parts[0] == "uploads")
89
+ )
90
+ ):
91
+ raise RuntimeError(f"unsafe backup member: {member.name}")
92
+ seen.add(member.name)
93
+ if member.isdir():
94
+ (destination / Path(*name.parts)).mkdir(parents=True, exist_ok=True)
95
+ continue
96
+ if not member.isfile():
97
+ raise RuntimeError(f"unsupported backup member: {member.name}")
98
+ source = archive.extractfile(member)
99
+ if source is None:
100
+ raise RuntimeError(f"cannot read backup member: {member.name}")
101
+ target = destination / Path(*name.parts)
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+ with target.open("wb") as output:
104
+ shutil.copyfileobj(source, output)
105
+
106
+ required = {"manifest.json", "prashna.db"}
107
+ if not required.issubset(seen):
108
+ raise RuntimeError("backup is missing manifest.json or prashna.db")
109
+
110
+
111
+ def _validate_snapshot(snapshot: Path, manifest_path: Path) -> None:
112
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
113
+ if manifest.get("format") != ARCHIVE_FORMAT:
114
+ raise RuntimeError(f"unsupported backup format: {manifest.get('format')!r}")
115
+ with sqlite3.connect(f"file:{snapshot}?mode=ro", uri=True) as connection:
116
+ result = connection.execute("PRAGMA quick_check").fetchone()
117
+ if not result or result[0] != "ok":
118
+ raise RuntimeError(f"backup database failed quick_check: {result!r}")
119
+ tables = {
120
+ row[0]
121
+ for row in connection.execute(
122
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
123
+ ).fetchall()
124
+ }
125
+ if "users" not in tables or "sessions" not in tables:
126
+ raise RuntimeError("backup database is not a Prashna database")
127
+
128
+
129
+ def restore_archive(archive_path: Path, *, confirmed: bool = False) -> Path:
130
+ """Restore an archive after creating a sibling pre-restore backup."""
131
+ if not confirmed:
132
+ raise RuntimeError("restore requires --confirm and a stopped Prashna service")
133
+ archive_path = archive_path.resolve()
134
+ if not archive_path.is_file():
135
+ raise RuntimeError(f"backup not found: {archive_path}")
136
+
137
+ with tempfile.TemporaryDirectory(prefix="prashna-restore-") as temp:
138
+ extracted = Path(temp)
139
+ _extract_checked(archive_path, extracted)
140
+ snapshot = extracted / "prashna.db"
141
+ _validate_snapshot(snapshot, extracted / "manifest.json")
142
+
143
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
144
+ pre_restore = archive_path.with_name(f"prashna-pre-restore-{stamp}.tar.gz")
145
+ create_archive(pre_restore)
146
+
147
+ target_db = _db_path()
148
+ target_db.parent.mkdir(parents=True, exist_ok=True)
149
+ staged_db = target_db.with_name(f".{target_db.name}.restore")
150
+ shutil.copy2(snapshot, staged_db)
151
+ os.replace(staged_db, target_db)
152
+ for suffix in ("-wal", "-shm", "-journal"):
153
+ target_db.with_name(target_db.name + suffix).unlink(missing_ok=True)
154
+
155
+ uploads = _uploads_path()
156
+ uploads.mkdir(parents=True, exist_ok=True)
157
+ for current in uploads.iterdir():
158
+ if current.is_dir() and not current.is_symlink():
159
+ shutil.rmtree(current)
160
+ else:
161
+ current.unlink()
162
+ restored_uploads = extracted / "uploads"
163
+ if restored_uploads.is_dir():
164
+ for source in restored_uploads.rglob("*"):
165
+ relative = source.relative_to(restored_uploads)
166
+ target = uploads / relative
167
+ if source.is_dir():
168
+ target.mkdir(parents=True, exist_ok=True)
169
+ elif source.is_file():
170
+ target.parent.mkdir(parents=True, exist_ok=True)
171
+ shutil.copy2(source, target)
172
+ return pre_restore
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ parser = argparse.ArgumentParser(description=__doc__)
177
+ commands = parser.add_subparsers(dest="command", required=True)
178
+ create = commands.add_parser("create", help="create a SQLite + uploads archive")
179
+ create.add_argument("archive", type=Path)
180
+ restore = commands.add_parser("restore", help="restore an archive while the app is stopped")
181
+ restore.add_argument("archive", type=Path)
182
+ restore.add_argument("--confirm", action="store_true")
183
+ args = parser.parse_args(argv)
184
+
185
+ if args.command == "create":
186
+ print(create_archive(args.archive))
187
+ return 0
188
+ pre_restore = restore_archive(args.archive, confirmed=args.confirm)
189
+ print(f"restored {args.archive}; previous state: {pre_restore}")
190
+ return 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())
prashna/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """The `prashna` command: run Prashna on this computer (UC-X-15).
2
+
3
+ prashna start on http://127.0.0.1:8000 and open the browser
4
+ prashna --port 8080 use another port
5
+ prashna --data-dir DIR keep the database and uploads in DIR
6
+ prashna backup create F archive the data folder (see `prashna backup --help`)
7
+
8
+ Data lives in the per-user app-data folder unless --data-dir or
9
+ PRASHNA_DATA_DIR says otherwise. On the first run the browser opens the
10
+ one-time setup page, so creating the admin needs no copy-and-paste.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import shlex
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+ import webbrowser
23
+ from importlib import metadata
24
+ from pathlib import Path
25
+
26
+
27
+ def _version() -> str:
28
+ try:
29
+ return metadata.version("prashna")
30
+ except metadata.PackageNotFoundError:
31
+ return "0+unknown"
32
+
33
+
34
+ def _parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="prashna", description="Live Q&A and quizzes for any session with an audience.")
37
+ parser.add_argument("--host", default="127.0.0.1",
38
+ help="address to listen on (default 127.0.0.1; 0.0.0.0 to allow other devices)")
39
+ parser.add_argument("--port", type=int, default=8000, help="port to listen on (default 8000)")
40
+ parser.add_argument("--data-dir", help="folder for the database and uploads "
41
+ "(default: PRASHNA_DATA_DIR or your app-data folder)")
42
+ parser.add_argument("--no-browser", action="store_true", help="don't open the browser")
43
+ parser.add_argument("--version", action="version", version=f"prashna {_version()}")
44
+ commands = parser.add_subparsers(dest="command")
45
+ backup = commands.add_parser("backup", add_help=False, help="create or restore a backup of the data folder")
46
+ backup.add_argument("args", nargs=argparse.REMAINDER)
47
+ return parser
48
+
49
+
50
+ def _data_dir(arg: str | None) -> Path:
51
+ chosen = arg or os.environ.get("PRASHNA_DATA_DIR")
52
+ if chosen:
53
+ return Path(chosen).expanduser().resolve()
54
+ from .db import default_data_dir
55
+ return default_data_dir()
56
+
57
+
58
+ def _port_free(host: str, port: int) -> bool:
59
+ with socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET) as sock:
60
+ try:
61
+ sock.bind((host, port))
62
+ except OSError:
63
+ return False
64
+ return True
65
+
66
+
67
+ def _launch_browser(url: str) -> None:
68
+ """Open `url`. A BROWSER setting (the usual way to choose a browser) is
69
+ launched directly: webbrowser's discovery of every installed browser can
70
+ take several seconds on Linux desktops before it tries BROWSER."""
71
+ for entry in filter(None, os.environ.get("BROWSER", "").split(os.pathsep)):
72
+ command = entry.replace("%s", url) if "%s" in entry else f"{entry} {shlex.quote(url)}"
73
+ try:
74
+ subprocess.Popen(shlex.split(command))
75
+ return
76
+ except OSError:
77
+ continue
78
+ webbrowser.open(url)
79
+
80
+
81
+ def _open_when_ready(host: str, port: int, url: str) -> None:
82
+ deadline = time.monotonic() + 30
83
+ while time.monotonic() < deadline:
84
+ try:
85
+ with socket.create_connection((host, port), timeout=0.5):
86
+ break
87
+ except OSError:
88
+ time.sleep(0.2)
89
+ _launch_browser(url)
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ args = _parser().parse_args(argv)
94
+ data_dir = _data_dir(args.data_dir)
95
+ data_dir.mkdir(parents=True, exist_ok=True)
96
+ # Set before the server modules are imported; they read these at import.
97
+ os.environ.setdefault("PRASHNA_DB_PATH", str(data_dir / "prashna.db"))
98
+ os.environ.setdefault("PRASHNA_UPLOADS_DIR", str(data_dir / "uploads"))
99
+
100
+ if args.command == "backup":
101
+ from .backup import main as backup_main
102
+ return backup_main(args.args)
103
+
104
+ if not _port_free(args.host, args.port):
105
+ print(f"Port {args.port} is already in use. Start on another port, "
106
+ f"for example: prashna --port {args.port + 1}", file=sys.stderr)
107
+ return 1
108
+
109
+ from . import auth, db
110
+ db.init_db()
111
+ with db.SessionLocal() as session:
112
+ token = auth.setup_token(session)
113
+ shown_host = "127.0.0.1" if args.host in ("0.0.0.0", "::") else args.host
114
+ address = f"http://{shown_host}:{args.port}"
115
+ url = f"{address}/#setup={token}" if token else f"{address}/"
116
+
117
+ print(f"\nPrashna is running at {address}")
118
+ print(f"Data folder: {data_dir}")
119
+ if token:
120
+ if args.no_browser:
121
+ print(f"Create your admin account here (one-time link): {url}")
122
+ else:
123
+ print("Opening the one-time setup page in your browser to create your admin account.")
124
+ print("Press Ctrl+C to stop.\n", flush=True)
125
+ if not args.no_browser:
126
+ threading.Thread(target=_open_when_ready, args=(shown_host, args.port, url), daemon=True).start()
127
+
128
+ import uvicorn
129
+ uvicorn.run("prashna.main:app", host=args.host, port=args.port, log_level="warning")
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
prashna/db.py ADDED
@@ -0,0 +1,155 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import create_engine, event
6
+ from sqlalchemy.engine import Engine
7
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
8
+
9
+ from .diagnostics import log_diagnostic as _log_diagnostic
10
+
11
+
12
+ def default_data_dir() -> Path:
13
+ """Where data lives when PRASHNA_DB_PATH / PRASHNA_UPLOADS_DIR are unset:
14
+ the platform's per-user app-data folder, for a source checkout too, so
15
+ the database and the generated secrets never land in a repository."""
16
+ if sys.platform == "win32":
17
+ return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "prashna"
18
+ if sys.platform == "darwin":
19
+ return Path.home() / "Library" / "Application Support" / "prashna"
20
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "prashna"
21
+
22
+
23
+ # Allow the DB location to be overridden via env (Docker volume in prod).
24
+ DB_PATH = Path(os.environ.get("PRASHNA_DB_PATH") or default_data_dir() / "prashna.db")
25
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
26
+ engine = create_engine(
27
+ f"sqlite:///{DB_PATH}",
28
+ connect_args={"check_same_thread": False},
29
+ future=True,
30
+ )
31
+
32
+ # Per-connection SQLite pragmas:
33
+ # - foreign_keys=ON: SQLite ignores ON DELETE CASCADE / FK constraints unless
34
+ # set per connection; without it orphaned rows are possible.
35
+ # - journal_mode=WAL: readers don't block the single writer (and vice versa),
36
+ # which matters under a live quiz where many participants submit at once.
37
+ # - busy_timeout=5000: a writer waits up to 5s for the lock instead of
38
+ # erroring immediately with "database is locked".
39
+ # - synchronous=NORMAL: safe with WAL, much less fsync stalling under load.
40
+ @event.listens_for(Engine, "connect")
41
+ def _set_sqlite_pragmas(dbapi_conn, _conn_record):
42
+ try:
43
+ cursor = dbapi_conn.cursor()
44
+ cursor.execute("PRAGMA foreign_keys=ON")
45
+ cursor.execute("PRAGMA journal_mode=WAL")
46
+ cursor.execute("PRAGMA busy_timeout=5000")
47
+ cursor.execute("PRAGMA synchronous=NORMAL")
48
+ cursor.close()
49
+ except Exception:
50
+ # Other dialects (Postgres etc.) don't need this; ignore quietly.
51
+ pass
52
+
53
+
54
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
55
+
56
+
57
+ class Base(DeclarativeBase):
58
+ pass
59
+
60
+
61
+ def get_db():
62
+ db = SessionLocal()
63
+ try:
64
+ yield db
65
+ finally:
66
+ db.close()
67
+
68
+
69
+ def init_db():
70
+ from . import models # noqa: F401
71
+ Base.metadata.create_all(bind=engine)
72
+ _apply_additive_migrations()
73
+
74
+
75
+ def _apply_additive_migrations(*, bind: Engine | None = None):
76
+ """Bridge gap between `create_all()` and additive column changes.
77
+
78
+ Prashna intentionally has no Alembic. `create_all` only creates
79
+ missing tables, not missing columns or indexes on existing tables. For
80
+ each supported additive change we introduce post-launch, add it here so
81
+ operators don't have to delete `prashna.db` on every model addition.
82
+ """
83
+ target = bind or engine
84
+ additive = [
85
+ # (table, column, ddl-snippet)
86
+ ("users", "session_version", "INTEGER NOT NULL DEFAULT 1"),
87
+ ("users", "github_id", "INTEGER"),
88
+ ("sessions", "participant_base_url", "VARCHAR(500)"),
89
+ ("sessions", "feedback_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
90
+ ("participants", "email", "VARCHAR(254)"),
91
+ ("participants", "contact_number", "VARCHAR(40)"),
92
+ ("participants", "organization", "VARCHAR(120)"),
93
+ ("participants", "role", "VARCHAR(120)"),
94
+ ("participants", "linkedin_url", "VARCHAR(500)"),
95
+ ("participants", "photo_url", "VARCHAR(500)"),
96
+ ("participants", "current_work", "TEXT"),
97
+ ("participants", "learning_goal", "TEXT"),
98
+ ("quiz_questions", "graded", "BOOLEAN NOT NULL DEFAULT 1"),
99
+ ("quiz_version_questions", "graded", "BOOLEAN NOT NULL DEFAULT 1"),
100
+ ("quiz_runs", "show_live_counts", "BOOLEAN NOT NULL DEFAULT 0"),
101
+ ]
102
+ # Indexes to (re)create on existing DBs. `ALTER TABLE ADD COLUMN` can't
103
+ # carry UNIQUE/partial-index constraints; create those separately.
104
+ indexes = [
105
+ ("ix_users_github_id", "users", ["github_id"], True, None),
106
+ (
107
+ "uq_session_live_run",
108
+ "quiz_runs",
109
+ ["session_id"],
110
+ True,
111
+ "status = 'live'",
112
+ ),
113
+ ("ix_qsub_run_question", "quiz_submissions", ["quiz_run_id", "question_id"], False, None),
114
+ ]
115
+ with target.begin() as conn:
116
+ tables = {
117
+ row[0]
118
+ for row in conn.exec_driver_sql(
119
+ "SELECT name FROM sqlite_master WHERE type='table'"
120
+ ).fetchall()
121
+ }
122
+ for table, column, ddl in additive:
123
+ if table not in tables:
124
+ continue
125
+ cols = {row[1] for row in conn.exec_driver_sql(
126
+ f"PRAGMA table_info({table})").fetchall()}
127
+ if column not in cols:
128
+ conn.exec_driver_sql(
129
+ f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
130
+ _log_diagnostic(
131
+ "db.migration",
132
+ action="add_column",
133
+ outcome="success",
134
+ table=table,
135
+ column=column,
136
+ )
137
+ for name, table, columns, unique, where in indexes:
138
+ if table not in tables:
139
+ continue
140
+ existing_cols = {
141
+ row[1] for row in conn.exec_driver_sql(f"PRAGMA table_info({table})")
142
+ }
143
+ if not set(columns).issubset(existing_cols):
144
+ continue
145
+ uniq = "UNIQUE " if unique else ""
146
+ where_sql = f" WHERE {where}" if where else ""
147
+ conn.exec_driver_sql(
148
+ f"CREATE {uniq}INDEX IF NOT EXISTS {name} ON {table} ({', '.join(columns)}){where_sql}")
149
+ _log_diagnostic(
150
+ "db.migration",
151
+ action="create_index",
152
+ outcome="success",
153
+ table=table,
154
+ index=name,
155
+ )