Files
2026-05-16 19:13:10 +00:00

406 lines
19 KiB
Python

import json
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.store import JsonStore
class JsonRepository:
backend = "json"
def __init__(self) -> None:
self.store = JsonStore()
def list_courses(self) -> list[dict[str, Any]]:
return self.store.load("courses", [])
def replace_courses(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.replace_collection("courses", items)
def list_assignments(self) -> list[dict[str, Any]]:
return self.store.load("assignments", [])
def replace_assignments(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.replace_collection("assignments", items)
def list_sync_runs(self) -> list[dict[str, Any]]:
return self.store.load("sync_runs", [])
def save_sync_runs(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.save("sync_runs", items)
def list_submissions(self) -> list[dict[str, Any]]:
return self.store.load("submissions", [])
def create_submission(self, item: dict[str, Any]) -> dict[str, Any]:
return self.store.append_collection("submissions", item)
def update_submission(self, submission_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
return self.store.update_collection_item(
"submissions",
lambda item: str(item.get("id")) == str(submission_id),
lambda item: {**item, **updates},
)
def list_recordings(self) -> list[dict[str, Any]]:
return self.store.load("recordings", [])
def create_recording(self, item: dict[str, Any]) -> dict[str, Any]:
return self.store.append_collection("recordings", item)
def update_recording(self, recording_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
return self.store.update_collection_item(
"recordings",
lambda item: str(item.get("id")) == str(recording_id),
lambda item: {**item, **updates},
)
class PostgresRepository:
backend = "postgres"
def __init__(self) -> None:
import psycopg
from psycopg.rows import dict_row
self._psycopg = psycopg
self._dict_row = dict_row
self.dsn = settings.database_url.replace("postgresql+psycopg://", "postgresql://")
def _connect(self):
return self._psycopg.connect(self.dsn, row_factory=self._dict_row)
def _ensure_course(self, cur, item: dict[str, Any]) -> dict[str, Any] | None:
course_id = item.get("class_id") or item.get("course_id")
if not course_id:
return None
cur.execute("SELECT id, d2l_course_id, name FROM schoolhouse_course WHERE d2l_course_id = %s", (course_id,))
row = cur.fetchone()
if row:
return row
course_name = item.get("class_name") or item.get("course_name") or str(course_id)
cur.execute(
"""
INSERT INTO schoolhouse_course (d2l_course_id, name, last_synced_at)
VALUES (%s, %s, NOW())
RETURNING id, d2l_course_id, name
""",
(course_id, course_name),
)
return cur.fetchone()
def _ensure_assignment(self, cur, item: dict[str, Any]) -> dict[str, Any] | None:
course = self._ensure_course(cur, item)
if not course:
return None
assignment_id = item.get("assignment_id")
assignment_name = item.get("assignment_name") or item.get("title")
if assignment_id:
cur.execute(
"SELECT id, title FROM schoolhouse_assignment WHERE course_id = %s AND d2l_assignment_key = %s LIMIT 1",
(course["id"], assignment_id),
)
row = cur.fetchone()
if row:
return row
if assignment_name:
cur.execute(
"SELECT id, title FROM schoolhouse_assignment WHERE course_id = %s AND title = %s LIMIT 1",
(course["id"], assignment_name),
)
row = cur.fetchone()
if row:
return row
cur.execute(
"""
INSERT INTO schoolhouse_assignment (course_id, d2l_assignment_key, title, source, status, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
RETURNING id, title
""",
(course["id"], assignment_id, assignment_name, item.get("source", "schoolhouse-intake"), item.get("status", "assigned")),
)
return cur.fetchone()
return None
def list_courses(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT id::text AS id, d2l_course_id, name, code, semester, last_synced_at FROM schoolhouse_course ORDER BY name")
return list(cur.fetchall())
def replace_courses(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
for item in items:
cur.execute(
"""
INSERT INTO schoolhouse_course (d2l_course_id, name, code, semester, last_synced_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (d2l_course_id)
DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code, semester = EXCLUDED.semester, last_synced_at = NOW()
""",
(item.get("d2l_course_id"), item.get("name"), item.get("code"), item.get("semester")),
)
conn.commit()
return items
def list_assignments(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT a.id::text AS id,
c.d2l_course_id AS course_id,
c.name AS course_name,
a.title,
a.grade_text AS grade,
a.professor_comments AS comments,
a.status,
a.updated_at
FROM schoolhouse_assignment a
JOIN schoolhouse_course c ON c.id = a.course_id
ORDER BY c.name, a.title
"""
)
return list(cur.fetchall())
def replace_assignments(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
for item in items:
cur.execute("SELECT id FROM schoolhouse_course WHERE d2l_course_id = %s", (item.get("course_id"),))
row = cur.fetchone()
if not row:
continue
course_pk = row["id"]
cur.execute(
"""
INSERT INTO schoolhouse_assignment (course_id, d2l_assignment_key, title, grade_text, professor_comments, source, status, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT DO NOTHING
""",
(course_pk, item.get("id"), item.get("title"), item.get("grade"), item.get("comments"), item.get("source", "d2l"), item.get("status", "assigned")),
)
cur.execute(
"""
UPDATE schoolhouse_assignment
SET grade_text = %s,
professor_comments = %s,
status = COALESCE(status, %s),
updated_at = NOW()
WHERE course_id = %s AND title = %s
""",
(item.get("grade"), item.get("comments"), item.get("status", "assigned"), course_pk, item.get("title")),
)
conn.commit()
return items
def list_sync_runs(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT id::text AS id, started_at, completed_at, status, raw_snapshot_path AS snapshot_path, changes_summary_json FROM schoolhouse_d2l_sync_run ORDER BY started_at DESC")
return list(cur.fetchall())
def save_sync_runs(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not items:
return items
last = items[-1]
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO schoolhouse_d2l_sync_run (completed_at, status, raw_snapshot_path, changes_summary_json) VALUES (NOW(), %s, %s, %s::jsonb)",
(last.get("status", "completed"), last.get("snapshot_path"), json.dumps(last)),
)
conn.commit()
return items
def list_submissions(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT s.id::text AS id,
a.id::text AS assignment_id,
c.d2l_course_id AS class_id,
c.name AS class_name,
a.title AS assignment_name,
s.version_label,
s.original_filename,
s.mime_type,
s.status,
s.paperless_document_id,
s.paperless_title,
s.paperless_note_last_hash,
s.submitted_at,
s.confirmed_metadata_json,
s.confirmed_metadata_json->>'batch_id' AS batch_id,
s.confirmed_metadata_json->>'file_count' AS file_count,
s.confirmed_metadata_json->>'file_index' AS file_index,
s.confirmed_metadata_json->>'submission_kind' AS submission_kind,
s.confirmed_metadata_json->>'artifact_kind' AS artifact_kind,
s.confirmed_metadata_json->>'course_code' AS course_code,
s.confirmed_metadata_json->>'paper_kind' AS paper_kind,
s.confirmed_metadata_json->>'source' AS source,
s.confirmed_metadata_json->>'notes' AS notes,
s.confirmed_metadata_json->>'received_at' AS received_at,
s.confirmed_metadata_json->>'bundle_path' AS bundle_path,
s.confirmed_metadata_json->>'bundle_filename' AS bundle_filename,
s.confirmed_metadata_json->>'source_listing_path' AS source_listing_path,
s.confirmed_metadata_json->>'receipt_pdf_path' AS receipt_pdf_path,
s.confirmed_metadata_json->>'paperless_task_id' AS paperless_task_id,
s.confirmed_metadata_json->'file_manifest_json' AS file_manifest_json,
s.confirmed_metadata_json->'stored_paths' AS stored_paths,
s.confirmed_metadata_json->>'stored_path' AS stored_path,
s.confirmed_metadata_json->>'local_file' AS local_file
FROM schoolhouse_submission s
JOIN schoolhouse_assignment a ON a.id = s.assignment_id
JOIN schoolhouse_course c ON c.id = a.course_id
ORDER BY s.created_at DESC
"""
)
return list(cur.fetchall())
def create_submission(self, item: dict[str, Any]) -> dict[str, Any]:
with self._connect() as conn, conn.cursor() as cur:
row = self._ensure_assignment(cur, item)
if not row:
raise ValueError("Assignment not found and could not be created in Postgres repository")
cur.execute(
"""
INSERT INTO schoolhouse_submission (
assignment_id, version_label, original_filename, mime_type, checksum_sha256,
submitted_at, suggested_metadata_json, confirmed_metadata_json, status
) VALUES (%s, %s, %s, %s, %s, NOW(), %s::jsonb, %s::jsonb, %s)
RETURNING id::text AS id
""",
(
row["id"], item.get("version_label") or "v1", item.get("original_filename"), item.get("mime_type", "application/octet-stream"), item.get("checksum_sha256"),
json.dumps(item.get("paperless_handoff", {})), json.dumps(item), item.get("status", "uploaded-local"),
),
)
created = cur.fetchone()
conn.commit()
item["id"] = created["id"]
return item
def update_submission(self, submission_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT confirmed_metadata_json FROM schoolhouse_submission WHERE id = %s::bigint", (submission_id,))
current = cur.fetchone()
if not current:
return None
merged_metadata = {**(current.get("confirmed_metadata_json") or {}), **updates}
cur.execute(
"""
UPDATE schoolhouse_submission
SET status = COALESCE(%s, status),
paperless_document_id = COALESCE(%s, paperless_document_id),
paperless_title = COALESCE(%s, paperless_title),
paperless_note_last_hash = COALESCE(%s, paperless_note_last_hash),
paperless_note_appended_at = CASE WHEN %s::text IS NOT NULL THEN NOW() ELSE paperless_note_appended_at END,
confirmed_metadata_json = %s::jsonb,
updated_at = NOW()
WHERE id = %s::bigint
RETURNING id::text AS id, status, paperless_document_id, paperless_title, paperless_note_last_hash
""",
(
updates.get("status"), updates.get("paperless_document_id"), updates.get("paperless_title"), updates.get("paperless_note_last_hash"), updates.get("paperless_note_last_hash"),
json.dumps(merged_metadata), submission_id,
),
)
row = cur.fetchone()
conn.commit()
return {**merged_metadata, **dict(row)} if row else None
def list_recordings(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT r.id::text AS id,
c.d2l_course_id AS class_id,
c.name AS class_name,
r.assignment_id::text AS assignment_id,
r.course_id::text AS course_id,
r.session_date::text AS session_date,
r.original_filename,
r.mp3_filename,
r.transcription_status,
r.summary_status,
r.recording_paperless_document_id,
r.recording_metadata_json,
r.recording_metadata_json->>'stored_path' AS stored_path,
r.recording_metadata_json->>'mp3_path' AS mp3_path,
r.recording_metadata_json->>'status' AS status,
r.recording_metadata_json->>'conversion_status' AS conversion_status,
r.recording_metadata_json->>'notes' AS notes,
r.recording_metadata_json->>'delete_wav_after_success' AS delete_wav_after_success
FROM schoolhouse_recording r
JOIN schoolhouse_course c ON c.id = r.course_id
ORDER BY r.created_at DESC
"""
)
return list(cur.fetchall())
def create_recording(self, item: dict[str, Any]) -> dict[str, Any]:
with self._connect() as conn, conn.cursor() as cur:
row = self._ensure_course(cur, item)
if not row:
raise ValueError("Course not found and could not be created in Postgres repository")
cur.execute(
"""
INSERT INTO schoolhouse_recording (
course_id, assignment_id, session_date, original_filename, mp3_filename, notes,
transcription_status, summary_status, recording_metadata_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id::text AS id
""",
(
row["id"], item.get("assignment_id") or None, item.get("session_date"), item.get("original_filename"),
Path(item.get("mp3_path")).name if item.get("mp3_path") else None, item.get("notes"), item.get("transcription_status"),
item.get("summary_status"), json.dumps(item),
),
)
created = cur.fetchone()
conn.commit()
item["id"] = created["id"]
return item
def update_recording(self, recording_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT recording_metadata_json FROM schoolhouse_recording WHERE id = %s::bigint", (recording_id,))
current = cur.fetchone()
if not current:
return None
merged_metadata = {**(current.get("recording_metadata_json") or {}), **updates}
cur.execute(
"""
UPDATE schoolhouse_recording
SET transcription_status = COALESCE(%s, transcription_status),
summary_status = COALESCE(%s, summary_status),
transcript_paperless_document_id = COALESCE(%s, transcript_paperless_document_id),
summary_paperless_document_id = COALESCE(%s, summary_paperless_document_id),
recording_paperless_document_id = COALESCE(%s, recording_paperless_document_id),
mp3_filename = COALESCE(%s, mp3_filename),
notes = COALESCE(%s, notes),
recording_metadata_json = %s::jsonb,
updated_at = NOW()
WHERE id = %s::bigint
RETURNING id::text AS id, transcription_status, summary_status, recording_paperless_document_id
""",
(
updates.get("transcription_status"), updates.get("summary_status"), updates.get("transcript_paperless_document_id"),
updates.get("summary_paperless_document_id"), updates.get("recording_paperless_document_id"),
Path(updates.get("mp3_path")).name if updates.get("mp3_path") else None,
updates.get("notes"), json.dumps(merged_metadata), recording_id,
),
)
row = cur.fetchone()
conn.commit()
return {**merged_metadata, **dict(row)} if row else None
def get_repository():
backend = getattr(settings, "storage_backend", "auto")
if backend == "json":
return JsonRepository()
if settings.database_url.startswith("postgresql"):
try:
return PostgresRepository()
except Exception:
return JsonRepository()
return JsonRepository()