From 0ea5d9a77b50922308712946400fe8fd4171e129 Mon Sep 17 00:00:00 2001 From: James Person Date: Thu, 12 Mar 2026 12:08:13 -0400 Subject: [PATCH 1/9] Resubmission - Allow users to copy their old report files and form content. --- backend/audit/forms.py | 29 ++++ .../audit/templates/audit/upload-report.html | 16 ++ backend/audit/views/upload_report_view.py | 140 ++++++++++++++---- backend/dissemination/file_downloads.py | 32 +++- backend/static/js/upload-report.js | 28 ++++ 5 files changed, 215 insertions(+), 30 deletions(-) diff --git a/backend/audit/forms.py b/backend/audit/forms.py index bfdd6073f4..5967f5feac 100644 --- a/backend/audit/forms.py +++ b/backend/audit/forms.py @@ -22,6 +22,35 @@ class UploadReportForm(forms.Form): schedule_prior_findings = forms.IntegerField(initial=0, required=False, min_value=1) CAP_page = forms.IntegerField(initial=0, required=False, min_value=1) upload_report = forms.FileField() + keep_previous_report = forms.BooleanField(required=False) + + def clean(self): + """ + For 'Original' submissions, these fields do not really need cleaning - they're integers, so the default errors will do. + The view will handle erroneous report uploads. + + For resubmissions, a user might upload an erroneous report and then indicate to keep their previous report. + Then, we want to clear all errors. + """ + cleaned_data = super().clean() + if cleaned_data.get("keep_previous_report"): + for field in [ + "financial_statements", + "financial_statements_opinion", + "schedule_expenditures", + "schedule_expenditures_opinion", + "uniform_guidance_control", + "uniform_guidance_compliance", + "GAS_control", + "GAS_compliance", + "schedule_findings", + "schedule_prior_findings", + "CAP_page", + "upload_report", + ]: + if field in self.errors: + del self.errors[field] + return cleaned_data def _kvpair(info): diff --git a/backend/audit/templates/audit/upload-report.html b/backend/audit/templates/audit/upload-report.html index 6d7e11d50e..843ed3540b 100644 --- a/backend/audit/templates/audit/upload-report.html +++ b/backend/audit/templates/audit/upload-report.html @@ -96,6 +96,22 @@

{% if already_submitted %}Re-upload{% else

{% endif %} + {% if is_resubmission %} +
+ + +
+ {% endif %} + + {% if form.non_field_errors %} + {{ form.non_field_errors|striptags }} + {% endif %} + diff --git a/backend/audit/views/upload_report_view.py b/backend/audit/views/upload_report_view.py index 5e29276f23..58ea5f9f09 100644 --- a/backend/audit/views/upload_report_view.py +++ b/backend/audit/views/upload_report_view.py @@ -16,6 +16,7 @@ ) from audit.forms import UploadReportForm from audit.models.constants import EventType +from dissemination.file_downloads import copy_file logging.basicConfig( format="%(asctime)s %(levelname)-8s %(module)s:%(lineno)d %(message)s" @@ -97,11 +98,16 @@ def get(self, request, *args, **kwargs): sar = SingleAuditReportFile.objects.filter(sac_id=sac.id) if sar.exists(): sar = sar.latest("date_created") - current_info = { "cleaned_data": getattr(sar, "component_page_numbers", {}), } + previous_report_id = ( + sac.resubmission_meta.get("previous_report_id") + if sac.resubmission_meta + else None + ) + context = { "auditee_name": sac.auditee_name, "report_id": report_id, @@ -109,6 +115,7 @@ def get(self, request, *args, **kwargs): "user_provided_organization_type": sac.user_provided_organization_type, "page_number_inputs": self.page_number_inputs(), "already_submitted": True if sar else False, + "is_resubmission": True if previous_report_id else False, "form": current_info, } @@ -130,6 +137,12 @@ def post(self, request, *args, **kwargs): audit = Audit.objects.find_audit_or_none(report_id) form = UploadReportForm(request.POST, request.FILES) + previous_report_id = ( + sac.resubmission_meta.get("previous_report_id") + if sac.resubmission_meta + else None + ) + # Standard context always needed on this page context = { "auditee_name": sac.auditee_name, @@ -137,43 +150,68 @@ def post(self, request, *args, **kwargs): "auditee_uei": sac.auditee_uei, "user_provided_organization_type": sac.user_provided_organization_type, "page_number_inputs": self.page_number_inputs(), + "is_resubmission": bool(previous_report_id), } - if form.is_valid(): - file = request.FILES["upload_report"] - # SOT TODO: The audit.id, per comment above, MUST exist at this point. - # Pass the audit ID if we have one. Otherwise, None is valid. Revert to just `audit.id` after TODO. - sar_file = self.reformat_form_data( - file, form, sac.id, audit.id if audit else None - ) - - # Try to save the formatted form data. If it fails on the file - # (encryption issues, file size issues), add and pass back the file errors. - # If it fails due to something else, re-raise it to be handled further below. + if not form.is_valid(): + # Form is invalid, show the errors. + return render(request, "audit/upload-report.html", context | {"form": form}) + + # Resubmissions - if the user is keeping their previously submitted report + if form.cleaned_data.get("keep_previous_report") and previous_report_id: try: - sar_file.full_clean() - sar_file.save( - event_user=request.user, - event_type=EventType.AUDIT_REPORT_PDF_UPDATED, + self._copy_previous_report( + previous_report_id=previous_report_id, + current_sac=sac, + current_audit=audit, + request=request, ) - - self._save_audit( - report_id=report_id, sar_file=sar_file, request=request + except Exception as err: + form.add_error( + None, + f"Unable to copy the previous report: {err}", ) - except ValidationError as err: - for issue in err.error_dict.get("file"): - form.add_error("upload_report", issue) return render( - request, "audit/upload-report.html", context | {"form": form} + request, + "audit/upload-report.html", + context | {"form": form}, ) - except Exception as err: - raise err + return redirect( + reverse("audit:SubmissionProgress", args=[report_id]) + ) - # Form data saved, redirect to checklist. - return redirect(reverse("audit:SubmissionProgress", args=[report_id])) + # "Original" or new report submissions + file = request.FILES["upload_report"] + # SOT TODO: The audit.id, per comment above, MUST exist at this point. + # Pass the audit ID if we have one. Otherwise, None is valid. Revert to just `audit.id` after TODO. + sar_file = self.reformat_form_data( + file, form, sac.id, audit.id if audit else None + ) - # form.is_valid() failed (standard Django issues). Show the errors. - return render(request, "audit/upload-report.html", context | {"form": form}) + # Try to save the formatted form data. If it fails on the file + # (encryption issues, file size issues), add and pass back the file errors. + # If it fails due to something else, re-raise it to be handled further below. + try: + sar_file.full_clean() + sar_file.save( + event_user=request.user, + event_type=EventType.AUDIT_REPORT_PDF_UPDATED, + ) + + self._save_audit( + report_id=report_id, sar_file=sar_file, request=request + ) + except ValidationError as err: + for issue in err.error_dict.get("file"): + form.add_error("upload_report", issue) + return render( + request, "audit/upload-report.html", context | {"form": form} + ) + except Exception as err: + raise err + + # Form data saved, redirect to checklist. + return redirect(reverse("audit:SubmissionProgress", args=[report_id])) except SingleAuditChecklist.DoesNotExist as err: raise PermissionDenied("You do not have access to this audit.") from err @@ -239,3 +277,47 @@ def _save_audit(report_id, sar_file, request): event_user=request.user, event_type=EventType.AUDIT_REPORT_PDF_UPDATED, ) + + @staticmethod + def _copy_previous_report(previous_report_id, current_sac, current_audit, request): + """ + Copy the SingleAuditReportFile and the associated s3 object from the + previous submission to the current resubmission. + """ + previous_sac = SingleAuditChecklist.objects.get(report_id=previous_report_id) + previous_sar = SingleAuditReportFile.objects.filter(sac=previous_sac).latest( + "date_created" + ) + + # Copy the S3 object + source_key = f"singleauditreport/{previous_sac.report_id}.pdf" + dest_key = f"singleauditreport/{current_sac.report_id}.pdf" + copy_file(source_key, dest_key) + + # Copy the SingleAuditReportFile row + new_sar = SingleAuditReportFile( + file=dest_key, + filename=f"{current_sac.report_id}.pdf", + sac=current_sac, + audit=current_audit, + component_page_numbers=previous_sar.component_page_numbers, + ) + new_sar.save( + event_user=request.user, + event_type=EventType.AUDIT_REPORT_PDF_UPDATED, + ) + + # Mirror onto the Audit model if it exists + if current_audit: + current_audit.audit.update( + { + "file_information": { + "filename": new_sar.filename, + "pages": new_sar.component_page_numbers, + } + } + ) + current_audit.save( + event_user=request.user, + event_type=EventType.AUDIT_REPORT_PDF_UPDATED, + ) diff --git a/backend/dissemination/file_downloads.py b/backend/dissemination/file_downloads.py index 9f2c65028e..d8d33c7c0c 100644 --- a/backend/dissemination/file_downloads.py +++ b/backend/dissemination/file_downloads.py @@ -91,7 +91,7 @@ def file_exists(filename, show_warning=True): return True except ClientError: if show_warning: - logger.warn(f"Unable to locate file {filename} in S3!") + logger.warning(f"Unable to locate file {filename} in S3!") return False @@ -125,3 +125,33 @@ def get_download_url(filename): raise Http404("File not found") except ClientError: raise Http404("File not found") + + +def copy_file(source_key, dest_key): + """ + Copy a file within the private S3 bucket from source_key to dest_key. + Returns True on success, raises Http404 if the source does not exist. + """ + if not file_exists(source_key): + raise Http404(f"Unable to locate file {source_key} in S3!") + + s3_client = boto3_client( + service_name="s3", + region_name=settings.AWS_S3_PRIVATE_REGION_NAME, + aws_access_key_id=settings.AWS_PRIVATE_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_PRIVATE_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_PRIVATE_INTERNAL_ENDPOINT, + config=Config(signature_version="s3v4"), + ) + + source_bucket = settings.AWS_PRIVATE_STORAGE_BUCKET_NAME + try: + s3_client.copy_object( + Bucket=source_bucket, + Key=dest_key, + CopySource={"Bucket": source_bucket, "Key": source_key}, + ) + return True + except ClientError as err: + logger.error(f"Failed to copy S3 object {source_key} -> {dest_key}: {err}") + raise Http404("Failed to copy file") from err diff --git a/backend/static/js/upload-report.js b/backend/static/js/upload-report.js index 1575485075..4a869626bd 100644 --- a/backend/static/js/upload-report.js +++ b/backend/static/js/upload-report.js @@ -2,6 +2,7 @@ var FORM = document.getElementById('upload-report__form'); const continue_button = document.getElementById(`continue`); //