Add Doris Schoolhouse and clean pending homelab changes

This commit is contained in:
Fizzlepoof
2026-05-15 21:58:42 +00:00
parent 0e7dd21c66
commit 5c889f108e
60 changed files with 3145 additions and 20 deletions

View File

@@ -0,0 +1,148 @@
import hashlib
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from app.config import settings
from app.services.d2l import D2LService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
from app.services.webhooks import WebhookService
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
store = JsonStore()
repo = get_repository()
d2l = D2LService()
paperless = PaperlessService()
webhooks = WebhookService()
@router.get("")
async def list_assignments():
courses = {item["id"]: item for item in d2l.get_courses()}
items = repo.list_assignments()
submissions = repo.list_submissions()
submission_counts = {}
for submission in submissions:
submission_counts[submission.get("assignment_id")] = submission_counts.get(submission.get("assignment_id"), 0) + 1
for item in items:
item["submission_count"] = submission_counts.get(item.get("id"), 0)
item["course"] = courses.get(item.get("course_id"), {})
return {"items": items, "backend": repo.backend}
@router.get('/submissions')
async def list_submissions():
return {"items": repo.list_submissions(), "backend": repo.backend}
@router.post("/intake")
async def intake_assignment(
file: UploadFile,
class_id: str = Form(...),
class_name: str = Form(...),
assignment_name: str = Form(...),
version: str = Form("v1"),
submission_kind: str = Form("assignment_turn_in"),
semester: str = Form(""),
course_code: str = Form(""),
paper_kind: str = Form(""),
source: str = Form("doris-schoolhouse"),
telegram_chat_id: str = Form(""),
telegram_message_id: str = Form(""),
notes: str = Form(""),
):
assignments = d2l.get_assignments(class_id)
matching = next((item for item in assignments if item.get("title") == assignment_name), None)
if not matching:
raise HTTPException(status_code=404, detail="Assignment not found in cached D2L data for selected class.")
upload_root = Path(settings.upload_tmp_dir)
upload_root.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename or "upload.bin").name
submission_id = store.new_id("submission")
stored_path = upload_root / f"{submission_id}-{safe_name}"
payload = await file.read()
stored_path.write_bytes(payload)
checksum = hashlib.sha256(payload).hexdigest()
record = {
"id": submission_id,
"assignment_id": matching["id"],
"class_id": class_id,
"class_name": class_name,
"assignment_name": assignment_name,
"version_label": version,
"submission_kind": submission_kind,
"semester": semester or None,
"course_code": course_code or (course.get("code") if course else None),
"paper_kind": paper_kind or None,
"source": source or "doris-schoolhouse",
"telegram_chat_id": telegram_chat_id or None,
"telegram_message_id": telegram_message_id or None,
"notes": notes,
"original_filename": safe_name,
"stored_path": str(stored_path),
"mime_type": file.content_type or "application/octet-stream",
"file_size_bytes": len(payload),
"checksum_sha256": checksum,
"status": "uploaded-local",
"received_at": store.now(),
}
record["paperless_handoff"] = paperless.assignment_intake_payload(record)
created = repo.create_submission(record)
return {"status": "ok", "submission": created, "backend": repo.backend}
@router.post("/{submission_id}/confirm-paperless")
async def confirm_assignment_paperless(submission_id: str):
submissions = repo.list_submissions()
current = next((item for item in submissions if str(item.get("id")) == str(submission_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Submission not found.")
handoff = paperless.assignment_intake_payload(current)
file_path = current.get("stored_path") or current.get("local_file")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=400, detail="Stored upload file is missing.")
fields = {
"class_name": handoff["class_name"],
"assignment_name": handoff["assignment_name"],
"submission_kind": handoff["submission_kind"],
"notes": handoff.get("notes", ""),
"source": handoff.get("source", "doris-schoolhouse"),
}
for optional_key in ("semester", "course_code", "paper_kind", "telegram_chat_id", "telegram_message_id"):
value = handoff.get(optional_key)
if value:
fields[optional_key] = value
response = webhooks.post_multipart(handoff["target_url"], fields, "document", file_path)
body = response.get("body", {}) if isinstance(response, dict) else {}
body_obj = body[0] if isinstance(body, list) and body and isinstance(body[0], dict) else (body if isinstance(body, dict) else {})
ok = bool(response.get("ok", False)) if isinstance(response, dict) else False
updated = repo.update_submission(submission_id, {
"status": "paperless-queued" if ok else "paperless-error",
"confirmed_at": store.now(),
"webhook_response": response,
"paperless_document_id": body_obj.get("paperless_document_id") or body_obj.get("document_id"),
"paperless_title": body_obj.get("paperless_title") or body_obj.get("title") or handoff.get("assignment_name"),
})
return {"status": "ok" if ok else "error", "submission": updated or current, "handoff": handoff, "webhook": response}
@router.post("/{assignment_id}/status")
async def update_assignment_status(assignment_id: str, status: str = Form(...)):
assignments = repo.list_assignments()
updated = None
for item in assignments:
if str(item.get("id")) == str(assignment_id):
item["status"] = status
item["updated_at"] = store.now()
updated = item
break
if not updated:
raise HTTPException(status_code=404, detail="Assignment not found.")
repo.replace_assignments(assignments)
return {"status": "ok", "assignment": updated}

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter
from app.services.d2l import D2LService
from app.services.repository import get_repository
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
repo = get_repository()
d2l = D2LService()
@router.get("/summary")
async def dashboard_summary():
courses = d2l.get_courses()
assignments = repo.list_assignments()
submissions = repo.list_submissions()
recordings = repo.list_recordings()
sync_runs = repo.list_sync_runs()
return {
"backend": repo.backend,
"counts": {
"courses": len(courses),
"assignments": len(assignments),
"submissions": len(submissions),
"recordings": len(recordings),
},
"latest_sync": sync_runs[-1] if sync_runs else None,
"recent_assignments": assignments[:8],
"recent_submissions": submissions[:8],
"recent_recordings": recordings[:8],
}

View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.services.diagnostics import DiagnosticsService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
router = APIRouter(prefix="/api", tags=["paperless", "health"])
service = PaperlessService()
repo = get_repository()
store = JsonStore()
diagnostics = DiagnosticsService()
@router.get("/health")
async def health():
return {"ok": True, "service": "doris-schoolhouse", "state_dir": settings.state_dir, "backend": repo.backend}
@router.get("/health/details")
async def health_details():
return diagnostics.collect()
@router.post("/paperless/webhook/intake-enriched")
async def paperless_intake_enriched(submission_id: str, document_id: int, paperless_title: str | None = None):
updated = repo.update_submission(
submission_id,
{
"status": "paperless-linked",
"paperless_document_id": document_id,
"paperless_title": paperless_title,
"paperless_linked_at": store.now(),
},
)
if not updated:
raise HTTPException(status_code=404, detail="Submission not found.")
return {"status": "ok", "submission": updated}
@router.post("/paperless/webhook/recording-complete")
async def paperless_recording_complete(recording_id: str, transcript_document_id: int | None = None, summary_document_id: int | None = None, recording_document_id: int | None = None):
updated = repo.update_recording(
recording_id,
{
"status": "paperless-linked",
"transcript_paperless_document_id": transcript_document_id,
"summary_paperless_document_id": summary_document_id,
"recording_paperless_document_id": recording_document_id,
"transcription_status": "ready" if transcript_document_id else None,
"summary_status": "ready" if summary_document_id else None,
"paperless_linked_at": store.now(),
},
)
if not updated:
raise HTTPException(status_code=404, detail="Recording not found.")
return {"status": "ok", "recording": updated}
@router.post("/paperless/assignments/{assignment_id}/append-grade-note")
async def append_grade_note(assignment_id: str, execute: bool = False):
if not execute:
return service.describe_grade_note_plan(assignment_id)
try:
return service.append_grade_note(assignment_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@@ -0,0 +1,130 @@
import hashlib
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from app.config import settings
from app.services.d2l import D2LService
from app.services.media import MediaService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
from app.services.webhooks import WebhookService
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
store = JsonStore()
repo = get_repository()
d2l = D2LService()
paperless = PaperlessService()
media = MediaService()
webhooks = WebhookService()
@router.get("")
async def list_recordings():
return {"items": repo.list_recordings(), "backend": repo.backend}
@router.post("/intake")
async def intake_recording(
file: UploadFile,
class_id: str = Form(...),
class_name: str = Form(...),
session_date: str = Form(...),
assignment_id: str | None = Form(None),
notes: str = Form(""),
):
course = next((item for item in d2l.get_courses() if str(item.get("d2l_course_id") or item.get("id")) == str(class_id)), None)
if not course:
raise HTTPException(status_code=404, detail="Class not found in cached D2L data.")
upload_root = Path(settings.upload_tmp_dir)
upload_root.mkdir(parents=True, exist_ok=True)
recording_id = store.new_id("recording")
safe_name = Path(file.filename or "recording.wav").name
stored_path = upload_root / f"{recording_id}-{safe_name}"
payload = await file.read()
stored_path.write_bytes(payload)
checksum = hashlib.sha256(payload).hexdigest()
mp3_path = media.planned_mp3_path(str(stored_path))
record = {
"id": recording_id,
"class_id": str(course.get("d2l_course_id") or class_id),
"class_name": class_name,
"assignment_id": assignment_id,
"session_date": session_date,
"notes": notes,
"original_filename": safe_name,
"stored_path": str(stored_path),
"mp3_path": mp3_path,
"conversion_command": media.conversion_command(str(stored_path), mp3_path),
"delete_wav_after_success": True,
"mime_type": file.content_type or "audio/wav",
"file_size_bytes": len(payload),
"checksum_sha256": checksum,
"status": "uploaded-local",
"conversion_status": "queued",
"transcription_status": "queued",
"summary_status": "queued",
"received_at": store.now(),
}
record["paperless_handoff"] = paperless.recording_intake_payload(record)
created = repo.create_recording(record)
return {"status": "ok", "recording": created, "backend": repo.backend}
@router.post("/{recording_id}/reprocess")
async def reprocess_recording(recording_id: str):
recordings = repo.list_recordings()
current = next((item for item in recordings if str(item.get("id")) == str(recording_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Recording not found.")
handoff = paperless.recording_intake_payload(current)
file_path = current.get("mp3_path") or current.get("stored_path") or current.get("local_file")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=400, detail="Local recording file is missing.")
fields = {
"class_name": handoff["class_name"],
"lecture_title": handoff["lecture_title"],
"notes": handoff.get("notes", ""),
"source": handoff.get("source", "doris-schoolhouse"),
}
response = webhooks.post_multipart(handoff["target_url"], fields, "data", file_path)
body = response.get("body", {}) if isinstance(response, dict) else {}
body_obj = body[0] if isinstance(body, list) and body and isinstance(body[0], dict) else (body if isinstance(body, dict) else {})
ok = bool(response.get("ok", False)) if isinstance(response, dict) else False
updated = repo.update_recording(recording_id, {
"status": "requeued" if ok else "requeue-error",
"requeued_at": store.now(),
"webhook_response": response,
"transcription_status": "processing" if ok else "error",
"summary_status": "processing" if ok else "error",
"recording_paperless_document_id": body_obj.get("recording_paperless_document_id") or body_obj.get("document_id"),
})
return {"status": "ok" if ok else "error", "recording": updated or current, "handoff": handoff, "webhook": response}
@router.post("/{recording_id}/convert")
async def convert_recording(recording_id: str):
recordings = repo.list_recordings()
current = next((item for item in recordings if str(item.get("id")) == str(recording_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Recording not found.")
wav_path = current.get("stored_path")
if not wav_path or not Path(wav_path).exists():
raise HTTPException(status_code=400, detail="Source WAV file is missing.")
mp3_path = current.get("mp3_path") or media.planned_mp3_path(wav_path)
result = media.convert_wav_to_mp3(wav_path, mp3_path)
updates = {
"conversion_status": result["status"],
"conversion_result": result,
"updated_at": store.now(),
}
if result["status"] == "ok":
updates["mp3_path"] = mp3_path
updates["delete_source_result"] = media.delete_source_if_requested(wav_path, bool(current.get("delete_wav_after_success")))
updates["status"] = "converted"
updated = repo.update_recording(recording_id, updates)
return {"status": result["status"], "recording": updated or current, "conversion": result}

View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter
from app.services.d2l import D2LService
router = APIRouter(prefix="/api/d2l", tags=["d2l"])
service = D2LService()
@router.get("/courses")
async def get_courses():
return {"items": service.get_courses()}
@router.get("/assignments")
async def get_course_assignments(course_id: str):
return {"items": service.get_assignments(course_id), "course_id": course_id}
@router.get("/grades")
async def get_course_grades(course_id: str):
return service.get_grades(course_id)
@router.post("/sync")
async def sync_d2l():
return service.sync_to_store()

View File

@@ -0,0 +1,24 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request, "title": "Doris Schoolhouse"})
@router.get("/assignments/upload", response_class=HTMLResponse)
async def assignment_upload(request: Request):
return templates.TemplateResponse("assignment_upload.html", {"request": request, "title": "Assignment Upload"})
@router.get("/recordings/upload", response_class=HTMLResponse)
async def recording_upload(request: Request):
return templates.TemplateResponse("recording_upload.html", {"request": request, "title": "Recording Upload"})