diff --git a/.github/ISSUE_TEMPLATE/offboarding.md b/.github/ISSUE_TEMPLATE/offboarding.md
index b905930b87..6a4b44ec01 100644
--- a/.github/ISSUE_TEMPLATE/offboarding.md
+++ b/.github/ISSUE_TEMPLATE/offboarding.md
@@ -21,6 +21,8 @@ assignees: ''
- [ ] Make a PR to [remove the departing team member from the list of developers and managers](https://github.com/GSA-TTS/FAC/tree/main/terraform/meta/config.tf) with access to our spaces.
- [ ] [Remove the departing team member as a member of the FAC group in New Relic.](https://one.newrelic.com/admin-portal/organizations/users-list) (@GSA-TTS/fac-admins can do this)
- [ ] If they're leaving TTS altogether, also delete their account.
+- [ ] Remove the user from any test accounts (e.g. the Google Group that is used for Cypress test accounts) if they are in that group.
+- [ ] Remove from the API GG if they are a member.
**For product leads/owners, also...**
diff --git a/.github/workflows/failed-data-migration-reprocessor.yml b/.github/workflows/failed-data-migration-reprocessor.yml
new file mode 100644
index 0000000000..c9ec4b1483
--- /dev/null
+++ b/.github/workflows/failed-data-migration-reprocessor.yml
@@ -0,0 +1,50 @@
+---
+name: Failed data migration reprocessor
+on:
+ workflow_dispatch:
+ inputs:
+ environment:
+ required: true
+ type: choice
+ description: The environment the workflow should run on.
+ options:
+ - dev
+ - staging
+ - preview
+ year:
+ required: true
+ type: string
+ description: Provide audit year.
+ page_size:
+ required: true
+ type: string
+ description: Number of audit reports by page.
+ pages:
+ required: true
+ type: string
+ description: Comma-separated list of pages.
+ error_tag:
+ required: true
+ type: string
+ description: Error tag associated with failed migrations.
+
+jobs:
+ historic-data-migrator:
+ name: Generate and disseminate historic data in ${{ inputs.environment }} database
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment }}
+ env:
+ space: ${{ inputs.environment }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Run Django command to generate and disseminate historic data in ${{ inputs.environment }}
+ uses: cloud-gov/cg-cli-tools@main
+ with:
+ cf_username: ${{ secrets.CF_USERNAME }}
+ cf_password: ${{ secrets.CF_PASSWORD }}
+ cf_org: gsa-tts-oros-fac
+ cf_space: ${{ env.space }}
+ command: cf run-task gsa-fac -k 1G -m 1G --name failed_data_migration_reprocessor --command "python manage.py reprocess_failed_migration --year ${{ inputs.year }} --page_size ${{ inputs.page_size }} --pages ${{ inputs.pages }} --error_tag ${{ inputs.error_tag }}"
diff --git a/backend/audit/fixtures/basic.pdf b/backend/audit/fixtures/basic.pdf
index 3a0f8b7744..f03f1a93a3 100644
Binary files a/backend/audit/fixtures/basic.pdf and b/backend/audit/fixtures/basic.pdf differ
diff --git a/backend/audit/validators.py b/backend/audit/validators.py
index aa09bd10be..42cf28a488 100644
--- a/backend/audit/validators.py
+++ b/backend/audit/validators.py
@@ -526,6 +526,8 @@ def validate_single_audit_report_file_extension(file):
def validate_pdf_file_integrity(file):
"""Files must be readable PDFs"""
+ MIN_CHARARACTERS_IN_PDF = 6000
+
try:
reader = PdfReader(file)
@@ -538,13 +540,13 @@ def validate_pdf_file_integrity(file):
for page in reader.pages:
page_text = page.extract_text()
text_length += len(page_text)
- # If we find any characters, we're content.
- if text_length > 0:
+ # If we find enough characters, we're content.
+ if text_length >= MIN_CHARARACTERS_IN_PDF:
break
- if text_length == 0:
+ if text_length < MIN_CHARARACTERS_IN_PDF:
raise ValidationError(
- "We were unable to process the file you uploaded because it contains no readable text."
+ "We were unable to process the file you uploaded because it contains no readable text or too little text."
)
except ValidationError:
diff --git a/backend/audit/views/views.py b/backend/audit/views/views.py
index 6047007824..36db6afba4 100644
--- a/backend/audit/views/views.py
+++ b/backend/audit/views/views.py
@@ -56,6 +56,8 @@
validate_secondary_auditors_json,
)
+from dissemination.file_downloads import get_download_url, get_filename
+
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(module)s:%(lineno)d %(message)s"
)
@@ -204,11 +206,28 @@ def _save_audit_data(self, sac, form_section, audit_data):
setattr(sac, handler_info["field_name"], audit_data)
sac.save()
- # this is marked as csrf_exempt to enable by-hand testing via tools like Postman. Should be removed when the frontend form is implemented!
@method_decorator(csrf_exempt)
def dispatch(self, *args, **kwargs):
return super(ExcelFileHandlerView, self).dispatch(*args, **kwargs)
+ def get(self, request, *args, **kwargs):
+ """
+ Given a report ID and form section, redirect the caller to a download URL for the associated Excel file (if one exists)
+ """
+ try:
+ report_id = kwargs["report_id"]
+ form_section = kwargs["form_section"]
+
+ sac = SingleAuditChecklist.objects.get(report_id=report_id)
+
+ filename = get_filename(sac, form_section)
+ download_url = get_download_url(filename)
+
+ return redirect(download_url)
+ except SingleAuditChecklist.DoesNotExist as err:
+ logger.warning("no SingleAuditChecklist found with report ID %s", report_id)
+ raise PermissionDenied() from err
+
def post(self, request, *_args, **kwargs):
"""
Handle Excel file upload:
@@ -270,11 +289,6 @@ def post(self, request, *_args, **kwargs):
class SingleAuditReportFileHandlerView(
SingleAuditChecklistAccessRequiredMixin, generic.View
):
- # this is marked as csrf_exempt to enable by-hand testing via tools like Postman. Should be removed when the frontend form is implemented!
- @method_decorator(csrf_exempt)
- def dispatch(self, *args, **kwargs):
- return super(SingleAuditReportFileHandlerView, self).dispatch(*args, **kwargs)
-
def post(self, request, *args, **kwargs):
try:
report_id = kwargs["report_id"]
diff --git a/backend/census_historical_migration/end_to_end_core.py b/backend/census_historical_migration/end_to_end_core.py
index e5f87bc9b8..ea7fd4414c 100644
--- a/backend/census_historical_migration/end_to_end_core.py
+++ b/backend/census_historical_migration/end_to_end_core.py
@@ -113,44 +113,32 @@ def run_end_to_end(user, audit_header):
InspectionRecord.reset()
try:
sac = setup_sac(user, audit_header)
+ builder_loader = workbook_builder_loader(user, sac, audit_header)
- if sac.general_information["audit_type"] == "alternative-compliance-engagement":
- logger.info(
- f"Skipping ACE audit: {audit_header.DBKEY} {audit_header.AUDITYEAR}"
- )
- raise DataMigrationError(
- "Skipping ACE audit",
- "skip_ace_audit",
- )
- else:
- builder_loader = workbook_builder_loader(user, sac, audit_header)
-
- for section, fun in sections_to_handlers.items():
- builder_loader(fun, section)
-
- record_dummy_pdf_object(sac, user)
-
- errors = sac.validate_cross()
-
- if errors.get("errors"):
- raise CrossValidationError(
- f"{errors.get('errors')}", "cross_validation"
- )
-
- step_through_certifications(sac, audit_header)
-
- disseminate(sac)
-
- MigrationResult.append_success(f"{sac.report_id} created")
- record_migration_status(
- audit_header.AUDITYEAR,
- audit_header.DBKEY,
- )
- record_migration_transformations(
- audit_header.AUDITYEAR,
- audit_header.DBKEY,
- sac.report_id,
- )
+ for section, fun in sections_to_handlers.items():
+ builder_loader(fun, section)
+
+ record_dummy_pdf_object(sac, user)
+
+ errors = sac.validate_cross()
+
+ if errors.get("errors"):
+ raise CrossValidationError(f"{errors.get('errors')}", "cross_validation")
+
+ step_through_certifications(sac, audit_header)
+
+ disseminate(sac)
+
+ MigrationResult.append_success(f"{sac.report_id} created")
+ record_migration_status(
+ audit_header.AUDITYEAR,
+ audit_header.DBKEY,
+ )
+ record_migration_transformations(
+ audit_header.AUDITYEAR,
+ audit_header.DBKEY,
+ sac.report_id,
+ )
except Exception as exc:
handle_exception(exc, audit_header)
diff --git a/backend/census_historical_migration/historic_data_loader.py b/backend/census_historical_migration/historic_data_loader.py
index 697d62db24..7f07e319c0 100644
--- a/backend/census_historical_migration/historic_data_loader.py
+++ b/backend/census_historical_migration/historic_data_loader.py
@@ -27,21 +27,26 @@ def load_historic_data_for_year(audit_year, page_size, pages):
logger.info(
f"Processing page {page_number} with {page.object_list.count()} submissions."
)
+ total_count, error_count = perform_migration(
+ user, page.object_list, total_count, error_count
+ )
+ log_results(error_count, total_count)
- for submission in page.object_list:
- # Migrate a single submission
- run_end_to_end(user, submission)
-
- MigrationResult.append_summary(submission.AUDITYEAR, submission.DBKEY)
-
- total_count += 1
-
- if MigrationResult.has_errors():
- error_count += 1
- if total_count % 5 == 0:
- logger.info(f"Processed = {total_count}, Errors = {error_count}")
- log_results(error_count, total_count)
+def perform_migration(user, submissions, round_count, total_error_count):
+ total_count = round_count
+ error_count = total_error_count
+ for submission in submissions:
+ # Migrate a single submission
+ run_end_to_end(user, submission)
+ total_count += 1
+ if MigrationResult.has_errors():
+ error_count += 1
+ if total_count % 5 == 0:
+ logger.info(f"Processed = {total_count}, Errors = {error_count}")
+ MigrationResult.append_summary(submission.AUDITYEAR, submission.DBKEY)
+
+ return total_count, error_count
def log_results(error_count, total_count):
diff --git a/backend/census_historical_migration/management/commands/reprocess_failed_migration.py b/backend/census_historical_migration/management/commands/reprocess_failed_migration.py
new file mode 100644
index 0000000000..4791c29ba8
--- /dev/null
+++ b/backend/census_historical_migration/management/commands/reprocess_failed_migration.py
@@ -0,0 +1,47 @@
+from census_historical_migration.process_failed_migration import (
+ reprocess_failed_reports,
+)
+from census_historical_migration.sac_general_lib.utils import (
+ normalize_year_string_or_exit,
+)
+
+from django.core.management.base import BaseCommand
+
+import logging
+import sys
+
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.WARNING)
+
+
+class Command(BaseCommand):
+ help = """
+ Reprocess failed migration reports for a given year and error tag using pagination
+ Usage:
+ manage.py run_migration
+ --year Upload completed worksheet
A file has already been uploaded for this section. A successful reupload will overwrite your previous submission.
Last uploaded by {{last_uploaded_by}} at {{last_uploaded_at}} Download current workbook
{% endif %} diff --git a/backend/report_submission/views.py b/backend/report_submission/views.py index a45a13599c..e48951310f 100644 --- a/backend/report_submission/views.py +++ b/backend/report_submission/views.py @@ -9,7 +9,10 @@ import api.views +from audit.cross_validation import sac_validation_shape from audit.cross_validation.naming import NC, SECTION_NAMES as SN +from audit.cross_validation.submission_progress_check import section_completed_metadata + from audit.models import Access, SingleAuditChecklist, LateChangeError, SubmissionEvent from audit.validators import validate_general_information_json @@ -299,6 +302,10 @@ def get(self, request, *args, **kwargs): "DB_id": SN[NC.FEDERAL_AWARDS].snake_case, "instructions_url": instructions_base_url + "federal-awards/", "workbook_url": workbook_base_url + "federal-awards-workbook.xlsx", + # below URL handled as a special case because of inconsistent name usage in audit/urls.py and audit/cross_validation/naming.py + "existing_workbook_url": reverse( + "audit:FederalAwardsExpended", args=[report_id] + ), }, "notes-to-sefa": { "view_id": "notes-to-sefa", @@ -307,6 +314,9 @@ def get(self, request, *args, **kwargs): "DB_id": SN[NC.NOTES_TO_SEFA].snake_case, "instructions_url": instructions_base_url + "notes-to-sefa/", "workbook_url": workbook_base_url + "notes-to-sefa-workbook.xlsx", + "existing_workbook_url": reverse( + f"audit:{SN[NC.NOTES_TO_SEFA].camel_case}", args=[report_id] + ), }, "audit-findings": { "view_id": "audit-findings", @@ -318,6 +328,10 @@ def get(self, request, *args, **kwargs): "no_findings_disclaimer": True, "workbook_url": workbook_base_url + "federal-awards-audit-findings-workbook.xlsx", + "existing_workbook_url": reverse( + f"audit:{SN[NC.FINDINGS_UNIFORM_GUIDANCE].camel_case}", + args=[report_id], + ), }, "audit-findings-text": { "view_id": "audit-findings-text", @@ -328,6 +342,9 @@ def get(self, request, *args, **kwargs): + "federal-awards-audit-findings-text/", "no_findings_disclaimer": True, "workbook_url": workbook_base_url + "audit-findings-text-workbook.xlsx", + "existing_workbook_url": reverse( + f"audit:{SN[NC.FINDINGS_TEXT].camel_case}", args=[report_id] + ), }, "cap": { "view_id": "cap", @@ -338,6 +355,10 @@ def get(self, request, *args, **kwargs): "no_findings_disclaimer": True, "workbook_url": workbook_base_url + "corrective-action-plan-workbook.xlsx", + "existing_workbook_url": reverse( + f"audit:{SN[NC.CORRECTIVE_ACTION_PLAN].camel_case}", + args=[report_id], + ), }, "additional-ueis": { "view_id": "additional-ueis", @@ -346,6 +367,9 @@ def get(self, request, *args, **kwargs): "DB_id": SN[NC.ADDITIONAL_UEIS].snake_case, "instructions_url": instructions_base_url + "additional-ueis-workbook/", "workbook_url": workbook_base_url + "additional-ueis-workbook.xlsx", + "existing_workbook_url": reverse( + "audit:AdditionalUeis", args=[report_id] + ), }, "secondary-auditors": { "view_id": "secondary-auditors", @@ -355,6 +379,10 @@ def get(self, request, *args, **kwargs): "instructions_url": instructions_base_url + "secondary-auditors-workbook/", "workbook_url": workbook_base_url + "secondary-auditors-workbook.xlsx", + # below URL handled as a special case because of inconsistent name usage in audit/urls.py and audit/cross_validation/naming.py + "existing_workbook_url": reverse( + f"audit:{SN[NC.SECONDARY_AUDITORS].camel_case}", args=[report_id] + ), }, "additional-eins": { "view_id": "additional-eins", @@ -363,6 +391,10 @@ def get(self, request, *args, **kwargs): "DB_id": SN[NC.ADDITIONAL_EINS].snake_case, "instructions_url": instructions_base_url + "additional-eins-workbook/", "workbook_url": workbook_base_url + "additional-eins-workbook.xlsx", + # below URL handled as a special case because of inconsistent name usage in audit/urls.py and audit/cross_validation/naming.py + "existing_workbook_url": reverse( + "audit:AdditionalEins", args=[report_id] + ), }, } @@ -382,12 +414,20 @@ def get(self, request, *args, **kwargs): } # Using the current URL, append page specific context path_name = request.path.split("/")[2] + for item in additional_context[path_name]: context[item] = additional_context[path_name][item] try: context["already_submitted"] = getattr( sac, additional_context[path_name]["DB_id"] ) + + shaped_sac = sac_validation_shape(sac) + completed_metadata = section_completed_metadata(shaped_sac, path_name) + + context["last_uploaded_by"] = completed_metadata[0] + context["last_uploaded_at"] = completed_metadata[1] + except Exception: context["already_submitted"] = None