import hashlib from pathlib import Path from typing import Any from urllib.parse import quote_plus 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" response: dict[str, Any] body_obj: dict[str, Any] ok = False error_detail = None try: 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 if not ok: error_detail = str(response.get("error") or response.get("body") or "Paperless confirmation failed.") except Exception as exc: error_detail = f"{type(exc).__name__}: {exc}" response = { "ok": False, "status_code": 500, "mode": "direct-paperless" if artifact_kind == "source_bundle" else "webhook", "error": error_detail, } body_obj = {} 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 updated["paperless_error_detail"] = error_detail 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, "error_detail": error_detail, } @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: status_value = str(created.get("status", "uploaded-local")) redirect_url = "/assignments/upload?submitted=" + str(created["id"]) + "&file_count=" + str(bundle["file_count"]) + "&artifact_kind=" + str(created.get("artifact_kind", "document")) + "&paperless_status=" + status_value if auto_confirmed and auto_confirmed.get("error_detail"): redirect_url += "&paperless_error=" + quote_plus(str(auto_confirmed["error_detail"])) 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}