Files
truenas-stacks/home/doris-schoolhouse/app/routes/api_assignments.py
2026-05-16 19:13:10 +00:00

251 lines
10 KiB
Python

import hashlib
from pathlib import Path
from typing import Any
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse
from app.config import settings
from app.services.bundles import SubmissionBundleService
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()
bundles = SubmissionBundleService()
def _confirm_submission_to_paperless(current: dict) -> dict:
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
artifact_kind = handoff.get("artifact_kind") or "document"
if artifact_kind == "source_bundle":
paperless_response = paperless.upload_source_bundle_receipt(current)
enrichment = paperless.enrich_direct_submission(current, paperless_response)
response = {
"ok": True,
"status_code": 200,
"body": paperless_response,
"mode": "direct-paperless",
"enrichment": enrichment,
}
task_id = paperless_response.get("task_id") or paperless_response.get("id")
body_obj = {
"paperless_document_id": enrichment.get("document_id"),
"paperless_title": enrichment.get("title") or paperless.build_submission_title(current),
"paperless_task_id": task_id,
"paperless_metadata": enrichment.get("metadata_payload"),
}
ok = True
else:
if handoff.get("artifact_kind"):
fields["artifact_kind"] = str(handoff["artifact_kind"])
if handoff.get("file_count"):
fields["file_count"] = str(handoff["file_count"])
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
updates = {
"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"),
"paperless_task_id": body_obj.get("paperless_task_id"),
}
updated = repo.update_submission(str(current.get("id")), updates) or current
grade_note = None
if ok and updated.get("assignment_id"):
grade_note = paperless.append_grade_note_if_available(str(updated.get("assignment_id")))
return {
"status": "ok" if ok else "error",
"submission": updated,
"submissions": [updated],
"handoff": handoff,
"webhook": [response],
"grade_note": grade_note,
"file_count": handoff.get("file_count") or 1,
}
@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(
request: Request,
files: list[UploadFile] | None = File(None),
file: UploadFile | None = File(None),
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.")
uploads = list(files or [])
if file is not None:
uploads.append(file)
if not uploads:
raise HTTPException(status_code=400, detail="At least one upload is required.")
upload_root = Path(settings.upload_tmp_dir)
upload_root.mkdir(parents=True, exist_ok=True)
course = next((item for item in d2l.get_courses() if str(item.get("d2l_course_id") or item.get("id")) == str(class_id)), None)
duplicate_names: dict[str, int] = {}
prepared_uploads: list[dict[str, Any]] = []
for index, upload in enumerate(uploads, start=1):
safe_name = Path(upload.filename or f"upload-{index}.bin").name
duplicate_count = duplicate_names.get(safe_name, 0)
duplicate_names[safe_name] = duplicate_count + 1
if duplicate_count:
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
safe_name = f"{stem}-{duplicate_count + 1}{suffix}"
payload = await upload.read()
prepared_uploads.append({
"filename": safe_name,
"payload": payload,
"mime_type": upload.content_type or "application/octet-stream",
})
submission_id = store.new_id("submission")
bundle = bundles.build_bundle(
upload_root=upload_root,
submission_id=submission_id,
class_name=class_name,
assignment_name=assignment_name,
version_label=version,
uploads=prepared_uploads,
notes=notes,
)
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,
"artifact_kind": bundle["artifact_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": bundle["bundle_filename"],
"stored_path": bundle["bundle_path"],
"stored_paths": [item["stored_path"] for item in bundle["file_manifest_json"]],
"bundle_path": bundle["bundle_path"],
"bundle_filename": bundle["bundle_filename"],
"source_listing_path": bundle["source_listing_path"],
"receipt_pdf_path": bundle["receipt_pdf_path"],
"file_manifest_json": bundle["file_manifest_json"],
"mime_type": bundle["bundle_mime_type"],
"file_size_bytes": bundle["bundle_size_bytes"],
"checksum_sha256": bundle["bundle_checksum_sha256"],
"status": "uploaded-local",
"received_at": store.now(),
"batch_id": None,
"file_count": bundle["file_count"],
"file_index": None,
"combined_source_checksum_sha256": bundle["combined_source_checksum_sha256"],
}
record["paperless_handoff"] = paperless.assignment_intake_payload(record)
created = repo.create_submission(record)
accept = request.headers.get("accept", "")
auto_confirmed = None
if "text/html" in accept:
auto_confirmed = _confirm_submission_to_paperless(created)
created = auto_confirmed["submission"]
response_payload = {
"status": "ok",
"submission": created,
"submissions": [created],
"batch_id": None,
"file_count": bundle["file_count"],
"paperless": auto_confirmed,
"backend": repo.backend,
}
if "text/html" in accept:
redirect_url = "/assignments/upload?submitted=" + str(created["id"]) + "&file_count=" + str(bundle["file_count"]) + "&artifact_kind=" + str(created.get("artifact_kind", "document")) + "&paperless_status=" + str(created.get("status", "uploaded-local"))
return RedirectResponse(url=redirect_url, status_code=303)
return response_payload
@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.")
return _confirm_submission_to_paperless(current)
@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}