|
20 | 20 | get_storage_service, |
21 | 21 | ) |
22 | 22 | from ..services import JobService, QueueService, S3URLService, StorageService |
| 23 | +from ..services.approval_service import ApprovalService |
23 | 24 | from ..services.document_processing_service import DocumentProcessingService |
24 | 25 | from ..services.metrics_service import jobs_submitted_total |
25 | 26 | from .schemas import ( |
@@ -711,3 +712,201 @@ async def get_ledger( |
711 | 712 | processing_duration_ms=0, |
712 | 713 | final_markdown_url=final_markdown_url, |
713 | 714 | ) |
| 715 | + |
| 716 | + |
| 717 | +# --------------------------------------------------------------------------- |
| 718 | +# PII approval / denial — by-job-id endpoints. |
| 719 | +# |
| 720 | +# The token-based equivalent lives at /api/v1/approval/{token}/decision in |
| 721 | +# api.approval. These by-job-id variants exist so machine clients (e.g. the |
| 722 | +# reflow-canvas-lti connector) can forward a faculty decision without having |
| 723 | +# to juggle an approval token — they already authenticated with an API key |
| 724 | +# and they already know the job_id from a prior status poll. |
| 725 | +# --------------------------------------------------------------------------- |
| 726 | + |
| 727 | + |
| 728 | +class PIIDecisionInput(BaseModel): |
| 729 | + """Input for the by-job-id PII decision endpoints.""" |
| 730 | + |
| 731 | + justification: str | None = Field( |
| 732 | + None, |
| 733 | + min_length=10, |
| 734 | + max_length=1000, |
| 735 | + description="Optional explanation for the decision (10–1000 chars when provided).", |
| 736 | + ) |
| 737 | + reviewed_by: str = Field( |
| 738 | + ..., |
| 739 | + min_length=3, |
| 740 | + description="Reviewer identifier (email or stable user id).", |
| 741 | + ) |
| 742 | + |
| 743 | + |
| 744 | +class PIIDecisionResponse(BaseModel): |
| 745 | + """Response for the by-job-id PII decision endpoints.""" |
| 746 | + |
| 747 | + message: str |
| 748 | + job_id: str |
| 749 | + decision: Literal["approved", "denied"] |
| 750 | + |
| 751 | + |
| 752 | +@router.post( |
| 753 | + "/{job_id}/pii/approve", |
| 754 | + response_model=PIIDecisionResponse, |
| 755 | + summary="Approve PII gate for a document (by job_id)", |
| 756 | + description=( |
| 757 | + "Records a faculty approval of the PII findings on a job that is in " |
| 758 | + "``awaiting_approval`` status, then resumes processing. Symmetric " |
| 759 | + "counterpart of ``POST /{job_id}/pii/deny``. Intended for connector " |
| 760 | + "consumption (the Canvas LTI connector forwards a faculty decision " |
| 761 | + "here after the panorama PII gate)." |
| 762 | + ), |
| 763 | +) |
| 764 | +async def approve_pii( |
| 765 | + job_id: str, |
| 766 | + body: PIIDecisionInput, |
| 767 | + background_tasks: BackgroundTasks, |
| 768 | + redis_client: Any = Depends(get_redis_client), |
| 769 | + storage_service: StorageService = Depends(get_storage_service), |
| 770 | + s3_url_service: S3URLService = Depends(get_s3_url_service), |
| 771 | +) -> PIIDecisionResponse: |
| 772 | + return await _process_pii_decision( |
| 773 | + job_id=job_id, |
| 774 | + decision="approved", |
| 775 | + body=body, |
| 776 | + background_tasks=background_tasks, |
| 777 | + redis_client=redis_client, |
| 778 | + storage_service=storage_service, |
| 779 | + s3_url_service=s3_url_service, |
| 780 | + ) |
| 781 | + |
| 782 | + |
| 783 | +@router.post( |
| 784 | + "/{job_id}/pii/deny", |
| 785 | + response_model=PIIDecisionResponse, |
| 786 | + summary="Deny PII gate for a document (by job_id)", |
| 787 | + description=( |
| 788 | + "Records a faculty denial of the PII findings on a job that is in " |
| 789 | + "``awaiting_approval`` status, then cleans up the document. Symmetric " |
| 790 | + "counterpart of ``POST /{job_id}/pii/approve``." |
| 791 | + ), |
| 792 | +) |
| 793 | +async def deny_pii( |
| 794 | + job_id: str, |
| 795 | + body: PIIDecisionInput, |
| 796 | + background_tasks: BackgroundTasks, |
| 797 | + redis_client: Any = Depends(get_redis_client), |
| 798 | + storage_service: StorageService = Depends(get_storage_service), |
| 799 | + s3_url_service: S3URLService = Depends(get_s3_url_service), |
| 800 | +) -> PIIDecisionResponse: |
| 801 | + return await _process_pii_decision( |
| 802 | + job_id=job_id, |
| 803 | + decision="denied", |
| 804 | + body=body, |
| 805 | + background_tasks=background_tasks, |
| 806 | + redis_client=redis_client, |
| 807 | + storage_service=storage_service, |
| 808 | + s3_url_service=s3_url_service, |
| 809 | + ) |
| 810 | + |
| 811 | + |
| 812 | +async def _process_pii_decision( |
| 813 | + *, |
| 814 | + job_id: str, |
| 815 | + decision: Literal["approved", "denied"], |
| 816 | + body: PIIDecisionInput, |
| 817 | + background_tasks: BackgroundTasks, |
| 818 | + redis_client: Any, |
| 819 | + storage_service: StorageService, |
| 820 | + s3_url_service: S3URLService, |
| 821 | +) -> PIIDecisionResponse: |
| 822 | + """Shared body for approve_pii + deny_pii. |
| 823 | +
|
| 824 | + Pre-validates the job's current status (404 if missing, 409 if not |
| 825 | + awaiting_approval) before invoking the approval_service. That contract is |
| 826 | + what the Canvas LTI connector relies on to surface "another instructor |
| 827 | + decided in a parallel tab" as a 409 to the operator. The token-based |
| 828 | + sibling endpoint in api.approval lets quick_approve / quick_deny set the |
| 829 | + status unconditionally; that's safe there because the token already |
| 830 | + proves the job is in awaiting_approval, but it isn't safe by job_id. |
| 831 | +
|
| 832 | + Connector contract pinned by: |
| 833 | + https://github.com/oshrizak/reflow-canvas-lti — the Canvas LTI |
| 834 | + connector tries this endpoint first when forwarding a faculty PII |
| 835 | + decision; on 404/405 it falls back to |
| 836 | + ``POST /api/v1/approval/{token}/decision`` so the connector keeps |
| 837 | + working against Core deployments that pre-date this PR. Both |
| 838 | + branches are covered in the connector's |
| 839 | + ``tests/integration/test_pii_decision.py`` |
| 840 | + (``test_pii_approve_prefers_by_job_id_endpoint`` + |
| 841 | + ``test_pii_approve_falls_back_to_token_endpoint_on_405``). |
| 842 | + Net effect once this PR merges + Core ships: connector drops the |
| 843 | + approval-token round-trip and submits decisions in one POST. |
| 844 | + """ |
| 845 | + job_service = JobService(redis_client) |
| 846 | + queue_service = QueueService(redis_client) |
| 847 | + approval_service = ApprovalService( |
| 848 | + redis_client=redis_client, |
| 849 | + s3_client=None, # Lazy-loaded inside the background task on the denial path. |
| 850 | + job_service=job_service, |
| 851 | + queue_service=queue_service, |
| 852 | + storage_service=storage_service, |
| 853 | + s3_url_service=s3_url_service, |
| 854 | + ) |
| 855 | + |
| 856 | + job = await job_service.get_job(job_id) |
| 857 | + if not job: |
| 858 | + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") |
| 859 | + |
| 860 | + current_status = str(job.get("status") or "") |
| 861 | + if current_status != "awaiting_approval": |
| 862 | + raise HTTPException( |
| 863 | + status_code=409, |
| 864 | + detail=( |
| 865 | + f"Job {job_id} is in {current_status!r}, not 'awaiting_approval' " |
| 866 | + f"— a decision was already recorded." |
| 867 | + ), |
| 868 | + ) |
| 869 | + |
| 870 | + s3_key = job.get("s3_key", "") |
| 871 | + justification = body.justification or "" |
| 872 | + |
| 873 | + try: |
| 874 | + if decision == "approved": |
| 875 | + await approval_service.quick_approve(job_id) |
| 876 | + background_tasks.add_task( |
| 877 | + approval_service.process_approval_background, |
| 878 | + job_id=job_id, |
| 879 | + s3_key=s3_key, |
| 880 | + justification=justification, |
| 881 | + reviewed_by=body.reviewed_by, |
| 882 | + ) |
| 883 | + return PIIDecisionResponse( |
| 884 | + message="Job approved - processing started", |
| 885 | + job_id=job_id, |
| 886 | + decision="approved", |
| 887 | + ) |
| 888 | + |
| 889 | + await approval_service.quick_deny(job_id) |
| 890 | + background_tasks.add_task( |
| 891 | + approval_service.process_denial_background, |
| 892 | + job_id=job_id, |
| 893 | + s3_key=s3_key, |
| 894 | + justification=justification, |
| 895 | + reviewed_by=body.reviewed_by, |
| 896 | + ) |
| 897 | + return PIIDecisionResponse( |
| 898 | + message="Job denied - cleanup started", |
| 899 | + job_id=job_id, |
| 900 | + decision="denied", |
| 901 | + ) |
| 902 | + except ValueError as exc: |
| 903 | + # Defensive: approval_service can raise ValueError on data checks |
| 904 | + # (e.g. missing s3_key). The job exists but isn't in a decidable |
| 905 | + # state — surface as 409 not 500. |
| 906 | + raise HTTPException(status_code=409, detail=str(exc)) from exc |
| 907 | + except Exception as exc: # noqa: BLE001 |
| 908 | + logger.exception("PII decision (%s) failed for job=%s", decision, job_id) |
| 909 | + raise HTTPException( |
| 910 | + status_code=500, |
| 911 | + detail=f"Failed to process PII decision: {exc}", |
| 912 | + ) from exc |
0 commit comments