Finish Schoolhouse programming submission flow

This commit is contained in:
Fizzlepoof
2026-05-16 19:13:10 +00:00
parent d31de19227
commit e5a100d1a0
15 changed files with 753 additions and 74 deletions

View File

@@ -1,9 +1,12 @@
import hashlib
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Form, HTTPException, UploadFile
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
@@ -17,6 +20,74 @@ 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("")
@@ -41,7 +112,9 @@ async def list_submissions():
@router.post("/intake")
async def intake_assignment(
file: UploadFile,
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(...),
@@ -59,16 +132,43 @@ async def intake_assignment(
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)
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()
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"],
@@ -77,6 +177,7 @@ async def intake_assignment(
"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,
@@ -84,17 +185,44 @@ async def intake_assignment(
"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,
"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)
return {"status": "ok", "submission": created, "backend": repo.backend}
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")
@@ -103,33 +231,7 @@ async def confirm_assignment_paperless(submission_id: str):
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}
return _confirm_submission_to_paperless(current)
@router.post("/{assignment_id}/status")