Coverage for app \ services \ sqlite_store.py: 83%
53 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 20:58 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 20:58 -0400
1import sqlite3
2from pathlib import Path
4from app.settings import SQLITE_PATH
5 #Opens a Database connection
6def get_conn() -> sqlite3.Connection:
7 # Ensure the DB directory exists before opening the SQLite file.
8 db_path = Path(SQLITE_PATH)
9 db_path.parent.mkdir(parents=True, exist_ok=True)
11 # row_factory lets us access columns by name (row["expert_id"])
12 conn = sqlite3.connect(db_path, check_same_thread=False)
13 conn.row_factory = sqlite3.Row
14 conn.execute("PRAGMA foreign_keys = ON;")
15 return conn
18def _children_needs_unlink_migration(conn: sqlite3.Connection) -> bool:
19 rows = conn.execute("PRAGMA table_info(children)").fetchall()
20 if not rows:
21 return False
22 for row in rows:
23 if row["name"] == "expert_id":
24 # old schema had NOT NULL expert_id, new schema allows NULL for unlink
25 return bool(row["notnull"])
26 return False
29def _migrate_drop_unique_name_index(conn: sqlite3.Connection) -> None:
30 indexes = {row["name"] for row in conn.execute("PRAGMA index_list(children)").fetchall()}
31 if "idx_children_unique_profile_per_expert" in indexes:
32 conn.execute("DROP INDEX idx_children_unique_profile_per_expert")
33 conn.commit()
36def _migrate_add_interaction_mode(conn: sqlite3.Connection) -> None:
37 cols = {row["name"] for row in conn.execute("PRAGMA table_info(children)").fetchall()}
38 if "interaction_mode" not in cols:
39 conn.execute(
40 "ALTER TABLE children ADD COLUMN interaction_mode TEXT NOT NULL DEFAULT 'flexible'"
41 )
42 conn.commit()
44def _migrate_add_login_code_plain(conn: sqlite3.Connection) -> None:
45 cols = {row["name"] for row in conn.execute("PRAGMA table_info(parents)").fetchall()}
46 if "login_code" not in cols:
47 conn.execute("ALTER TABLE parents ADD COLUMN login_code TEXT NULL")
48 conn.commit()
51def _migrate_add_parent_id(conn: sqlite3.Connection) -> None:
52 cols = {row["name"] for row in conn.execute("PRAGMA table_info(children)").fetchall()}
53 if "parent_id" not in cols:
54 conn.execute(
55 "ALTER TABLE children ADD COLUMN parent_id TEXT NULL REFERENCES parents(parent_id) ON UPDATE CASCADE ON DELETE SET NULL"
56 )
57 conn.commit()
60def _migrate_children_for_unlink(conn: sqlite3.Connection) -> None:
61 if not _children_needs_unlink_migration(conn):
62 return
64 conn.execute("PRAGMA foreign_keys = OFF;")
65 conn.executescript(
66 """
67 CREATE TABLE IF NOT EXISTS children_new(
68 child_id TEXT PRIMARY KEY
69 CHECK(
70 length(child_id) = 6
71 AND child_id GLOB '[0-9][0-9][0-9][0-9][0-9][0-9]'
72 ),
73 expert_id TEXT NULL,
74 first_name TEXT NOT NULL,
75 last_name TEXT NOT NULL,
76 icon_key TEXT NOT NULL,
77 is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0,1)),
78 created_at TEXT NOT NULL,
79 updated_at TEXT NOT NULL,
80 FOREIGN KEY(expert_id) REFERENCES experts(expert_id)
81 ON UPDATE CASCADE
82 ON DELETE SET NULL
83 );
85 INSERT INTO children_new
86 (child_id, expert_id, first_name, last_name, icon_key, is_active, created_at, updated_at)
87 SELECT
88 child_id, expert_id, first_name, last_name, icon_key, is_active, created_at, updated_at
89 FROM children;
91 DROP TABLE children;
92 ALTER TABLE children_new RENAME TO children;
94 CREATE INDEX IF NOT EXISTS idx_children_expert_id
95 ON children (expert_id);
97 CREATE INDEX IF NOT EXISTS idx_children_active_expert
98 ON children (expert_id, is_active);
100 """
101 )
102 conn.execute("PRAGMA foreign_keys = ON;")
105def init_db() -> None:
106 #creates table on start up
107 with get_conn() as conn:
108 # Idempotent bootstrap: safe to run at every app startup. from documentation
109 conn.executescript(
110 """
111 CREATE TABLE IF NOT EXISTS experts (
112 expert_id TEXT PRIMARY KEY,
113 display_name TEXT NOT NULL,
114 password_hash TEXT NOT NULL,
115 is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
116 created_at TEXT NOT NULL,
117 updated_at TEXT NOT NULL
118 );
120 CREATE TABLE IF NOT EXISTS videos(
121 id TEXT PRIMARY KEY,
122 title TEXT NOT NULL,
123 thumbnail TEXT,
124 duration FLOAT DEFAULT 0
125 );
127 CREATE TABLE IF NOT EXISTS video_assignments (
128 video_id TEXT PRIMARY KEY,
129 expert_id TEXT NULL,
130 assignment_source TEXT NOT NULL CHECK (assignment_source IN ('admin', 'expert_claim', 'unassigned')),
131 assigned_at TEXT NULL,
132 updated_at TEXT NOT NULL,
133 FOREIGN KEY (expert_id) REFERENCES experts(expert_id)
134 ON UPDATE CASCADE
135 ON DELETE SET NULL
136 );
138 CREATE INDEX IF NOT EXISTS idx_video_assignments_expert_id
139 ON video_assignments (expert_id);
141 CREATE TABLE IF NOT EXISTS video_expert_assignments(
143 video_id TEXT NOT NULL,
144 expert_id TEXT NOT NULL,
145 assignment_source TEXT NOT NULL CHECK(assignment_source IN ('admin', 'expert_claim', 'unassigned')),
146 assigned_at TEXT NOT NULL,
147 updated_at TEXT NOT NULL,
148 PRIMARY KEY (video_id, expert_id),
149 FOREIGN KEY (expert_id) REFERENCES experts(expert_id)
150 ON UPDATE CASCADE
151 ON DELETE CASCADE
152 );
154 CREATE INDEX IF NOT EXISTS idx_video_expert_assignments_video_id
155 ON video_expert_assignments (video_id);
157 CREATE INDEX IF NOT EXISTS idx_video_expert_assignments_expert_id
158 ON video_expert_assignments (expert_id);
161 INSERT OR IGNORE INTO video_expert_assignments
162 (video_id,expert_id,assignment_source,assigned_at,updated_at)
163 SELECT
164 video_id,
165 expert_id,
166 COALESCE(NULLIF(assignment_source, ''), 'admin'),
167 COALESCE(assigned_at, updated_at),
168 updated_at
169 FROM video_assignments
170 WHERE expert_id IS NOT NULL;
172 CREATE TABLE IF NOT EXISTS children(
173 child_id TEXT PRIMARY KEY
174 CHECK(
175 length(child_id) = 6
176 AND child_id GLOB '[0-9][0-9][0-9][0-9][0-9][0-9]'
177 ),
178 expert_id TEXT NULL,
179 first_name TEXT NOT NULL,
180 last_name TEXT NOT NULL,
181 icon_key TEXT NOT NULL,
182 interaction_mode TEXT NOT NULL DEFAULT 'flexible',
183 is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0,1)),
184 created_at TEXT NOT NULL,
185 updated_at TEXT NOT NULL,
186 FOREIGN KEY(expert_id) REFERENCES experts(expert_id)
187 ON UPDATE CASCADE
188 ON DELETE SET NULL
189 );
191 CREATE INDEX IF NOT EXISTS idx_children_expert_id
192 ON children (expert_id);
194 CREATE INDEX IF NOT EXISTS idx_children_active_expert
195 ON children (expert_id, is_active);
197 CREATE TABLE IF NOT EXISTS parents(
198 parent_id TEXT PRIMARY KEY,
199 display_name TEXT NOT NULL,
200 login_code_hash TEXT NOT NULL,
201 is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN(0,1)),
202 created_at TEXT NOT NULL,
203 updated_at TEXT NOT NULL
204 );
206 CREATE INDEX IF NOT EXISTS idx_parents_active
207 ON parents (is_active);
209 CREATE TABLE IF NOT EXISTS quiz_attempts(
211 attempt_id INTEGER PRIMARY KEY AUTOINCREMENT,
212 video_id TEXT NOT NULL,
213 child_id TEXT NOT NULL,
214 interaction_mode TEXT,
215 timestamp TEXT,
216 total_questions INT,
217 correct INT,
218 incorrect INT,
219 percentage FLOAT,
220 FOREIGN KEY (video_id) REFERENCES video_assignments(video_id),
221 FOREIGN KEY (child_id) REFERENCES children(child_id)
222 );
224 """
228 )
229 _migrate_children_for_unlink(conn)
230 _migrate_add_interaction_mode(conn)
231 _migrate_drop_unique_name_index(conn)
232 _migrate_add_parent_id(conn)
233 _migrate_add_login_code_plain(conn)
234 conn.commit()