diff --git a/backend/api/views.py b/backend/api/views.py index d5d9f3f0cd..e6a4bae0ce 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -4,6 +4,7 @@ from django.http import Http404, HttpResponse, JsonResponse from django.urls import reverse from django.views import View, generic +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from rest_framework import viewsets from rest_framework.authentication import BaseAuthentication @@ -23,6 +24,8 @@ UEISerializer, ) +UserModel = get_user_model() + AUDITEE_INFO_PREVIOUS_STEP_DATA_WE_NEED = [ "user_provided_organization_type", "met_spending_threshold", @@ -128,6 +131,7 @@ def access_and_submission_check(user, data): role="certifying_auditor_contact", email=serializer.data.get("certifying_auditor_contact"), ) + for contact in serializer.data.get("auditee_contacts"): Access.objects.create(sac=sac, role="editor", email=contact) for contact in serializer.data.get("auditor_contacts"): diff --git a/backend/audit/cross_validation/__init__.py b/backend/audit/cross_validation/__init__.py index 6a59fde1f8..6a4716e847 100644 --- a/backend/audit/cross_validation/__init__.py +++ b/backend/audit/cross_validation/__init__.py @@ -55,11 +55,13 @@ ] """ -from .auditee_ueis_match import auditee_ueis_match from .additional_ueis import additional_ueis +from .auditee_ueis_match import auditee_ueis_match +from .audit_findings import audit_findings from .sac_validation_shape import sac_validation_shape # noqa: F401 functions = [ + audit_findings, auditee_ueis_match, additional_ueis, ] diff --git a/backend/audit/cross_validation/audit_findings.py b/backend/audit/cross_validation/audit_findings.py new file mode 100644 index 0000000000..edfbd10f64 --- /dev/null +++ b/backend/audit/cross_validation/audit_findings.py @@ -0,0 +1,28 @@ +def audit_findings(sac_dict): + """ + Checks that the number of audit findings in the Federal Awards and Audit Findings + sections are consistent. + """ + all_sections = sac_dict["sf_sac_sections"] + awards_outer = all_sections.get("federal_awards", {}) + awards = awards_outer.get("federal_awards", []) if awards_outer else [] + finds_outer = all_sections.get("findings_text", {}) + findings = finds_outer.get("findings_text_entries", []) if finds_outer else [] + # If both empty, that's consistent: + if not awards and not findings: + return [] + if awards: + # How to determine if findings is required? Loop through the awards? + num_expected = sum(_.get("number_of_audit_findings", 0) for _ in findings) + # For now, just check for non-zero + if num_expected and not awards: + return [ + { + "error": "There are findings listed in Federal Awards " + " but none in Findings Text" + } + ] + + if False > 1: + return [{"error": "error message"}] + return [] diff --git a/backend/audit/cross_validation/errors.py b/backend/audit/cross_validation/errors.py index 999a78332d..fde0aed512 100644 --- a/backend/audit/cross_validation/errors.py +++ b/backend/audit/cross_validation/errors.py @@ -20,6 +20,10 @@ def err_auditee_ueis_match(): return "Not all auditee UEIs matched." +def err_awards_findings_but_no_findings_text(): + return "There are findings indicated in Federal Awards but" "none in Findings Text." + + def err_missing_tribal_data_sharing_consent(): return ( "As a tribal organization, you must complete the data " diff --git a/backend/audit/etl.py b/backend/audit/etl.py index b770dcca03..2a9e55f898 100644 --- a/backend/audit/etl.py +++ b/backend/audit/etl.py @@ -86,6 +86,19 @@ def load_findings(self): def load_federal_award(self): federal_awards = self.single_audit_checklist.federal_awards + report_id = self.single_audit_checklist.report_id + try: + general = General.objects.get(report_id=report_id) + except General.DoesNotExist: + logger.error( + f"General must be loaded before FederalAward. report_id = {report_id}" + ) + return + general.total_amount_expended = federal_awards["FederalAwards"].get( + "total_amount_expended" + ) + general.save() + for entry in federal_awards["FederalAwards"]["federal_awards"]: program = entry["program"] loan = entry["loan_or_loan_guarantee"] @@ -113,12 +126,10 @@ def load_federal_award(self): is_loan=loan["is_guaranteed"] == "Y", loan_balance=loan["loan_balance_at_audit_period_end"], is_direct=is_direct, - is_major=program["is_major"] == "Y", mp_audit_report_type=program["audit_report_type"], findings_count=program["number_of_audit_findings"], is_passthrough_award=is_passthrough, passthrough_amount=subrecipient_amount, - type_requirement=None, # TODO: What is this? ) federal_award.save() @@ -274,9 +285,7 @@ def load_general(self): entity_type=general_information["user_provided_organization_type"], number_months=general_information["audit_period_other_months"], audit_period_covered=general_information["audit_period_covered"], - is_report_required=None, # TODO: Notes say this hasn't been used since 2008. - total_fed_expenditures=None, # TODO: Where does this come from? - type_report_major_program=None, # TODO: Where does this come from? + total_amount_expended=None, # loaded from FederalAward type_audit_code="UG", is_public=self.single_audit_checklist.is_public, data_source="G-FAC", diff --git a/backend/audit/fixtures/json/federal-awards--test0001test--simple-pass.json b/backend/audit/fixtures/json/federal-awards--test0001test--simple-pass.json new file mode 100644 index 0000000000..0969498bea --- /dev/null +++ b/backend/audit/fixtures/json/federal-awards--test0001test--simple-pass.json @@ -0,0 +1,42 @@ +{ + "FederalAwards": { + "auditee_uei": "TEST0001TEST", + "total_amount_expended": 12345, + "federal_awards": [ + { + "program": { + "federal_agency_prefix": "42", + "three_digit_extension": "RD", + "additional_award_identification": 1234, + "program_name": "FLOWER DREAMING", + "is_major": "N", + "audit_report_type": "", + "number_of_audit_findings": 0, + "amount_expended": 500, + "federal_program_total": 500 + }, + "loan_or_loan_guarantee": { + "is_guaranteed": "N", + "loan_balance_at_audit_period_end": 0 + }, + "direct_or_indirect_award": { + "is_direct": "N", + "entities": [ + { + "passthrough_name": "Lothlórien", + "passthrough_identifying_number": "54321" + } + ] + }, + "cluster": { + "cluster_name": "N/A", + "cluster_total": 0 + }, + "subrecipients": { + "is_passed": "N" + }, + "award_reference": "AWARD-0001" + } + ] + } +} diff --git a/backend/audit/fixtures/json/general-information--test0001test--simple-pass.json b/backend/audit/fixtures/json/general-information--test0001test--simple-pass.json new file mode 100644 index 0000000000..8be3fc1308 --- /dev/null +++ b/backend/audit/fixtures/json/general-information--test0001test--simple-pass.json @@ -0,0 +1,35 @@ +{ + "ein": "123456789", + "audit_type": "single-audit", + "auditee_uei": "TEST0001TEST", + "auditee_zip": "94109", + "auditor_ein": "987654321", + "auditor_zip": "94109", + "auditee_city": "HAL", + "auditee_name": "IBM", + "auditor_city": "Rivendell", + "is_usa_based": true, + "auditee_email": "fictional.fac.tester+test@gsa.gov", + "auditee_phone": "5558675309", + "auditee_state": "CA", + "auditor_email": "fictional.fac.tester+test@gsa.gov", + "auditor_phone": "5558675309", + "auditor_state": "CA", + "auditor_country": "United States", + "auditor_firm_name": "Fellowship", + "audit_period_covered": "annual", + "auditee_contact_name": "Frodo Baggins", + "auditor_contact_name": "Sauron", + "auditee_contact_title": "Ringbearer", + "auditor_contact_title": "Dark Lord", + "multiple_eins_covered": false, + "multiple_ueis_covered": false, + "auditee_address_line_1": "HAL", + "auditor_address_line_1": "Main Hall", + "met_spending_threshold": true, + "auditee_fiscal_period_end": "2023-01-01", + "ein_not_an_ssn_attestation": true, + "auditee_fiscal_period_start": "2022-01-01", + "user_provided_organization_type": "non-profit", + "auditor_ein_not_an_ssn_attestation": true +} diff --git a/backend/audit/fixtures/single_audit_checklist.py b/backend/audit/fixtures/single_audit_checklist.py index 8258904b67..00c4960647 100644 --- a/backend/audit/fixtures/single_audit_checklist.py +++ b/backend/audit/fixtures/single_audit_checklist.py @@ -76,6 +76,47 @@ def _fake_general_information(auditee_name=None): return general_information +def fake_auditor_certification(): + """Create fake auditor confirmation form data.""" + fake = Faker() + data_step_1 = { + "is_OMB_limited": True, + "is_auditee_responsible": True, + "has_used_auditors_report": True, + "has_no_auditee_procedures": True, + "is_FAC_releasable": True, + } + data_step_2 = { + "auditor_name": fake.name(), + "auditor_title": fake.job(), + "auditor_certification_date_signed": fake.date(), + } + + return data_step_1, data_step_2 + + +def fake_auditee_certification(): + """Create fake auditor confirmation form data.""" + fake = Faker() + data_step_1 = { + "has_no_PII": True, + "has_no_BII": True, + "meets_2CFR_specifications": True, + "is_2CFR_compliant": True, + "is_complete_and_accurate": True, + "has_engaged_auditor": True, + "is_issued_and_signed": True, + "is_FAC_releasable": True, + } + data_step_2 = { + "auditee_name": fake.name(), + "auditee_title": fake.job(), + "auditee_certification_date_signed": fake.date(), + } + + return data_step_1, data_step_2 + + def _create_sac(user, auditee_name): """Create a single example SAC.""" SingleAuditChecklist = apps.get_model("audit.SingleAuditChecklist") diff --git a/backend/audit/forms.py b/backend/audit/forms.py index 627c1f1ba3..87ffd6f384 100644 --- a/backend/audit/forms.py +++ b/backend/audit/forms.py @@ -55,3 +55,34 @@ def clean_booleans(self): dollar_threshold = forms.IntegerField(min_value=1) is_low_risk_auditee = forms.MultipleChoiceField(choices=choices_YoN) agencies = forms.MultipleChoiceField(choices=choices_agencies) + + +class AuditorCertificationStep1Form(forms.Form): + is_OMB_limited = forms.BooleanField() + is_auditee_responsible = forms.BooleanField() + has_used_auditors_report = forms.BooleanField() + has_no_auditee_procedures = forms.BooleanField() + is_FAC_releasable = forms.BooleanField() + + +class AuditorCertificationStep2Form(forms.Form): + auditor_name = forms.CharField() + auditor_title = forms.CharField() + auditor_certification_date_signed = forms.DateField() + + +class AuditeeCertificationStep1Form(forms.Form): + has_no_PII = forms.BooleanField() + has_no_BII = forms.BooleanField() + meets_2CFR_specifications = forms.BooleanField() + is_2CFR_compliant = forms.BooleanField() + is_complete_and_accurate = forms.BooleanField() + has_engaged_auditor = forms.BooleanField() + is_issued_and_signed = forms.BooleanField() + is_FAC_releasable = forms.BooleanField() + + +class AuditeeCertificationStep2Form(forms.Form): + auditee_name = forms.CharField() + auditee_title = forms.CharField() + auditee_certification_date_signed = forms.DateField() diff --git a/backend/audit/migrations/0032_singleauditchecklist_auditee_certification_and_more.py b/backend/audit/migrations/0032_singleauditchecklist_auditee_certification_and_more.py new file mode 100644 index 0000000000..634e0c5884 --- /dev/null +++ b/backend/audit/migrations/0032_singleauditchecklist_auditee_certification_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.3 on 2023-08-07 18:21 + +import audit.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("audit", "0031_singleauditreportfile_component_page_numbers"), + ] + + operations = [ + migrations.AddField( + model_name="singleauditchecklist", + name="auditee_certification", + field=models.JSONField( + blank=True, + null=True, + validators=[audit.validators.validate_auditee_certification_json], + ), + ), + migrations.AddField( + model_name="singleauditchecklist", + name="auditor_certification", + field=models.JSONField( + blank=True, + null=True, + validators=[audit.validators.validate_auditor_certification_json], + ), + ), + ] diff --git a/backend/audit/migrations/0033_singleauditchecklist_tribal_data_consent.py b/backend/audit/migrations/0033_singleauditchecklist_tribal_data_consent.py new file mode 100644 index 0000000000..f8ab5dd211 --- /dev/null +++ b/backend/audit/migrations/0033_singleauditchecklist_tribal_data_consent.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.3 on 2023-08-10 19:49 + +import audit.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("audit", "0032_singleauditchecklist_auditee_certification_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="singleauditchecklist", + name="tribal_data_consent", + field=models.JSONField( + blank=True, + null=True, + validators=[audit.validators.validate_tribal_data_consent_json], + ), + ), + ] diff --git a/backend/audit/models.py b/backend/audit/models.py index fa3ef138c0..91b2a16c48 100644 --- a/backend/audit/models.py +++ b/backend/audit/models.py @@ -26,6 +26,9 @@ validate_secondary_auditors_json, validate_notes_to_sefa_json, validate_single_audit_report_file, + validate_auditor_certification_json, + validate_auditee_certification_json, + validate_tribal_data_consent_json, validate_audit_information_json, validate_component_page_numbers, ) @@ -273,6 +276,18 @@ class STATUS: blank=True, null=True, validators=[validate_notes_to_sefa_json] ) + auditor_certification = models.JSONField( + blank=True, null=True, validators=[validate_auditor_certification_json] + ) + + auditee_certification = models.JSONField( + blank=True, null=True, validators=[validate_auditee_certification_json] + ) + + tribal_data_consent = models.JSONField( + blank=True, null=True, validators=[validate_tribal_data_consent_json] + ) + def validate_full(self): """ Full validation, intended for use when the user indicates that the diff --git a/backend/audit/templates/audit/auditee-certification-step-1.html b/backend/audit/templates/audit/auditee-certification-step-1.html new file mode 100644 index 0000000000..462eee45c6 --- /dev/null +++ b/backend/audit/templates/audit/auditee-certification-step-1.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+ Auditee certification checklist +

Check the box next to each item confirm your report meets the requirements.

+

You must check all boxes must be checked in order to certify your single audit.

+
+ + + {{ form.errors.has_no_PII|striptags }} +
+
+ + + {{ form.errors.has_no_BII|striptags }} +
+
+ + + {{ form.errors.meets_2CFR_specifications|striptags }} +
+
+ + + {{ form.errors.is_2CFR_compliant|striptags }} +
+
+ + + {{ form.errors.is_complete_and_accurate|striptags }} +
+
+ + + {{ form.errors.has_engaged_auditor|striptags }} +
+
+ + + {{ form.errors.is_issued_and_signed|striptags }} +
+
+ + + {{ form.errors.is_FAC_releasable|striptags }} +
+
+
+ + Cancel +
+
+
+
+ {% include "audit-metadata.html" %} +{% endblock content %} diff --git a/backend/audit/templates/audit/auditee-certification-step-2.html b/backend/audit/templates/audit/auditee-certification-step-2.html new file mode 100644 index 0000000000..19d5e1a265 --- /dev/null +++ b/backend/audit/templates/audit/auditee-certification-step-2.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+

Auditee certification agreement

+

Agree and sign to auditee statement

+ + + + + +
mm/dd/yyyy
+
+ +
+
+ +
+
+
+ {% include "audit-metadata.html" %} +{% endblock content %} diff --git a/backend/audit/templates/audit/auditee-certification.html b/backend/audit/templates/audit/auditee-certification.html deleted file mode 100644 index ddaaa6684c..0000000000 --- a/backend/audit/templates/audit/auditee-certification.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
- {% csrf_token %} -
- - Auditee Certification - -

This is some text explaining that clicking the button below will provide auditee certification for this audit.

-

Audit {{ report_id }} has a submission status of: {{ submission_status }}

-

This action is taken by the CERTIFYING AUDITEE

- -
-
-
-
-{% endblock content %} \ No newline at end of file diff --git a/backend/audit/templates/audit/auditor-certification-step-1.html b/backend/audit/templates/audit/auditor-certification-step-1.html new file mode 100644 index 0000000000..fa0586e425 --- /dev/null +++ b/backend/audit/templates/audit/auditor-certification-step-1.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+ Auditor certification checklist +

Check the box next to each item confirm your report meets the requirements.

+

You must check all boxes in order to certify your single audit.

+
+ + + {{ form.errors.is_OMB_limited|striptags }} +
+
+ + + {{ form.errors.is_auditee_responsible|striptags }} +
+
+ + + {{ form.errors.has_used_auditors_report|striptags }} +
+
+ + + {{ form.errors.has_no_auditee_procedures|striptags }} +
+
+ + + {{ form.errors.is_FAC_releasable|striptags }} +
+
+
+ + Cancel +
+
+
+
+ {% include "audit-metadata.html" %} +{% endblock content %} diff --git a/backend/audit/templates/audit/auditor-certification-step-2.html b/backend/audit/templates/audit/auditor-certification-step-2.html new file mode 100644 index 0000000000..83a04ac73c --- /dev/null +++ b/backend/audit/templates/audit/auditor-certification-step-2.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+

Auditor certification agreement

+

Agree and sign to auditor statement

+ + + + + +
mm/dd/yyyy
+
+ +
+
+ +
+
+
+ {% include "audit-metadata.html" %} +{% endblock content %} diff --git a/backend/audit/templates/audit/auditor-certification.html b/backend/audit/templates/audit/auditor-certification.html deleted file mode 100644 index e80dd0a428..0000000000 --- a/backend/audit/templates/audit/auditor-certification.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
- {% csrf_token %} -
- - Auditor Certification - -

This is some text explaining that clicking the button below will provide auditor certification for this audit.

-

Audit {{ report_id }} has a submission status of: {{ submission_status }}

-

This action is taken by the CERTIFYING AUDITOR

- -
-
-
-
-{% endblock content %} \ No newline at end of file diff --git a/backend/audit/templates/audit/cross-validation-results.html b/backend/audit/templates/audit/cross-validation-results.html deleted file mode 100644 index b8e0e90b5b..0000000000 --- a/backend/audit/templates/audit/cross-validation-results.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
- {% csrf_token %} -
- - Pre-submission validation results: - -
- {% if errors.errors %} -

While validating across sheets we encountered the errors below. You must correct these errors in order to proceed to certification.

-

Errors:

-
{{ errors.errors | pprint}}
-

Data submitted:

-
{{ errors.data | pprint}}
-

Audit {{ report_id }} has a submission status of: {{ submission_status }}

- {% else %} - Looks good! - {% endif %} -
-
-
-
-
-{% endblock content %} \ No newline at end of file diff --git a/backend/audit/templates/audit/cross-validation.html b/backend/audit/templates/audit/cross-validation.html deleted file mode 100644 index 9abe0ed73c..0000000000 --- a/backend/audit/templates/audit/cross-validation.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
- {% csrf_token %} -
- - Pre-submission validation - -

Check your workbooks to confirm you entered your data correctly. This tool also cross-validates the workbooks against each other.

-

If there are errors, you may re-upload the affected workbooks.

- - Cancel -
-
-
-
-{% endblock content %} \ No newline at end of file diff --git a/backend/audit/templates/audit/cross-validation/cross-validation-results.html b/backend/audit/templates/audit/cross-validation/cross-validation-results.html new file mode 100644 index 0000000000..6f6428e4b7 --- /dev/null +++ b/backend/audit/templates/audit/cross-validation/cross-validation-results.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+ Pre-submission validation +

+ Check your workbooks to confirm you entered your data correctly. This tool also cross-validates the workbooks against each other. +

+

If there are errors, you may re-upload the affected workbooks.

+ {% if errors.errors %} + + + + + + + + + {% for error in errors.errors %}{% endfor %} + + +
Errors
{{ error.error }}
+ {% else %} +

No errors were found.

+ {% endif %} + +
+
+
+
+{% endblock content %} diff --git a/backend/audit/templates/audit/cross-validation/cross-validation.html b/backend/audit/templates/audit/cross-validation/cross-validation.html new file mode 100644 index 0000000000..404c2c1375 --- /dev/null +++ b/backend/audit/templates/audit/cross-validation/cross-validation.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+ Pre-submission validation +

+ Check your workbooks to confirm you entered your data correctly. This tool also cross-validates the workbooks against each other. +

+

If there are errors, you may re-upload the affected workbooks.

+
+ + + Return to checklist +
+
+
+
+
+{% endblock content %} diff --git a/backend/audit/templates/audit/cross-validation/ready-for-certification.html b/backend/audit/templates/audit/cross-validation/ready-for-certification.html new file mode 100644 index 0000000000..b683595ee1 --- /dev/null +++ b/backend/audit/templates/audit/cross-validation/ready-for-certification.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+
+
+ {% csrf_token %} +
+ Lock for certification +

The audit validates and is ready for certification.

+

+ In order to certify, your submission must be locked. No further changes will be possible. Click to lock this audit for certification by the designated certifying officials. +

+ + Back +
+
+
+
+{% endblock content %} diff --git a/backend/audit/templates/audit/ready-for-certification.html b/backend/audit/templates/audit/ready-for-certification.html deleted file mode 100644 index 344d6626bc..0000000000 --- a/backend/audit/templates/audit/ready-for-certification.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} -
-
-
- {% csrf_token %} -
- - Submit for Certification - -

This is some text explaining that clicking the button below will result in the audit being made available to the certifying auditor for certification.

-

This button will run cross-sheet validation on the sheets that come before this step.

-

Audit {{ report_id }} has a submission status of: {{ submission_status }}

-

This action is taken by the AUDITEE

- -
-
-
-
-{% endblock content %} \ No newline at end of file diff --git a/backend/audit/templates/audit/submission_checklist/created-by.html b/backend/audit/templates/audit/submission_checklist/created-by.html index ddda4fc15d..d936b6502e 100644 --- a/backend/audit/templates/audit/submission_checklist/created-by.html +++ b/backend/audit/templates/audit/submission_checklist/created-by.html @@ -18,7 +18,7 @@ {% else %} {{ name.first_name }} {{ name.last_name }}. {% endif %} - {% else %} + {% elif time %} Completed {{ time }}. {% endif %}

diff --git a/backend/audit/templates/audit/submission_checklist/submission-checklist.html b/backend/audit/templates/audit/submission_checklist/submission-checklist.html index f4c433ef46..400ffd8163 100644 --- a/backend/audit/templates/audit/submission_checklist/submission-checklist.html +++ b/backend/audit/templates/audit/submission_checklist/submission-checklist.html @@ -30,20 +30,20 @@ {% comment %} Audit report PDF {% endcomment %}
  • - {% if audit_report.completed %} - {% include './icon-list-icon.html' with completed=audit_report.completed %} + {% if single_audit_report.completed %} + {% include './icon-list-icon.html' with completed=single_audit_report.completed %}

    Audit report PDF (Complete)

    Upload the single audit report package, or audit report. This should be a single PDF that is unlocked and machine-readable.

    - {% include './created-by.html' with time=audit_report.completed_date name=audit_report.created_by %} + {% include './created-by.html' with time=single_audit_report.completed_date name=single_audit_report.created_by %}

    Re-upload the Audit report PDF

    {% else %} - {% include './icon-list-icon.html' with completed=audit_report.completed %} + {% include './icon-list-icon.html' with completed=single_audit_report.completed %}

    Audit report PDF @@ -58,21 +58,21 @@ {% comment %} Workbook 1: Federal Awards {% endcomment %}

  • - {% if federal_awards_workbook.completed %} - {% include './icon-list-icon.html' with completed=federal_awards_workbook.completed %} + {% if federal_awards.completed %} + {% include './icon-list-icon.html' with completed=federal_awards.completed %}

    Workbook 1: Federal Awards (Complete)

    For each federal award received, you'll need the financial and agency details. This is also where you list the number of audit findings.

    - {% include './created-by.html' with time=federal_awards_workbook.completed_date name=federal_awards_workbook.created_by %} + {% include './created-by.html' with time=federal_awards.completed_date name=federal_awards.created_by %}

    Re-upload Workbook 1: Federal Awards

    {% else %} - {% include './icon-list-icon.html' with completed=federal_awards_workbook.completed %} + {% include './icon-list-icon.html' with completed=federal_awards.completed %}

    - {% if audit_findings_workbook.completed %} - {% include './icon-list-icon.html' with completed=audit_findings_workbook.completed %} + {% if findings_uniform_guidance.completed %} + {% include './icon-list-icon.html' with completed=findings_uniform_guidance.completed %}

    {% else %} - {% include './icon-list-icon.html' with completed=audit_findings_workbook.completed %} + {% include './icon-list-icon.html' with completed=findings_uniform_guidance.completed %}

    - {% if findings_text_workbook.completed %} - {% include './icon-list-icon.html' with completed=findings_text_workbook.completed %} + {% if findings_text.completed %} + {% include './icon-list-icon.html' with completed=findings_text.completed %}

    {% else %} - {% include './icon-list-icon.html' with completed=findings_text_workbook.completed %} + {% include './icon-list-icon.html' with completed=findings_text.completed %}

    - {% if CAP_workbook.completed %} - {% include './icon-list-icon.html' with completed=CAP_workbook.completed %} + {% if corrective_action_plan.completed %} + {% include './icon-list-icon.html' with completed=corrective_action_plan.completed %}

    {% else %} - {% include './icon-list-icon.html' with completed=CAP_workbook.completed %} + {% include './icon-list-icon.html' with completed=corrective_action_plan.completed %}

    Upload Workbook 4: Corrective Action Plan (CAP) @@ -176,20 +176,20 @@ {% comment %} Audit Information Form {% endcomment %}

  • - {% if audit_information_form.completed %} - {% include './icon-list-icon.html' with completed=audit_information_form.completed %} + {% if audit_information.completed %} + {% include './icon-list-icon.html' with completed=audit_information.completed %}

    Audit Information Form (Complete)

    This form is completed by both the auditee and auditor. Auditees complete this section using the Summary Schedule of Prior Audit Findings and Auditors complete this section using the information in the financial statement audit.

    - {% include './created-by.html' with time=audit_information_form.completed_date name=audit_information_form.created_by %} + {% include './created-by.html' with time=audit_information.completed_date name=audit_information.created_by %}

    Edit the Audit Information Form

    {% else %} - {% include './icon-list-icon.html' with completed=audit_information_form.completed %} + {% include './icon-list-icon.html' with completed=audit_information.completed %}

    Audit Information Form @@ -202,67 +202,71 @@

  • {% comment %} Workbook 5: Additional UEIs (optional) {% endcomment %} -
  • - {% if additional_UEIs_workbook.completed %} - {% include './icon-list-icon.html' with completed=additional_UEIs_workbook.completed %} -
    -

    Workbook 5: Additional UEIs (Complete)

    -

    - This workbook is only necessary if multiple the audit report covers UEIs. It is completed by the auditee. List the additional UEIs covered by the audit, excluding the primary UEI. -

    - {% include './created-by.html' with time=additional_UEIs_workbook.completed_date name=additional_UEIs_workbook.created_by %} -

    - Re-upload Workbook 5: Additional UEIs -

    -
    - {% else %} - {% include './icon-list-icon.html' with completed=additional_UEIs_workbook.completed %} -
    -

    - Upload Workbook 5: Additional UEIs (optional) -

    -

    - This workbook is only necessary if multiple the audit report covers UEIs. It is completed by the auditee. List the additional UEIs covered by the audit, excluding the primary UEI. -

    -
    - {% endif %} -
  • + {% if additional_ueis.display != "hidden" %} +
  • + {% if additional_ueis.completed %} + {% include './icon-list-icon.html' with completed=additional_ueis.completed %} +
    +

    Workbook 5: Additional UEIs (Complete)

    +

    + This workbook is only necessary if multiple the audit report covers UEIs. It is completed by the auditee. List the additional UEIs covered by the audit, excluding the primary UEI. +

    + {% include './created-by.html' with time=additional_ueis.completed_date name=additional_ueis.created_by %} +

    + Re-upload Workbook 5: Additional UEIs +

    +
    + {% else %} + {% include './icon-list-icon.html' with completed=additional_ueis.completed %} +
    +

    + Upload Workbook 5: Additional UEIs (optional) +

    +

    + This workbook is only necessary if multiple the audit report covers UEIs. It is completed by the auditee. List the additional UEIs covered by the audit, excluding the primary UEI. +

    +
    + {% endif %} +
  • + {% endif %} {% comment %} Upload Workbook 6: Secondary Auditors (optional) {% endcomment %} -
  • - {% if secondary_auditors_workbook.completed %} - {% include './icon-list-icon.html' with completed=secondary_auditors_workbook.completed %} -
    -

    Workbook 6: Secondary Auditors (Complete)

    -

    - This workbook is only necessary if multiple auditors did the audit work. In these cases, the secondary auditors should enter their information in this workbook. -

    - {% include './created-by.html' with time=secondary_auditors_workbook.completed_date name=secondary_auditors_workbook.created_by %} -

    - Re-upload Workbook 6: Secondary Auditors -

    -
    - {% else %} - {% include './icon-list-icon.html' with completed=secondary_auditors_workbook.completed %} -
    -

    - Upload Workbook 6: Secondary Auditors (optional) -

    -

    - This workbook is only necessary if multiple auditors did the audit work. In these cases, the secondary auditors should enter their information in this workbook. -

    -
    - {% endif %} -
  • + {% if secondary_auditors.display != "hidden" %} +
  • + {% if secondary_auditors.completed %} + {% include './icon-list-icon.html' with completed=secondary_auditors.completed %} +
    +

    Workbook 6: Secondary Auditors (Complete)

    +

    + This workbook is only necessary if multiple auditors did the audit work. In these cases, the secondary auditors should enter their information in this workbook. +

    + {% include './created-by.html' with time=secondary_auditors.completed_date name=secondary_auditors.created_by %} +

    + Re-upload Workbook 6: Secondary Auditors +

    +
    + {% else %} + {% include './icon-list-icon.html' with completed=secondary_auditors.completed %} +
    +

    + Upload Workbook 6: Secondary Auditors (optional) +

    +

    + This workbook is only necessary if multiple auditors did the audit work. In these cases, the secondary auditors should enter their information in this workbook. +

    +
    + {% endif %} +
  • + {% endif %} {% comment %} Key information (certification steps) {% endcomment %} -
    +
    @@ -286,12 +290,12 @@

    Pre-submission validation (Complete)

    Before submitting your audit, use our validation tool to check your workbooks for errors.

    - {% elif SFSAC_completed %} + {% elif complete %} {% include './icon-list-icon.html' with completed=pre_submission_validation.completed %}

    Pre-submission validation + href="{% url 'audit:CrossValidation' report_id %}">Pre-submission validation

    Before submitting your audit, use our validation tool to check your workbooks for errors.

    @@ -307,16 +311,16 @@ {% comment %} Auditor Certification {% endcomment %}
  • - {% if auditor_certified.completed %} - {% include './icon-list-icon.html' with completed=auditor_certified.completed %} + {% if certification.auditor_certified %} + {% include './icon-list-icon.html' with completed=certification.auditor_certified %}

    Auditor Certification (Complete)

    Your certifying auditor must attest and sign the single audit package before submission. In order to unlock this step, you must complete and upload all required workbooks from the previous steps, as well as the single audit report PDF.

    - {% elif auditor_certified.enabled %} - {% include './icon-list-icon.html' with completed=auditor_certified.completed %} + {% elif certification.auditor_enabled %} + {% include './icon-list-icon.html' with completed=certification.auditor_certified %}

    {% else %} - {% include './icon-list-icon.html' with completed=auditor_certified.completed %} + {% include './icon-list-icon.html' with completed=certification.auditor_certified %}

    Auditor Certification

    @@ -340,16 +344,16 @@ {% comment %} Auditee Certification {% endcomment %}

  • - {% if auditee_certified.completed %} - {% include './icon-list-icon.html' with completed=auditee_certified.completed %} + {% if certification.auditee_certified %} + {% include './icon-list-icon.html' with completed=certification.auditee_certified %}

    Auditee Certification (Complete)

    Your certifying auditee must attest and sign the single audit package before submission. In order to unlock this step, you must complete and upload all required workbooks from the previous steps, as well as the single audit report PDF.

    - {% elif auditee_certified.enabled %} - {% include './icon-list-icon.html' with completed=auditee_certified.completed %} + {% elif certification.auditee_enabled %} + {% include './icon-list-icon.html' with completed=certification.auditee_certified %}

    {% else %} - {% include './icon-list-icon.html' with completed=auditee_certified.completed %} + {% include './icon-list-icon.html' with completed=certification.auditee_certified %}

    Auditee Certification

    diff --git a/backend/audit/test_etl.py b/backend/audit/test_etl.py index 8d6e5ed3ea..d6baeb2341 100644 --- a/backend/audit/test_etl.py +++ b/backend/audit/test_etl.py @@ -91,7 +91,6 @@ def _fake_federal_awards(): "award_reference": "ABC123", "cluster": {"cluster_name": "N/A", "cluster_total": 0}, "program": { - "is_major": "Y", "program_name": "RETIRED AND SENIOR VOLUNTEER PROGRAM", "amount_expended": 9000, "audit_report_type": "U", @@ -271,12 +270,23 @@ def test_load_general(self): general = generals.first() self.assertEqual(self.report_id, general.report_id) + def test_load_award_before_general_should_fail(self): + self.etl.load_federal_award() + federal_awards = FederalAward.objects.all() + self.assertEqual(len(federal_awards), 0) + def test_load_federal_award(self): + self.etl.load_general() self.etl.load_federal_award() federal_awards = FederalAward.objects.all() self.assertEqual(len(federal_awards), 1) federal_award = federal_awards.first() self.assertEqual(self.report_id, federal_award.report_id) + general = General.objects.first() + self.assertEqual( + general.total_amount_expended, + self.sac.federal_awards["FederalAwards"].get("total_amount_expended"), + ) def test_load_findings(self): self.etl.load_findings() diff --git a/backend/audit/test_views.py b/backend/audit/test_views.py index 86f814e052..073c3ada9f 100644 --- a/backend/audit/test_views.py +++ b/backend/audit/test_views.py @@ -1,4 +1,8 @@ import json +from pathlib import Path +from tempfile import NamedTemporaryFile +from unittest.mock import patch + from django.test import TestCase from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -6,9 +10,6 @@ from django.test import Client from model_bakery import baker -from unittest.mock import patch - -from tempfile import NamedTemporaryFile from openpyxl import load_workbook from openpyxl.cell import Cell @@ -30,8 +31,13 @@ NOTES_TO_SEFA_ENTRY_FIXTURES, FORM_SECTIONS, ) +from .fixtures.single_audit_checklist import ( + fake_auditor_certification, + fake_auditee_certification, +) from .models import Access, SingleAuditChecklist -from .views import MySubmissions +from .views import MySubmissions, submission_progress_check +from .cross_validation.sac_validation_shape import snake_to_camel User = get_user_model() @@ -59,6 +65,8 @@ "auditor_contacts": ["d@d.com"], } +AUDIT_JSON_FIXTURES = Path(__file__).parent / "fixtures" / "json" + # Mocking the user login and file scan functions def _mock_login_and_scan(client, mock_scan_file): @@ -94,6 +102,12 @@ def _make_user_and_sac(**kwargs): return user, sac +def _load_json(target): + """Given a str or Path, load JSON from that target.""" + raw = Path(target).read_text(encoding="utf-8") + return json.loads(raw) + + class MySubmissionsViewTests(TestCase): def setUp(self): self.user = baker.make(User) @@ -188,11 +202,26 @@ def test_auditor_certification(self): """ Test that certifying auditor contacts can provide auditor certification """ + data_step_1, data_step_2 = fake_auditor_certification() + user, sac = _make_user_and_sac(submission_status="ready_for_certification") baker.make(Access, sac=sac, user=user, role="certifying_auditor_contact") kwargs = {"report_id": sac.report_id} - _authed_post(self.client, user, "audit:AuditorCertification", kwargs=kwargs) + _authed_post( + self.client, + user, + "audit:AuditorCertification", + kwargs=kwargs, + data=data_step_1, + ) + _authed_post( + self.client, + user, + "audit:AuditorCertificationConfirm", + kwargs=kwargs, + data=data_step_2, + ) updated_sac = SingleAuditChecklist.objects.get(report_id=sac.report_id) @@ -202,11 +231,26 @@ def test_auditee_certification(self): """ Test that certifying auditee contacts can provide auditee certification """ + data_step_1, data_step_2 = fake_auditee_certification() + user, sac = _make_user_and_sac(submission_status="auditor_certified") baker.make(Access, sac=sac, user=user, role="certifying_auditee_contact") kwargs = {"report_id": sac.report_id} - _authed_post(self.client, user, "audit:AuditeeCertification", kwargs=kwargs) + _authed_post( + self.client, + user, + "audit:AuditeeCertification", + kwargs=kwargs, + data=data_step_1, + ) + _authed_post( + self.client, + user, + "audit:AuditeeCertificationConfirm", + kwargs=kwargs, + data=data_step_2, + ) updated_sac = SingleAuditChecklist.objects.get(report_id=sac.report_id) @@ -949,3 +993,101 @@ def test_valid_file_upload_for_notes_to_sefa(self, mock_scan_file): notes_to_sefa_entries["note_title"], test_data[0]["note_title"], ) + + +class SubmissionProgressViewTests(TestCase): + """ + The page shows information about a submission and conditionally displays links/other + affordances for individual sections. + """ + + def setUp(self): + self.user = baker.make(User) + self.sac = baker.make(SingleAuditChecklist) + self.client = Client() + + def test_login_required(self): + """When an unauthenticated request is made""" + + response = self.client.post( + reverse( + "audit:SubmissionProgress", + kwargs={"report_id": "12345"}, + ) + ) + + self.assertEqual(response.status_code, 403) + + def test_phrase_in_page(self): + """Check for 'Create a single audit submission'.""" + baker.make(Access, user=self.user, sac=self.sac) + self.client.force_login(user=self.user) + phrase = "Single audit submission" + res = self.client.get( + reverse( + "audit:SubmissionProgress", kwargs={"report_id": self.sac.report_id} + ) + ) + self.assertIn(phrase, res.content.decode("utf-8")) + + def test_submission_progress_check_geninfo_only(self): + """ + Check the function containing the logic around which sections are required. + + If the conditional questions all have negative answers and data is absent for + the rest, return the appropriate shape. + """ + filename = "general-information--test0001test--simple-pass.json" + info = _load_json(AUDIT_JSON_FIXTURES / filename) + sac = baker.make(SingleAuditChecklist, general_information=info) + result = submission_progress_check(sac, None) + self.assertEqual(result["general_information"]["display"], "complete") + self.assertTrue(result["general_information"]["completed"]) + conditional_keys = ( + "additional_ueis", + "additional_eins", + "secondary_auditors", + ) + for key in conditional_keys: + self.assertEqual(result[key]["display"], "hidden") + self.assertFalse(result["complete"]) + baker.make(Access, user=self.user, sac=sac) + self.client.force_login(user=self.user) + res = self.client.get( + reverse("audit:SubmissionProgress", kwargs={"report_id": sac.report_id}) + ) + phrases = ( + "Upload the Additional UEIs workbook", + "Upload the Additional EINs workbook", + "Upload the Secondary Auditors workbook", + ) + for phrase in phrases: + self.assertNotIn(phrase, res.content.decode("utf-8")) + + def test_submission_progress_check_simple_pass(self): + """ + Check the function containing the logic around which sections are required. + + If the conditional questions all have negative answers and data is present for + the rest, return the appropriate shape. + + + """ + filename = "general-information--test0001test--simple-pass.json" + info = _load_json(AUDIT_JSON_FIXTURES / filename) + addl_sections = {} + for section_name, camel_name in snake_to_camel.items(): + addl_sections[section_name] = {camel_name: "whatever"} + addl_sections["general_information"] = info + sac = baker.make(SingleAuditChecklist, **addl_sections) + result = submission_progress_check(sac, None) + self.assertEqual(result["general_information"]["display"], "complete") + self.assertTrue(result["general_information"]["completed"]) + conditional_keys = ( + "additional_ueis", + "additional_eins", + "secondary_auditors", + ) + for key in conditional_keys: + self.assertEqual(result[key]["display"], "hidden") + self.assertTrue(result["complete"]) diff --git a/backend/audit/urls.py b/backend/audit/urls.py index f0ffbcbdb1..f1b4c0c9a0 100644 --- a/backend/audit/urls.py +++ b/backend/audit/urls.py @@ -27,14 +27,24 @@ def camel_to_hyphen(raw: str) -> str: ), path( "auditor-certification/", - views.AuditorCertificationView.as_view(), + views.AuditorCertificationStep1View.as_view(), name="AuditorCertification", ), + path( + "auditor-certification-confirm/", + views.AuditorCertificationStep2View.as_view(), + name="AuditorCertificationConfirm", + ), path( "auditee-certification/", - views.AuditeeCertificationView.as_view(), + views.AuditeeCertificationStep1View.as_view(), name="AuditeeCertification", ), + path( + "auditee-certification-confirm/", + views.AuditeeCertificationStep2View.as_view(), + name="AuditeeCertificationConfirm", + ), path( "certification/", views.CertificationView.as_view(), diff --git a/backend/audit/validators.py b/backend/audit/validators.py index 733cba7d8c..13f283cccc 100644 --- a/backend/audit/validators.py +++ b/backend/audit/validators.py @@ -255,6 +255,45 @@ def validate_secondary_auditors_json(value): raise ValidationError(message=_secondary_auditors_json_error(errors)) +def validate_auditor_certification_json(value): + """ + Apply JSON Schema for auditor certification and report errors. + """ + schema_path = settings.SECTION_SCHEMA_DIR / "AuditorCertification.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + + try: + validate(value, schema, format_checker=FormatChecker()) + except JSONSchemaValidationError as err: + raise ValidationError( + _(err.message), + ) from err + return value + + +def validate_auditee_certification_json(value): + """ + Apply JSON Schema for auditee certification and report errors. + """ + schema_path = settings.SECTION_SCHEMA_DIR / "AuditeeCertification.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + + try: + validate(value, schema, format_checker=FormatChecker()) + except JSONSchemaValidationError as err: + raise ValidationError( + _(err.message), + ) from err + return value + + +def validate_tribal_data_consent_json(value): + """ + Apply JSON Schema for tribal data consent and report errors. + """ + raise ValidationError("Not implemented") + + def validate_file_extension(file, allowed_extensions): """ User-provided filenames must be have an allowed extension diff --git a/backend/audit/views.py b/backend/audit/views.py index 39f059cbf1..49d3c12fe7 100644 --- a/backend/audit/views.py +++ b/backend/audit/views.py @@ -11,11 +11,9 @@ from django.utils.decorators import method_decorator from django.http import JsonResponse -from audit.forms import UploadReportForm, AuditInfoForm - from config.settings import AGENCY_NAMES, GAAP_RESULTS -from .fixtures.excel import FORM_SECTIONS, UNKNOWN_WORKBOOK +from audit.cross_validation import sac_validation_shape from audit.excel import ( extract_additional_ueis, extract_federal_awards, @@ -25,14 +23,13 @@ extract_secondary_auditors, extract_notes_to_sefa, ) -from audit.validators import ( - validate_additional_ueis_json, - validate_federal_award_json, - validate_corrective_action_plan_json, - validate_findings_text_json, - validate_findings_uniform_guidance_json, - validate_secondary_auditors_json, - validate_notes_to_sefa_json, +from audit.forms import ( + UploadReportForm, + AuditInfoForm, + AuditorCertificationStep1Form, + AuditorCertificationStep2Form, + AuditeeCertificationStep1Form, + AuditeeCertificationStep2Form, ) from audit.mixins import ( CertifyingAuditeeRequiredMixin, @@ -46,9 +43,25 @@ SingleAuditChecklist, SingleAuditReportFile, ) -from audit.validators import validate_audit_information_json from audit.utils import ExcelExtractionError +from audit.validators import ( + validate_additional_ueis_json, + validate_audit_information_json, + validate_auditee_certification_json, + validate_auditor_certification_json, + validate_corrective_action_plan_json, + validate_federal_award_json, + validate_findings_text_json, + validate_findings_uniform_guidance_json, + validate_notes_to_sefa_json, + validate_secondary_auditors_json, +) +from .fixtures.excel import FORM_SECTIONS, UNKNOWN_WORKBOOK + +logging.basicConfig( + format="%(asctime)s %(levelname)-8s %(module)s:%(lineno)d %(message)s" +) logger = logging.getLogger(__name__) @@ -263,7 +276,9 @@ def get(self, request, *args, **kwargs): "report_id": report_id, "submission_status": sac.submission_status, } - return render(request, "audit/cross-validation.html", context) + return render( + request, "audit/cross-validation/cross-validation.html", context + ) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -277,7 +292,9 @@ def post(self, request, *args, **kwargs): context = {"report_id": report_id, "errors": errors} - return render(request, "audit/cross-validation-results.html", context) + return render( + request, "audit/cross-validation/cross-validation-results.html", context + ) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -294,7 +311,9 @@ def get(self, request, *args, **kwargs): "report_id": report_id, "submission_status": sac.submission_status, } - return render(request, "audit/ready-for-certification.html", context) + return render( + request, "audit/cross-validation/ready-for-certification.html", context + ) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -311,25 +330,100 @@ def post(self, request, *args, **kwargs): return redirect(reverse("audit:SubmissionProgress", args=[report_id])) context = {"report_id": report_id, "errors": errors} - return render(request, "audit/cross-validation-results.html", context) + return render( + request, "audit/cross-validation/cross-validation-results.html", context + ) + + except SingleAuditChecklist.DoesNotExist: + raise PermissionDenied("You do not have access to this audit.") + + +class AuditorCertificationStep1View(CertifyingAuditorRequiredMixin, generic.View): + def get(self, request, *args, **kwargs): + report_id = kwargs["report_id"] + + try: + sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditorCertificationStep1Session": request.session.get( + "AuditorCertificationStep1Session", None + ) + } + form = AuditorCertificationStep1Form(request.POST or None, initial=initial) + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + "form": form, + } + return render(request, "audit/auditor-certification-step-1.html", context) + + except SingleAuditChecklist.DoesNotExist: + raise PermissionDenied("You do not have access to this audit.") + + def post(self, request, *args, **kwargs): + report_id = kwargs["report_id"] + + try: + sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditorCertificationStep1Session": request.session.get( + "AuditorCertificationStep1Session", None + ) + } + form = AuditorCertificationStep1Form(request.POST or None, initial=initial) + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + } + + if form.is_valid(): + # Save to session. Retrieved and saved after step 2. + request.session["AuditorCertificationStep1Session"] = form.cleaned_data + return redirect( + reverse("audit:AuditorCertificationConfirm", args=[report_id]) + ) + + context["form"] = form + return render(request, "audit/auditor-certification-step-1.html", context) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") -class AuditorCertificationView(CertifyingAuditorRequiredMixin, generic.View): +class AuditorCertificationStep2View(CertifyingAuditorRequiredMixin, generic.View): def get(self, request, *args, **kwargs): report_id = kwargs["report_id"] try: sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditorCertificationStep2Session": request.session.get( + "AuditorCertificationStep2Session", None + ) + } + form = AuditorCertificationStep2Form(request.POST or None, initial=initial) + + # Suggests a load/reload on step 2, which means we don't have step 1 session information. + # Send them back. + form1_cleaned = request.session.get( + "AuditorCertificationStep1Session", None + ) + if form1_cleaned is None: + return redirect(reverse("audit:AuditorCertification", args=[report_id])) context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, "report_id": report_id, "submission_status": sac.submission_status, + "form": form, } + return render(request, "audit/auditor-certification-step-2.html", context) - return render(request, "audit/auditor-certification.html", context) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -338,29 +432,132 @@ def post(self, request, *args, **kwargs): try: sac = SingleAuditChecklist.objects.get(report_id=report_id) + form1_cleaned = request.session.get( + "AuditorCertificationStep1Session", None + ) + form2 = AuditorCertificationStep2Form(request.POST or None) - sac.transition_to_auditor_certified() - sac.save() + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + } - return redirect(reverse("audit:SubmissionProgress", args=[report_id])) + if form2.is_valid(): + form_cleaned = { + "auditor_certification": form1_cleaned, + "auditor_signature": form2.cleaned_data, + } + form_cleaned["auditor_signature"][ + "auditor_certification_date_signed" + ] = form_cleaned["auditor_signature"][ + "auditor_certification_date_signed" + ].strftime( + "%d/%m/%Y" + ) + auditor_certification = sac.auditor_certification or {} + auditor_certification.update(form_cleaned) + validated = validate_auditor_certification_json(auditor_certification) + sac.auditor_certification = validated + sac.transition_to_auditor_certified() + sac.save() + logger.info("Auditor certification saved.", auditor_certification) + return redirect(reverse("audit:SubmissionProgress", args=[report_id])) + + context["form"] = form2 + return render(request, "audit/auditor-certification-step-2.html", context) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") -class AuditeeCertificationView(CertifyingAuditeeRequiredMixin, generic.View): +class AuditeeCertificationStep1View(CertifyingAuditeeRequiredMixin, generic.View): def get(self, request, *args, **kwargs): report_id = kwargs["report_id"] try: sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditeeCertificationStep1Session": request.session.get( + "AuditeeCertificationStep1Session", None + ) + } + form = AuditeeCertificationStep1Form(request.POST or None, initial=initial) + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + "form": form, + } + return render(request, "audit/auditee-certification-step-1.html", context) + + except SingleAuditChecklist.DoesNotExist: + raise PermissionDenied("You do not have access to this audit.") + def post(self, request, *args, **kwargs): + report_id = kwargs["report_id"] + + try: + sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditeeCertificationStep1Session": request.session.get( + "AuditeeCertificationStep1Session", None + ) + } + form = AuditeeCertificationStep1Form(request.POST or None, initial=initial) context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, "report_id": report_id, "submission_status": sac.submission_status, } - return render(request, "audit/auditee-certification.html", context) + if form.is_valid(): + # Save to session. Retrieved and saved after step 2. + request.session["AuditeeCertificationStep1Session"] = form.cleaned_data + return redirect( + reverse("audit:AuditeeCertificationConfirm", args=[report_id]) + ) + + context["form"] = form + return render(request, "audit/auditee-certification-step-1.html", context) + + except SingleAuditChecklist.DoesNotExist: + raise PermissionDenied("You do not have access to this audit.") + + +class AuditeeCertificationStep2View(CertifyingAuditeeRequiredMixin, generic.View): + def get(self, request, *args, **kwargs): + report_id = kwargs["report_id"] + + try: + sac = SingleAuditChecklist.objects.get(report_id=report_id) + initial = { + "AuditeeCertificationStep2Session": request.session.get( + "AuditeeCertificationStep2Session", None + ) + } + form = AuditeeCertificationStep2Form(request.POST or None, initial=initial) + + # Suggests a load/reload on step 2, which means we don't have step 1 session information. + # Send them back. + form1_cleaned = request.session.get( + "AuditeeCertificationStep1Session", None + ) + if form1_cleaned is None: + return redirect(reverse("audit:AuditeeCertification", args=[report_id])) + + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + "form": form, + } + return render(request, "audit/auditee-certification-step-2.html", context) + except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -369,11 +566,41 @@ def post(self, request, *args, **kwargs): try: sac = SingleAuditChecklist.objects.get(report_id=report_id) + form1_cleaned = request.session.get( + "AuditeeCertificationStep1Session", None + ) + form2 = AuditeeCertificationStep2Form(request.POST or None) - sac.transition_to_auditee_certified() - sac.save() + context = { + "auditee_uei": sac.auditee_uei, + "auditee_name": sac.auditee_name, + "report_id": report_id, + "submission_status": sac.submission_status, + } - return redirect(reverse("audit:SubmissionProgress", args=[report_id])) + if form2.is_valid(): + form_cleaned = { + "auditee_certification": form1_cleaned, + "auditee_signature": form2.cleaned_data, + } + form_cleaned["auditee_signature"][ + "auditee_certification_date_signed" + ] = form_cleaned["auditee_signature"][ + "auditee_certification_date_signed" + ].strftime( + "%d/%m/%Y" + ) + auditee_certification = sac.auditee_certification or {} + auditee_certification.update(form_cleaned) + validated = validate_auditee_certification_json(auditee_certification) + sac.auditee_certification = validated + sac.transition_to_auditee_certified() + sac.save() + logger.info("Auditee certification saved.", auditee_certification) + return redirect(reverse("audit:SubmissionProgress", args=[report_id])) + + context["form"] = form2 + return render(request, "audit/auditee-certification-step-2.html", context) except SingleAuditChecklist.DoesNotExist: raise PermissionDenied("You do not have access to this audit.") @@ -441,7 +668,136 @@ def post(self, request, *args, **kwargs): raise PermissionDenied("You do not have access to this audit.") +def conditional_keys_progress_check(sac, sections): + """ + Support function for submission_progress_check; handles the conditional sections. + """ + conditional_keys = { + "additional_ueis": sac.multiple_ueis_covered, + # Update once we have the question in. This may be handled in the gen info form rather than as a workbook. + "additional_eins": False, + # "additional_eins": sac.multiple_eins_covered, + "secondary_auditors": False, # update this once we have the question in. + } + output = {} + for key, value in conditional_keys.items(): + current = "incomplete" + if not value: + current = "hidden" + elif sections.get(key): + current = "complete" + info = {"display": current, "completed": current == "complete"} + output[key] = info + return output + + +def mandatory_keys_progress_check(sections, conditional_keys): + """ + Support function for submission_progress_check; handles the mandatory sections. + """ + other_keys = [k for k in sections if k not in conditional_keys] + output = {} + for k in other_keys: + if bool(sections[k]): + info = {"display": "complete", "completed": True} + else: + info = {"display": "incomplete", "completed": False} + output[k] = info + return output + + +def submission_progress_check( + sac: SingleAuditChecklist, sar: SingleAuditReportFile +) -> dict: + """ + Given a SingleAuditChecklist instance and a SingleAuditReportFile instance, + return information about submission progress. + + Returns this shape: + + { + "complete": [bool], + "single_audit_report": [progress_dict], + "additional_ueis": [progress_dict], + ... + "general_information": [progress_dict], + } + + Where each of the sections is represented at the top level, along with + single_audit_report, and [progress_dict] is: + + { + "display": "hidden"/"incomplete"/"complete", + "completed": [bool], + "completed_by": [email], + "completed_date": [date], + } + """ + # Use sac_validation_shape as source of truth for list of sections: + shaped_sac = sac_validation_shape(sac) + sections = shaped_sac["sf_sac_sections"] + # TODO: remove these once Notes to SEFA and tribal data consent are implemented + del sections["notes_to_sefa"] + del sections["tribal_data_consent"] + result = {k: None for k in sections} # type: ignore + progress = { + "display": None, + "completed": None, + "completed_by": None, + "completed_date": None, + } + + cond_keys = conditional_keys_progress_check(sac, sections) + for ckey, cvalue in cond_keys.items(): + result[ckey] = progress | cvalue + + mandatory_keys = mandatory_keys_progress_check(sections, cond_keys) + for mkey, mvalue in mandatory_keys.items(): + result[mkey] = progress | mvalue + + sar_progress = { + "display": "complete" if bool(sar) else "incomplete", + "completed": bool(sar), + } + + result["single_audit_report"] = progress | sar_progress # type: ignore + + complete = False + + def cond_pass(cond_key): + passing = ("hidden", "complete") + return result.get(cond_key, {}).get("display") in passing + + if all(bool(sections[k]) for k in mandatory_keys): + if all(cond_pass(j) for j in cond_keys): + complete = True + + result["complete"] = complete # type: ignore + + return result + + class SubmissionProgressView(SingleAuditChecklistAccessRequiredMixin, generic.View): + """ + Display information about and the current status of the sections of the submission, + including links to the pages for the sections. + + The following sections have three states, rather than two: + + + Additionai UEIs + + Additionai EINs + + Secondary Auditors + + The states are: + + + hidden + + incomplete + + complete + + In each case, they are hidden if the corresponding question in the General + Information form has been answered with a negative response. + """ + def get(self, request, *args, **kwargs): report_id = kwargs["report_id"] @@ -454,16 +810,8 @@ def get(self, request, *args, **kwargs): except SingleAuditReportFile.DoesNotExist: sar = None - submission_status_order = [ - "in_progress", - "ready_for_certification", - "auditor_certified", - "auditee_certified", - "certified", - "submitted", - ] + subcheck = submission_progress_check(sac, sar) - # TODO: Ensure the correct SAC elements are used to determine what's complete. context = { "single_audit_checklist": { "created": True, @@ -475,91 +823,37 @@ def get(self, request, *args, **kwargs): "completed_date": None, "completed_by": None, }, - "audit_report": { - "completed": True if (sar) else False, - "completed_date": sar.date_created.strftime( - "%b %d,%Y at %H:%M %p %Z" - ) - if (sar) - else None, - "completed_by": None, - }, - "federal_awards_workbook": { - "completed": True if (sac.federal_awards) else False, - "completed_date": None, - "completed_by": None, - }, - "audit_findings_workbook": { - "completed": True if (sac.findings_uniform_guidance) else False, - "completed_date": None, - "completed_by": None, - }, - "findings_text_workbook": { - "completed": True if (sac.findings_text) else False, - "completed_date": None, - "completed_by": None, - }, - "CAP_workbook": { - "completed": True if (sac.corrective_action_plan) else False, - "completed_date": None, - "completed_by": None, - }, - "audit_information_form": { - "completed": True if (sac.audit_information) else False, - "completed_date": None, - "completed_by": None, - }, - "additional_UEIs_workbook": { - "completed": True if (sac.additional_ueis) else False, - "completed_date": None, - "completed_by": None, - }, - "secondary_auditors_workbook": { - "completed": True if (sac.secondary_auditors) else False, - "completed_date": None, - "completed_by": None, - }, "pre_submission_validation": { - "completed": submission_status_order.index(sac.submission_status) - > submission_status_order.index("in_progress"), - }, - "auditor_certified": { - "enabled": sac.submission_status == "ready_for_certification", - "completed": submission_status_order.index(sac.submission_status) - > submission_status_order.index("auditor_certified"), + "completed": sac.submission_status == "ready_for_certification", "completed_date": None, + "completed_by": None, + "enabled": sac.submission_status == "auditee_certified", }, - "auditee_certified": { - "enabled": sac.submission_status == "auditor_certified", - "completed": submission_status_order.index(sac.submission_status) - > submission_status_order.index("auditee_certified"), - "completed_date": None, + "certification": { + "auditor_certified": bool(sac.auditor_certification), + "auditor_enabled": sac.submission_status + == "ready_for_certification", + "auditee_certified": bool(sac.auditee_certification), + "auditee_enabled": sac.submission_status == "auditor_certified", }, "submission": { - "completed": sac.is_submitted, + "completed": sac.submission_status == "submitted", "completed_date": None, "completed_by": None, + "enabled": sac.submission_status == "auditee_certified", }, "report_id": report_id, "auditee_name": sac.auditee_name, "auditee_uei": sac.auditee_uei, "user_provided_organization_type": sac.user_provided_organization_type, } - # Ensure required uploads are complete - context["SFSAC_completed"] = ( - context["federal_awards_workbook"]["completed"] - and context["federal_awards_workbook"]["completed"] - and context["audit_findings_workbook"]["completed"] - and context["findings_text_workbook"]["completed"] - and context["CAP_workbook"]["completed"] - and context["audit_information_form"]["completed"] - ) + context = context | subcheck return render( request, "audit/submission_checklist/submission-checklist.html", context ) - except SingleAuditChecklist.DoesNotExist: - raise PermissionDenied("You do not have access to this audit.") + except SingleAuditChecklist.DoesNotExist as err: + raise PermissionDenied("You do not have access to this audit.") from err class AuditInfoFormView(SingleAuditChecklistAccessRequiredMixin, generic.View): diff --git a/backend/cypress/e2e/full-submission.cy.js b/backend/cypress/e2e/full-submission.cy.js index a204e714bc..a9a212035c 100644 --- a/backend/cypress/e2e/full-submission.cy.js +++ b/backend/cypress/e2e/full-submission.cy.js @@ -42,30 +42,44 @@ describe('Full audit submission', () => { testValidGeneralInfo(); // Upload all the workbooks - cy.get(".usa-link").contains("Federal Awards workbook").click(); + cy.get(".usa-link").contains("Federal Awards").click(); testWorkbookFederalAwards(false); // don't intercept - cy.get(".usa-link").contains("Audit Findings workbook").click(); + cy.get(".usa-link").contains("Federal Awards Audit Findings").click(); testWorkbookFindingsUniformGuidance(false); // don't intercept - cy.get(".usa-link").contains("Audit Findings Text workbook").click(); + cy.get(".usa-link").contains("Federal Awards Audit Findings Text").click(); testWorkbookFindingsText(false); // don't intercept - cy.get(".usa-link").contains("Corrective Action Plan (CAP) workbook").click(); + cy.get(".usa-link").contains("Corrective Action Plan").click(); testWorkbookCorrectiveActionPlan(false); // don't intercept - cy.get(".usa-link").contains("Additional UEIs workbook").click(); + cy.get(".usa-link").contains("Additional UEIs").click(); testWorkbookAdditionalUEIs(false); // don't intercept - - // Can it be? We are ready for certification? - cy.get(".usa-button").contains("Ready for SF-SAC Certification").click(); + // Uncomment this block when ready to implement the certification steps. + /* + + // These aren't enabled because the previous steps didn't actually upload anything. Our upload responses are mocked. + // First step, pre-validation. + cy.get(".usa-link").contains("Pre-submission validation").click(); cy.url().should('match', /\/audit\/ready-for-certification\/[0-9A-Z]{17}/); - // Submit for certification button - cy.get("#continue").click() - // Can't tell if this is the right place for this to end up? - cy.url().should('match', /\/audit\/submission-progress\/[0-9A-Z]{17}/); - - + // Mock a prositive response on validation, then it comes back to the checklist + + // Second, auditor certification + cy.get(".usa-link").contains("Auditor Certification").click(); + // Two pages: + // 1. Click all the checkboxes to agree, submit and got to page 2 + // 2. Sign and date, submit and go back to checklist + + // Third, auditee certification + cy.get(".usa-link").contains("Auditee Certification").click(); + // The same as auditor certification, with different checkboxes. + + // Finally, submit for processing. + cy.get(".usa-link").contains("Submit to the FAC for processing").click(); + // This will probably take you back to the homepage, where the audit is now oof status "submitted". + + */ }); }); diff --git a/backend/cypress/fixtures/sam-gov-api-mock.json b/backend/cypress/fixtures/sam-gov-api-mock.json new file mode 100644 index 0000000000..a23edf80be --- /dev/null +++ b/backend/cypress/fixtures/sam-gov-api-mock.json @@ -0,0 +1,12 @@ +{ + "valid": "True", + "response": { + "auditee_uei": "LZGKJ22EF7B5", + "auditee_name": "Fire King International, LLC", + "auditee_fiscal_year_end_date": "12/31", + "auditee_address_line_1": "900 Park Place", + "auditee_city": "NEW ALBANY", + "auditee_state": "IN", + "auditee_zip": "47150" + } +} diff --git a/backend/cypress/support/auditee-info.js b/backend/cypress/support/auditee-info.js index 2d87ec86c2..1b66566ce2 100644 --- a/backend/cypress/support/auditee-info.js +++ b/backend/cypress/support/auditee-info.js @@ -1,9 +1,13 @@ // re-usable code for testing the Auditee Info form export function testValidAuditeeInfo() { + cy.intercept('api/sac/ueivalidation', { + fixture: 'sam-gov-api-mock.json', + }).as('uei_check_success') + // hard-coding some UEI which may eventually become unregistered cy.get('#auditee_uei').type('LZGKJ22EF7B5'); - cy.get('#auditee_uei-btn').click(); + cy.get('#auditee_uei-btn').click().wait('@uei_check_success'); // modal search result box needs "Continue" to be clicked cy.get('button[data-close-modal]').contains('Continue').click(); diff --git a/backend/cypress/support/general-info.js b/backend/cypress/support/general-info.js index fb47ca8468..bacaea75d9 100644 --- a/backend/cypress/support/general-info.js +++ b/backend/cypress/support/general-info.js @@ -16,7 +16,7 @@ export function testValidGeneralInfo() { cy.get('#auditee_zip').type('23219'); // there should already be a UEI in this box // cy.get('#auditee_uei').type('CMBSGK6P7BE1'); - cy.get('label[for=multiple-ueis-no]').click(); + cy.get('label[for=multiple-ueis-yes]').click(); cy.get('#ein').type('546000173'); cy.get('label[for=ein_not_an_ssn_attestation]').click(); cy.get('label[for=multiple-eins-no]').click(); diff --git a/backend/data_fixtures/audit/excel_workbooks_test_files/audit-findings-text-workbook--EMPTY.xlsx b/backend/data_fixtures/audit/excel_workbooks_test_files/audit-findings-text-workbook--EMPTY.xlsx new file mode 100644 index 0000000000..6722adab14 Binary files /dev/null and b/backend/data_fixtures/audit/excel_workbooks_test_files/audit-findings-text-workbook--EMPTY.xlsx differ diff --git a/backend/data_fixtures/audit/excel_workbooks_test_files/corrective-action-plan-workbook--EMPTY.xlsx b/backend/data_fixtures/audit/excel_workbooks_test_files/corrective-action-plan-workbook--EMPTY.xlsx new file mode 100644 index 0000000000..96401aadc6 Binary files /dev/null and b/backend/data_fixtures/audit/excel_workbooks_test_files/corrective-action-plan-workbook--EMPTY.xlsx differ diff --git a/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-audit-findings-workbook-EMPTY.xlsx b/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-audit-findings-workbook-EMPTY.xlsx new file mode 100644 index 0000000000..4813eb301b Binary files /dev/null and b/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-audit-findings-workbook-EMPTY.xlsx differ diff --git a/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-workbook-PASS.xlsx b/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-workbook-PASS.xlsx index f74840ffaa..a6554aa125 100644 Binary files a/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-workbook-PASS.xlsx and b/backend/data_fixtures/audit/excel_workbooks_test_files/federal-awards-workbook-PASS.xlsx differ diff --git a/backend/dissemination/migrations/0024_rename_total_fed_expenditures_general_total_amount_expended_and_more.py b/backend/dissemination/migrations/0024_rename_total_fed_expenditures_general_total_amount_expended_and_more.py new file mode 100644 index 0000000000..95fb84564f --- /dev/null +++ b/backend/dissemination/migrations/0024_rename_total_fed_expenditures_general_total_amount_expended_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.3 on 2023-08-10 17:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ( + "dissemination", + "0023_rename_report_required_general_is_report_required_and_more", + ), + ] + + operations = [ + migrations.RenameField( + model_name="general", + old_name="total_fed_expenditures", + new_name="total_amount_expended", + ), + migrations.RemoveField( + model_name="federalaward", + name="is_major", + ), + migrations.RemoveField( + model_name="federalaward", + name="type_requirement", + ), + migrations.RemoveField( + model_name="general", + name="type_report_major_program", + ), + ] diff --git a/backend/dissemination/migrations/0025_remove_general_is_report_required.py b/backend/dissemination/migrations/0025_remove_general_is_report_required.py new file mode 100644 index 0000000000..33755a71bb --- /dev/null +++ b/backend/dissemination/migrations/0025_remove_general_is_report_required.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2023-08-10 17:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ( + "dissemination", + "0024_rename_total_fed_expenditures_general_total_amount_expended_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="general", + name="is_report_required", + ), + ] diff --git a/backend/dissemination/models.py b/backend/dissemination/models.py index 7d48ac5ade..c294438be8 100644 --- a/backend/dissemination/models.py +++ b/backend/dissemination/models.py @@ -195,11 +195,6 @@ class FederalAward(models.Model): help_text=docs.direct, ) - is_major = models.BooleanField( - "Indicate whether or not the Federal program is a major program", - null=True, - help_text=docs.major_program, - ) mp_audit_report_type = models.CharField( "Type of Report Issued on the Major Program Compliance", max_length=40, @@ -232,12 +227,6 @@ class FederalAward(models.Model): decimal_places=2, help_text=docs.passthrough_amount, ) - type_requirement = models.CharField( - "Type Requirement Failure", - max_length=40, - null=True, - help_text=docs.type_requirement_cfdainfo, - ) class Meta: unique_together = ( @@ -732,25 +721,13 @@ class General(models.Model): audit_period_covered = models.CharField( "Audit Period Covered by Audit", max_length=40, help_text=docs.period_covered ) - is_report_required = models.BooleanField( - "Distribution to Federal Agency required?", - null=True, - help_text=docs.report_required, - ) - - total_fed_expenditures = models.DecimalField( + total_amount_expended = models.DecimalField( "Total Federal Expenditures", null=True, max_digits=10, decimal_places=2, help_text=docs.total_fed_expenditures, ) - type_report_major_program = models.CharField( - "Type of Report Issued on the Major Program Compliance", - max_length=40, - null=True, - help_text=docs.type_report_major_program_general, - ) type_audit_code = models.CharField("Determines if audit is A133 or UG", default="") # Metadata diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index fe825047c2..6b00cbbe20 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -31,7 +31,7 @@ services: ALLOWED_HOSTS: 0.0.0.0 127.0.0.1 localhost AV_SCAN_URL: http://clamav-rest:9000/scan DISABLE_AUTH: ${DISABLE_AUTH:-False} - PGRST_JWT_SECRET: ${PGRST_JWT_SECRET} + PGRST_JWT_SECRET: ${PGRST_JWT_SECRET:-32_chars_fallback_secret_testing} env_file: - ".env" @@ -70,7 +70,7 @@ services: PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000 PGRST_DB_ANON_ROLE: anon PGRST_DB_SCHEMAS: "api_v1_0_0_beta" - PGRST_JWT_SECRET: ${PGRST_JWT_SECRET} + PGRST_JWT_SECRET: ${PGRST_JWT_SECRET:-32_chars_fallback_secret_testing} # Fallback value for testing environments depends_on: - db volumes: diff --git a/backend/report_submission/forms.py b/backend/report_submission/forms.py index c721d7be5f..19ab1bbde8 100644 --- a/backend/report_submission/forms.py +++ b/backend/report_submission/forms.py @@ -10,7 +10,7 @@ class GeneralInformationForm(forms.Form): auditee_fiscal_period_end = forms.CharField() auditee_fiscal_period_start = forms.CharField() audit_period_covered = forms.CharField() - audit_period_other_months = forms.CharField() + audit_period_other_months = forms.CharField(required=False) ein = forms.CharField() ein_not_an_ssn_attestation = forms.BooleanField(required=False) multiple_eins_covered = forms.BooleanField(required=False) diff --git a/backend/schemas/output/excel/json/additional-ueis-workbook.json b/backend/schemas/output/excel/json/additional-ueis-workbook.json index 8f5d7f7088..e1b97407e6 100644 --- a/backend/schemas/output/excel/json/additional-ueis-workbook.json +++ b/backend/schemas/output/excel/json/additional-ueis-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"AdditionalUeis\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/audit-findings-text-workbook.json b/backend/schemas/output/excel/json/audit-findings-text-workbook.json index a83ded9a22..a0d0060307 100644 --- a/backend/schemas/output/excel/json/audit-findings-text-workbook.json +++ b/backend/schemas/output/excel/json/audit-findings-text-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"FindingsText\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/corrective-action-plan-workbook.json b/backend/schemas/output/excel/json/corrective-action-plan-workbook.json index 4ab21d0ccf..234eb2b74d 100644 --- a/backend/schemas/output/excel/json/corrective-action-plan-workbook.json +++ b/backend/schemas/output/excel/json/corrective-action-plan-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"CorrectiveActionPlan\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/federal-awards-audit-findings-workbook.json b/backend/schemas/output/excel/json/federal-awards-audit-findings-workbook.json index c331829756..00528cfad3 100644 --- a/backend/schemas/output/excel/json/federal-awards-audit-findings-workbook.json +++ b/backend/schemas/output/excel/json/federal-awards-audit-findings-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"FindingsUniformGuidance\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/federal-awards-workbook.json b/backend/schemas/output/excel/json/federal-awards-workbook.json index c6b4224138..bed579d738 100644 --- a/backend/schemas/output/excel/json/federal-awards-workbook.json +++ b/backend/schemas/output/excel/json/federal-awards-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"FederalAwardsExpended\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/notes-to-sefa-workbook.json b/backend/schemas/output/excel/json/notes-to-sefa-workbook.json index 6c0acdf115..724ae2dde2 100644 --- a/backend/schemas/output/excel/json/notes-to-sefa-workbook.json +++ b/backend/schemas/output/excel/json/notes-to-sefa-workbook.json @@ -52,7 +52,7 @@ "formula": "=\"NotesToSefa\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/json/secondary-auditors-workbook.json b/backend/schemas/output/excel/json/secondary-auditors-workbook.json index 8556390dcb..b2b7c309ba 100644 --- a/backend/schemas/output/excel/json/secondary-auditors-workbook.json +++ b/backend/schemas/output/excel/json/secondary-auditors-workbook.json @@ -50,7 +50,7 @@ "formula": "=\"SecondaryAuditors\"", "help": { "link": "https://fac.gov/documentation/validation/#section_name", - "text": "The workbook you are trying to upload does not match the required template" + "text": "The workbook you tried to upload is for a different section." }, "keep_locked": true, "range_cell": "B3", diff --git a/backend/schemas/output/excel/xlsx/additional-ueis-workbook.xlsx b/backend/schemas/output/excel/xlsx/additional-ueis-workbook.xlsx index 950396d6af..382f41bb50 100644 Binary files a/backend/schemas/output/excel/xlsx/additional-ueis-workbook.xlsx and b/backend/schemas/output/excel/xlsx/additional-ueis-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/audit-findings-text-workbook.xlsx b/backend/schemas/output/excel/xlsx/audit-findings-text-workbook.xlsx index 8e703aa54b..19cc819a5b 100644 Binary files a/backend/schemas/output/excel/xlsx/audit-findings-text-workbook.xlsx and b/backend/schemas/output/excel/xlsx/audit-findings-text-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/corrective-action-plan-workbook.xlsx b/backend/schemas/output/excel/xlsx/corrective-action-plan-workbook.xlsx index 6a923ab1ff..9edf7b2b91 100644 Binary files a/backend/schemas/output/excel/xlsx/corrective-action-plan-workbook.xlsx and b/backend/schemas/output/excel/xlsx/corrective-action-plan-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/federal-awards-audit-findings-workbook.xlsx b/backend/schemas/output/excel/xlsx/federal-awards-audit-findings-workbook.xlsx index 09b8cab0e3..b4b9153b23 100644 Binary files a/backend/schemas/output/excel/xlsx/federal-awards-audit-findings-workbook.xlsx and b/backend/schemas/output/excel/xlsx/federal-awards-audit-findings-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/federal-awards-workbook.xlsx b/backend/schemas/output/excel/xlsx/federal-awards-workbook.xlsx index 308e8377ef..182d0c9ec4 100644 Binary files a/backend/schemas/output/excel/xlsx/federal-awards-workbook.xlsx and b/backend/schemas/output/excel/xlsx/federal-awards-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/notes-to-sefa-workbook.xlsx b/backend/schemas/output/excel/xlsx/notes-to-sefa-workbook.xlsx index d66b044b39..468b19099e 100644 Binary files a/backend/schemas/output/excel/xlsx/notes-to-sefa-workbook.xlsx and b/backend/schemas/output/excel/xlsx/notes-to-sefa-workbook.xlsx differ diff --git a/backend/schemas/output/excel/xlsx/secondary-auditors-workbook.xlsx b/backend/schemas/output/excel/xlsx/secondary-auditors-workbook.xlsx index be87940d24..e0f0a0e22d 100644 Binary files a/backend/schemas/output/excel/xlsx/secondary-auditors-workbook.xlsx and b/backend/schemas/output/excel/xlsx/secondary-auditors-workbook.xlsx differ diff --git a/backend/schemas/output/sections/AdditionalUEIs.schema.json b/backend/schemas/output/sections/AdditionalUEIs.schema.json index 971cdc082e..db2c944b8a 100644 --- a/backend/schemas/output/sections/AdditionalUEIs.schema.json +++ b/backend/schemas/output/sections/AdditionalUEIs.schema.json @@ -84,9 +84,6 @@ "version": 20230807 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230621 diff --git a/backend/schemas/output/sections/AuditFindingsText.schema.json b/backend/schemas/output/sections/AuditFindingsText.schema.json index 8378e6725c..9ee3f9f1d4 100644 --- a/backend/schemas/output/sections/AuditFindingsText.schema.json +++ b/backend/schemas/output/sections/AuditFindingsText.schema.json @@ -84,9 +84,6 @@ "version": 20230807 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230408 diff --git a/backend/schemas/output/sections/AuditeeCertification.schema.json b/backend/schemas/output/sections/AuditeeCertification.schema.json new file mode 100644 index 0000000000..d6c39a83ba --- /dev/null +++ b/backend/schemas/output/sections/AuditeeCertification.schema.json @@ -0,0 +1,69 @@ +{ + "additionalProperties": false, + "properties": { + "auditee_certification": { + "description": "Auditee certification (required checkboxes)", + "properties": { + "has_engaged_auditor": { + "type": "boolean" + }, + "has_no_BII": { + "type": "boolean" + }, + "has_no_PII": { + "type": "boolean" + }, + "is_2CFR_compliant": { + "type": "boolean" + }, + "is_FAC_releasable": { + "type": "boolean" + }, + "is_complete_and_accurate": { + "type": "boolean" + }, + "is_issued_and_signed": { + "type": "boolean" + }, + "meets_2CFR_specifications": { + "type": "boolean" + } + }, + "required": [ + "has_no_PII", + "has_no_BII", + "meets_2CFR_specifications", + "is_2CFR_compliant", + "is_complete_and_accurate", + "has_engaged_auditor", + "is_issued_and_signed", + "is_FAC_releasable" + ], + "type": "object" + }, + "auditee_signature": { + "description": "Auditee signature and title", + "properties": { + "auditee_certification_date_signed": { + "description": "MM/DD/YYYY", + "pattern": "^[0-9]{2}/[0-9]{2}/[0-9]{4}$", + "title": "Date", + "type": "string" + }, + "auditee_name": { + "type": "string" + }, + "auditee_title": { + "type": "string" + } + }, + "required": [ + "auditee_name", + "auditee_title" + ], + "type": "object" + } + }, + "title": "AuditeeCertification", + "type": "object" +} diff --git a/backend/schemas/output/sections/AuditorCertification.schema.json b/backend/schemas/output/sections/AuditorCertification.schema.json new file mode 100644 index 0000000000..5cc59a4626 --- /dev/null +++ b/backend/schemas/output/sections/AuditorCertification.schema.json @@ -0,0 +1,57 @@ +{ + "additionalProperties": false, + "properties": { + "auditor_certification": { + "description": "Auditor certification (required checkboxes)", + "properties": { + "has_no_auditee_procedures": { + "type": "boolean" + }, + "has_used_auditors_report": { + "type": "boolean" + }, + "is_FAC_releasable": { + "type": "boolean" + }, + "is_OMB_limited": { + "type": "boolean" + }, + "is_auditee_responsible": { + "type": "boolean" + } + }, + "required": [ + "is_OMB_limited", + "is_auditee_responsible", + "has_used_auditors_report", + "has_no_auditee_procedures", + "is_FAC_releasable" + ], + "type": "object" + }, + "auditor_signature": { + "description": "Auditor signature and title", + "properties": { + "auditor_certification_date_signed": { + "description": "MM/DD/YYYY", + "pattern": "^[0-9]{2}/[0-9]{2}/[0-9]{4}$", + "title": "Date", + "type": "string" + }, + "auditor_name": { + "type": "string" + }, + "auditor_title": { + "type": "string" + } + }, + "required": [ + "auditor_name", + "auditor_title" + ], + "type": "object" + } + }, + "title": "AuditorCertification", + "type": "object" +} diff --git a/backend/schemas/output/sections/CorrectiveActionPlan.schema.json b/backend/schemas/output/sections/CorrectiveActionPlan.schema.json index fec707c263..22582cd47f 100644 --- a/backend/schemas/output/sections/CorrectiveActionPlan.schema.json +++ b/backend/schemas/output/sections/CorrectiveActionPlan.schema.json @@ -84,9 +84,6 @@ "version": 20230807 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230410 diff --git a/backend/schemas/output/sections/FederalAwards.schema.json b/backend/schemas/output/sections/FederalAwards.schema.json index b4a84c2f70..935ade83e0 100644 --- a/backend/schemas/output/sections/FederalAwards.schema.json +++ b/backend/schemas/output/sections/FederalAwards.schema.json @@ -735,9 +735,6 @@ "version": 20230807 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230408 diff --git a/backend/schemas/output/sections/FederalAwardsAuditFindings.schema.json b/backend/schemas/output/sections/FederalAwardsAuditFindings.schema.json index 6e5e2b39ff..7b86cd775f 100644 --- a/backend/schemas/output/sections/FederalAwardsAuditFindings.schema.json +++ b/backend/schemas/output/sections/FederalAwardsAuditFindings.schema.json @@ -8603,9 +8603,6 @@ "version": 20230807 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230410 diff --git a/backend/schemas/output/sections/GeneralInformation.schema.json b/backend/schemas/output/sections/GeneralInformation.schema.json index 52e8b26174..0bb80a2140 100644 --- a/backend/schemas/output/sections/GeneralInformation.schema.json +++ b/backend/schemas/output/sections/GeneralInformation.schema.json @@ -126,13 +126,67 @@ "$id": "http://example.org/generalinformation", "$schema": "http://json-schema.org/draft/2019-09/schema#", "additionalProperties": false, + "anyOf": [ + { + "if": { + "properties": { + "audit_period_covered": { + "const": "annual" + } + } + }, + "then": { + "audit_period_other_months": { + "description": "Empty string or null", + "enum": [ + "", + "null" + ], + "title": "EmptyString_Null" + } + } + }, + { + "if": { + "properties": { + "audit_period_covered": { + "const": "biennial" + } + } + }, + "then": { + "audit_period_other_months": { + "description": "Empty string or null", + "enum": [ + "", + "null" + ], + "title": "EmptyString_Null" + } + } + }, + { + "if": { + "properties": { + "audit_period_covered": { + "const": "other" + } + } + }, + "then": { + "audit_period_other_months": { + "pattern": "^0[0-9]|1[0-8]$", + "type": "string" + } + } + } + ], "metamodel_version": "1.7.0", "properties": { "audit_period_covered": { "$ref": "#/$defs/AuditPeriod" }, "audit_period_other_months": { - "pattern": "^[0-1][0-9]$", "type": "string" }, "audit_type": { diff --git a/backend/schemas/output/sections/NotesToSefa.schema.json b/backend/schemas/output/sections/NotesToSefa.schema.json index b9d8ad63a4..8f6f966134 100644 --- a/backend/schemas/output/sections/NotesToSefa.schema.json +++ b/backend/schemas/output/sections/NotesToSefa.schema.json @@ -92,9 +92,6 @@ "version": 20230713 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230713 diff --git a/backend/schemas/output/sections/SecondaryAuditors.schema.json b/backend/schemas/output/sections/SecondaryAuditors.schema.json index 9978c376fd..fad3c7f4c7 100644 --- a/backend/schemas/output/sections/SecondaryAuditors.schema.json +++ b/backend/schemas/output/sections/SecondaryAuditors.schema.json @@ -173,9 +173,6 @@ "version": 20230714 } }, - "required": [ - "Meta" - ], "title": "UNNAMED", "type": "object", "version": 20230714 diff --git a/backend/schemas/source/base/Base.libsonnet b/backend/schemas/source/base/Base.libsonnet index 7923baa1a4..32c4383d02 100644 --- a/backend/schemas/source/base/Base.libsonnet +++ b/backend/schemas/source/base/Base.libsonnet @@ -297,9 +297,6 @@ local REGEX_MONTHS_OTHER = '^0[0-9]|1[0-8]$'; local type_zipcode = Types.string { pattern: REGEX_ZIPCODE, }; -local type_months_other = Types.string { - pattern: REGEX_MONTHS_OTHER, -}; // UEIs are not case-sensitive, but we will upper-case all UEIs and store them // as uppercase-only, so we're only dealing with uppercase letters in these patterns. @@ -358,6 +355,16 @@ local Compound = { description: 'Reference Number', pattern: '^20[2-9][0-9]-[0-9]{3}$', }, + ComplianceRequirement: { + title: 'ComplianceRequirement', + description: 'Compliance requirement type', + pattern: '^A?B?C?E?F?G?H?I?J?L?M?N?P?$', + }, + Date: Types.string { + title: 'Date', + description: 'MM/DD/YYYY', + pattern: '^[0-9]{2}\/[0-9]{2}\/[0-9]{4}$', + }, NonEmptyString: Types.string { minLength: 1, }, @@ -369,7 +376,9 @@ local Compound = { pattern: phone_regex, }, Zip: type_zipcode, - MonthsOther: type_months_other, + MonthsOther: Types.string { + pattern: REGEX_MONTHS_OTHER, + }, }; diff --git a/backend/schemas/source/sections/AuditeeCertification.schema.jsonnet b/backend/schemas/source/sections/AuditeeCertification.schema.jsonnet new file mode 100644 index 0000000000..0e21b96d67 --- /dev/null +++ b/backend/schemas/source/sections/AuditeeCertification.schema.jsonnet @@ -0,0 +1,52 @@ +local Base = import '../base/Base.libsonnet'; +local Func = import '../base/Functions.libsonnet'; +local Types = Base.Types; + +local AuditeeCertification = Types.object { + additionalProperties: false, + properties: { + auditee_certification: Types.object { + description: 'Auditee certification (required checkboxes)', + properties: { + has_no_PII: Types.boolean, + has_no_BII: Types.boolean, + meets_2CFR_specifications: Types.boolean, + is_2CFR_compliant: Types.boolean, + is_complete_and_accurate: Types.boolean, + has_engaged_auditor: Types.boolean, + is_issued_and_signed: Types.boolean, + is_FAC_releasable: Types.boolean, + }, + required: [ + 'has_no_PII', + 'has_no_BII', + 'meets_2CFR_specifications', + 'is_2CFR_compliant', + 'is_complete_and_accurate', + 'has_engaged_auditor', + 'is_issued_and_signed', + 'is_FAC_releasable', + ], + }, + auditee_signature: Types.object { + description: 'Auditee signature and title', + properties: { + auditee_name: Types.string, + auditee_title: Types.string, + auditee_certification_date_signed: Base.Compound.Date, + }, + required: ['auditee_name', 'auditee_title'], + }, + }, + title: 'AuditeeCertification', +}; + +local Root = Types.object { + additionalProperties: false, + properties: { + AuditeeCertification: AuditeeCertification, + }, + version: 20230802, +}; + +AuditeeCertification diff --git a/backend/schemas/source/sections/AuditorCertification.schema.jsonnet b/backend/schemas/source/sections/AuditorCertification.schema.jsonnet new file mode 100644 index 0000000000..03303241ea --- /dev/null +++ b/backend/schemas/source/sections/AuditorCertification.schema.jsonnet @@ -0,0 +1,46 @@ +local Base = import '../base/Base.libsonnet'; +local Func = import '../base/Functions.libsonnet'; +local Types = Base.Types; + +local AuditorCertification = Types.object { + additionalProperties: false, + properties: { + auditor_certification: Types.object { + description: 'Auditor certification (required checkboxes)', + properties: { + is_OMB_limited: Types.boolean, + is_auditee_responsible: Types.boolean, + has_used_auditors_report: Types.boolean, + has_no_auditee_procedures: Types.boolean, + is_FAC_releasable: Types.boolean, + }, + required: [ + 'is_OMB_limited', + 'is_auditee_responsible', + 'has_used_auditors_report', + 'has_no_auditee_procedures', + 'is_FAC_releasable', + ], + }, + auditor_signature: Types.object { + description: 'Auditor signature and title', + properties: { + auditor_name: Types.string, + auditor_title: Types.string, + auditor_certification_date_signed: Base.Compound.Date, + }, + required: ['auditor_name', 'auditor_title'], + }, + }, + title: 'AuditorCertification', +}; + +local Root = Types.object { + additionalProperties: false, + properties: { + AuditorCertification: AuditorCertification, + }, + version: 20230802, +}; + +AuditorCertification diff --git a/backend/schemas/source/sections/GeneralInformation.schema.jsonnet b/backend/schemas/source/sections/GeneralInformation.schema.jsonnet index f075d91b87..fe7a1f66a2 100644 --- a/backend/schemas/source/sections/GeneralInformation.schema.jsonnet +++ b/backend/schemas/source/sections/GeneralInformation.schema.jsonnet @@ -23,7 +23,7 @@ local Types = Base.Types; audit_period_covered: { '$ref': '#/$defs/AuditPeriod', }, - audit_period_other_months: Base.Compound.MonthsOther, + audit_period_other_months: Types.string, auditee_address_line_1: Types.string { maxLength: 100, }, @@ -102,6 +102,44 @@ local Types = Base.Types; '$ref': '#/$defs/UserProvidedOrganizationType', }, }, + anyOf: [ + { + 'if': { + properties: { + audit_period_covered: { + const: 'annual', + }, + }, + }, + 'then': { + audit_period_other_months: Base.Enum.EmptyString_Null, + }, + }, + { + 'if': { + properties: { + audit_period_covered: { + const: 'biennial', + }, + }, + }, + 'then': { + audit_period_other_months: Base.Enum.EmptyString_Null, + }, + }, + { + 'if': { + properties: { + audit_period_covered: { + const: 'other', + }, + }, + }, + 'then': { + audit_period_other_months: Base.Compound.MonthsOther, + }, + }, + ], required: [], title: 'GeneralInformation', type: 'object', diff --git a/docs/testing.md b/docs/testing.md index 1b90170f39..642538542d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -119,7 +119,7 @@ Like with Bandit, new code will need to pass all of these to be merged into the # End-to-end testing We use Cypress to do end-to-end testing of the application. Tests are defined -in files in [backend/cypress/e2e/](/backend/cypress/e2e). +in files in [backend/cypress/e2e/](/backend/cypress/e2e). Run cypress with `npx cypress open`. ## Testing behind Login.gov