-
Notifications
You must be signed in to change notification settings - Fork 91
ENG-2526: Reprocess DSRs via /resubmit instead of /retry #8199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| type: Changed | ||
| description: The privacy request Reprocess action now picks up integrations added or removed after the original failure; the request is now resubmitted instead of retried (DAG rebuilt, prior execution logs cleared) instead of retried against the stale DAG. Adds a new `POST /privacy-request/bulk/resubmit` endpoint to support bulk reprocessing. | ||
| pr: 8199 | ||
| labels: ["high-risk"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,7 @@ | |
| PRIVACY_REQUEST_AUTHENTICATED, | ||
| PRIVACY_REQUEST_BATCH_EMAIL_SEND, | ||
| PRIVACY_REQUEST_BULK_FINALIZE, | ||
| PRIVACY_REQUEST_BULK_RESUBMIT, | ||
| PRIVACY_REQUEST_BULK_RETRY, | ||
| PRIVACY_REQUEST_BULK_SOFT_DELETE, | ||
| PRIVACY_REQUEST_CANCEL, | ||
|
|
@@ -2088,6 +2089,95 @@ def get_test_privacy_request_results( | |
| } | ||
|
|
||
|
|
||
| @router.post( | ||
| PRIVACY_REQUEST_BULK_RESUBMIT, | ||
| status_code=HTTP_200_OK, | ||
| response_model=BulkPostPrivacyRequests, | ||
| dependencies=[Security(verify_oauth_client, scopes=[PRIVACY_REQUEST_CREATE])], | ||
| ) | ||
| def bulk_resubmit_privacy_requests( | ||
| privacy_requests: PrivacyRequestBulkSelection, | ||
| *, | ||
| db: Session = Depends(deps.get_db), | ||
| privacy_request_service: PrivacyRequestService = Depends( | ||
| get_privacy_request_service | ||
| ), | ||
| ) -> BulkPostPrivacyRequests: | ||
| """ | ||
| Bulk resubmit privacy requests. Each request is deleted and a new privacy | ||
| request is created in its place, rebuilding the execution DAG from the | ||
| current integration configuration. | ||
|
|
||
| You can either provide explicit request_ids OR use filters to select privacy requests. | ||
| When using filters, you can optionally exclude specific IDs via exclude_ids. | ||
|
|
||
| For backwards compatibility, a plain list of request IDs is also accepted. | ||
| """ | ||
| succeeded: List[PrivacyRequestResponse] = [] | ||
| failed: List[Dict[str, Any]] = [] | ||
|
|
||
| try: | ||
| request_ids = privacy_request_service.resolve_request_ids(privacy_requests) | ||
| except ValueError as exc: | ||
| raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(exc)) from exc | ||
| batches = privacy_request_service.get_batches_for_bulk_operation(request_ids) | ||
|
|
||
| for batch in batches: | ||
| privacy_requests_dict = { | ||
| pr.id: pr | ||
| for pr in PrivacyRequest.query_without_large_columns(db) | ||
| .filter(PrivacyRequest.id.in_(batch)) | ||
| .all() | ||
| } | ||
|
|
||
| for privacy_request_id in batch: | ||
| privacy_request = privacy_requests_dict.get(privacy_request_id) | ||
|
|
||
| if not privacy_request: | ||
| failed.append( | ||
| { | ||
| "message": f"No privacy request found with id '{privacy_request_id}'", | ||
| "data": {"privacy_request_id": privacy_request_id}, | ||
| } | ||
| ) | ||
| continue | ||
|
|
||
| if privacy_request.deleted_at is not None: | ||
| failed.append( | ||
| { | ||
| "message": "Cannot resubmit a deleted privacy request", | ||
| "data": {"privacy_request_id": privacy_request_id}, | ||
| } | ||
| ) | ||
| continue | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The only pre-call guard here is Resubmitting an Consider adding an explicit status guard (analogous to the one in if privacy_request.status not in (PrivacyRequestStatus.error, PrivacyRequestStatus.paused, ...):
failed.append(...)
continue |
||
|
|
||
| try: | ||
| resubmitted = privacy_request_service.resubmit_privacy_request( | ||
| privacy_request_id | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The batch pre-fetch (lines 2155–2160) loads each This is the same pattern as |
||
| except FidesopsException as exc: | ||
| failed.append( | ||
| { | ||
| "message": exc.message, | ||
| "data": {"privacy_request_id": privacy_request_id}, | ||
| } | ||
| ) | ||
| continue | ||
|
|
||
| if resubmitted is None: | ||
| failed.append( | ||
| { | ||
| "message": f"No privacy request found with id '{privacy_request_id}'", | ||
| "data": {"privacy_request_id": privacy_request_id}, | ||
| } | ||
| ) | ||
| continue | ||
|
|
||
| succeeded.append(resubmitted) # type: ignore[arg-type] | ||
|
|
||
| return BulkPostPrivacyRequests(succeeded=succeeded, failed=failed) | ||
|
|
||
|
|
||
| @router.post( | ||
| PRIVACY_REQUEST_RESUBMIT, | ||
| dependencies=[Security(verify_oauth_client, scopes=[PRIVACY_REQUEST_CREATE])], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,6 +104,7 @@ | |
| PRIVACY_REQUEST_AUTHENTICATED, | ||
| PRIVACY_REQUEST_BATCH_EMAIL_SEND, | ||
| PRIVACY_REQUEST_BULK_FINALIZE, | ||
| PRIVACY_REQUEST_BULK_RESUBMIT, | ||
| PRIVACY_REQUEST_BULK_RETRY, | ||
| PRIVACY_REQUEST_BULK_SOFT_DELETE, | ||
| PRIVACY_REQUEST_CANCEL, | ||
|
|
@@ -9931,6 +9932,123 @@ def test_resubmit_privacy_request( | |
| assert response.status_code == HTTP_200_OK | ||
|
|
||
|
|
||
| class TestBulkResubmitPrivacyRequests: | ||
| @pytest.fixture(scope="function") | ||
| def url(self): | ||
| return f"{V1_URL_PREFIX}{PRIVACY_REQUEST_BULK_RESUBMIT}" | ||
|
|
||
| def test_bulk_resubmit_not_authenticated(self, url, api_client): | ||
| response = api_client.post(url, json=["1234", "5678"], headers={}) | ||
| assert response.status_code == 401 | ||
|
|
||
| def test_bulk_resubmit_wrong_scope(self, url, api_client, generate_auth_header): | ||
| # PRIVACY_REQUEST_CALLBACK_RESUME is sufficient for /bulk/retry but not /bulk/resubmit | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CALLBACK_RESUME]) | ||
| response = api_client.post(url, json=["1234", "5678"], headers=auth_header) | ||
| assert response.status_code == 403 | ||
|
|
||
| def test_bulk_resubmit_unknown_ids(self, url, api_client, generate_auth_header): | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CREATE]) | ||
| data = ["does-not-exist-1", "does-not-exist-2"] | ||
|
|
||
| response = api_client.post(url, json=data, headers=auth_header) | ||
| assert response.status_code == 200 | ||
|
|
||
| body = response.json() | ||
| assert body["succeeded"] == [] | ||
| failed_ids = [x["data"]["privacy_request_id"] for x in body["failed"]] | ||
| assert sorted(failed_ids) == sorted(data) | ||
|
|
||
| def test_bulk_resubmit_deleted_request( | ||
| self, url, api_client, generate_auth_header, soft_deleted_privacy_request | ||
| ): | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CREATE]) | ||
| data = [soft_deleted_privacy_request.id] | ||
|
|
||
| response = api_client.post(url, json=data, headers=auth_header) | ||
| assert response.status_code == 200 | ||
|
|
||
| body = response.json() | ||
| assert body["succeeded"] == [] | ||
| assert body["failed"] == [ | ||
| { | ||
| "message": "Cannot resubmit a deleted privacy request", | ||
| "data": {"privacy_request_id": soft_deleted_privacy_request.id}, | ||
| } | ||
| ] | ||
|
|
||
| def test_bulk_resubmit_completed_request_rejected( | ||
| self, url, api_client, generate_auth_header, db, privacy_requests | ||
| ): | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CREATE]) | ||
| privacy_requests[0].status = PrivacyRequestStatus.complete | ||
| privacy_requests[0].save(db) | ||
| data = [privacy_requests[0].id] | ||
|
|
||
| response = api_client.post(url, json=data, headers=auth_header) | ||
| assert response.status_code == 200 | ||
|
|
||
| body = response.json() | ||
| assert body["succeeded"] == [] | ||
| assert len(body["failed"]) == 1 | ||
| assert body["failed"][0]["data"]["privacy_request_id"] == privacy_requests[0].id | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The assertion here only checks assert body["failed"][0]["message"] == "Cannot resubmit a complete privacy request"Also worth considering: there is no test for resubmitting a request in |
||
|
|
||
| @mock.patch( | ||
| "fides.api.service.privacy_request.request_runner_service.run_privacy_request.apply_async" | ||
| ) | ||
| def test_bulk_resubmit_success( | ||
| self, | ||
| submit_mock, | ||
| url, | ||
| api_client, | ||
| generate_auth_header, | ||
| db, | ||
| privacy_requests, | ||
| ): | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CREATE]) | ||
| privacy_requests[0].status = PrivacyRequestStatus.error | ||
| privacy_requests[0].save(db) | ||
| data = [privacy_requests[0].id] | ||
|
|
||
| response = api_client.post(url, json=data, headers=auth_header) | ||
| assert response.status_code == 200 | ||
|
|
||
| body = response.json() | ||
| assert body["failed"] == [] | ||
| succeeded_ids = [x["id"] for x in body["succeeded"]] | ||
| # Resubmit preserves the original id (uses PrivacyRequestResubmit.id) | ||
| assert privacy_requests[0].id in succeeded_ids | ||
|
|
||
| @mock.patch( | ||
| "fides.api.service.privacy_request.request_runner_service.run_privacy_request.apply_async" | ||
| ) | ||
| def test_bulk_resubmit_mixed_result( | ||
| self, | ||
| submit_mock, | ||
| url, | ||
| api_client, | ||
| generate_auth_header, | ||
| db, | ||
| privacy_requests, | ||
| ): | ||
| auth_header = generate_auth_header(scopes=[PRIVACY_REQUEST_CREATE]) | ||
| privacy_requests[0].status = PrivacyRequestStatus.error | ||
| privacy_requests[0].save(db) | ||
|
|
||
| bad_id = "bad_test_id" | ||
| data = [privacy_requests[0].id, bad_id] | ||
|
|
||
| response = api_client.post(url, json=data, headers=auth_header) | ||
| assert response.status_code == 200 | ||
|
|
||
| body = response.json() | ||
| succeeded_ids = [x["id"] for x in body["succeeded"]] | ||
| failed_ids = [x["data"]["privacy_request_id"] for x in body["failed"]] | ||
|
|
||
| assert privacy_requests[0].id in succeeded_ids | ||
| assert bad_id in failed_ids | ||
|
|
||
|
|
||
| class TestSendBatchEmailIntegrations: | ||
| @pytest.fixture(scope="function") | ||
| def url(self): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts:664useBulkRetryMutation(line 664) anduseRetryMutation(line 675) are still exported here, but after this PR no component imports them — their call sites inReprocessButton.tsxanduseReprocessPrivacyRequest.tshave been switched to the new resubmit hooks. The underlying/bulk/retryand/{id}/retrybackend endpoints still exist, so keeping these exports isn't wrong, but it may be worth a comment noting they're intentionally retained for future use, or removing them now to avoid confusion about which hooks are canonical.