diff --git a/backend/audit/admin.py b/backend/audit/admin.py index a5a27c874..8adf802e5 100644 --- a/backend/audit/admin.py +++ b/backend/audit/admin.py @@ -13,7 +13,11 @@ UeiValidationWaiver, ) from audit.models.models import STATUS -from audit.models.viewflow import sac_transition +from audit.models.viewflow import ( + sac_flag_for_removal, + sac_revert_from_flagged_for_removal, + sac_transition, +) from audit.validators import ( validate_auditee_certification_json, validate_auditor_certification_json, @@ -24,6 +28,76 @@ logger = logging.getLogger(__name__) +@admin.action(description="Revert selected report(s) to In Progress") +def revert_to_in_progress(modeladmin, request, queryset): + successful_reverts = [] + errors = [] + + for sac in queryset: + if sac.submission_status == STATUS.FLAGGED_FOR_REMOVAL: + try: + sac_revert_from_flagged_for_removal(sac, request.user) + sac.save() + successful_reverts.append(sac.report_id) + except Exception as e: + modeladmin.message_user( + request, + f"Error reverting {sac.report_id}: {str(e)}", + level=messages.ERROR, + ) + errors.append(sac.report_id) + else: + modeladmin.message_user( + request, + f"Report {sac.report_id} is not flagged for removal.", + level=messages.WARNING, + ) + errors.append(sac.report_id) + + if successful_reverts: + modeladmin.message_user( + request, + f"Successfully reverted report(s) ({', '.join(successful_reverts)}) back to In Progress.", + level=messages.SUCCESS, + ) + + if errors: + modeladmin.message_user( + request, + f"Unable to revert report(s) ({', '.join(errors)}) back to In Progress.", + level=messages.ERROR, + ) + + +@admin.action(description="Flag selected report(s) for removal") +def flag_for_removal(modeladmin, request, queryset): + + flagged = [] + already_flagged = [] + + for sac in queryset: + if sac.submission_status != STATUS.FLAGGED_FOR_REMOVAL: + sac_flag_for_removal(sac, request.user) + sac.save() + flagged.append(sac.report_id) + else: + already_flagged.append(sac.report_id) + + if flagged: + modeladmin.message_user( + request, + f"Successfully flagged report(s) ({', '.join(flagged)}) for removal.", + level=messages.SUCCESS, + ) + + if already_flagged: + modeladmin.message_user( + request, + f"Report(s) ({', '.join(already_flagged)}) were already flagged.", + level=messages.WARNING, + ) + + class SACAdmin(admin.ModelAdmin): """ Support for read-only staff access, and control of what fields are present and @@ -41,6 +115,7 @@ def has_view_permission(self, request, obj=None): "report_id", "cognizant_agency", "oversight_agency", + "submission_status", ) list_filter = [ "cognizant_agency", @@ -50,6 +125,7 @@ def has_view_permission(self, request, obj=None): ] readonly_fields = ("submitted_by",) search_fields = ("general_information__auditee_uei", "report_id") + actions = [revert_to_in_progress, flag_for_removal] class AccessAdmin(admin.ModelAdmin): diff --git a/backend/audit/migrations/0015_alter_singleauditchecklist_submission_status_and_more.py b/backend/audit/migrations/0015_alter_singleauditchecklist_submission_status_and_more.py new file mode 100644 index 000000000..0b473f184 --- /dev/null +++ b/backend/audit/migrations/0015_alter_singleauditchecklist_submission_status_and_more.py @@ -0,0 +1,125 @@ +# Generated by Django 5.1.2 on 2024-12-17 16:56 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("audit", "0014_alter_sacvalidationwaiver_waiver_types"), + ] + + operations = [ + migrations.AlterField( + model_name="singleauditchecklist", + name="submission_status", + field=models.CharField( + choices=[ + ("in_progress", "In Progress"), + ("flagged_for_removal", "Flagged for Removal"), + ("ready_for_certification", "Ready for Certification"), + ("auditor_certified", "Auditor Certified"), + ("auditee_certified", "Auditee Certified"), + ("certified", "Certified"), + ("submitted", "Submitted"), + ("disseminated", "Disseminated"), + ], + default="in_progress", + ), + ), + migrations.AlterField( + model_name="singleauditchecklist", + name="transition_name", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("in_progress", "In Progress"), + ("flagged_for_removal", "Flagged for Removal"), + ("ready_for_certification", "Ready for Certification"), + ("auditor_certified", "Auditor Certified"), + ("auditee_certified", "Auditee Certified"), + ("certified", "Certified"), + ("submitted", "Submitted"), + ("disseminated", "Disseminated"), + ], + max_length=40, + ), + blank=True, + default=list, + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="submissionevent", + name="event", + field=models.CharField( + choices=[ + ("access-granted", "Access granted"), + ("additional-eins-updated", "Additional EINs updated"), + ("additional-eins-deleted", "Additional EINs deleted"), + ("additional-ueis-updated", "Additional UEIs updated"), + ("additional-ueis-deleted", "Additional UEIs deleted"), + ("audit-information-updated", "Audit information updated"), + ("audit-report-pdf-updated", "Audit report PDF updated"), + ( + "auditee-certification-completed", + "Auditee certification completed", + ), + ( + "auditor-certification-completed", + "Auditor certification completed", + ), + ( + "corrective-action-plan-updated", + "Corrective action plan updated", + ), + ( + "corrective-action-plan-deleted", + "Corrective action plan deleted", + ), + ("created", "Created"), + ("federal-awards-updated", "Federal awards updated"), + ( + "federal-awards-audit-findings-updated", + "Federal awards audit findings updated", + ), + ( + "federal-awards-audit-findings-deleted", + "Federal awards audit findings deleted", + ), + ( + "federal-awards-audit-findings-text-updated", + "Federal awards audit findings text updated", + ), + ( + "federal-awards-audit-findings-text-deleted", + "Federal awards audit findings text deleted", + ), + ( + "findings-uniform-guidance-updated", + "Findings uniform guidance updated", + ), + ( + "findings-uniform-guidance-deleted", + "Findings uniform guidance deleted", + ), + ("general-information-updated", "General information updated"), + ("locked-for-certification", "Locked for certification"), + ("unlocked-after-certification", "Unlocked after certification"), + ("notes-to-sefa-updated", "Notes to SEFA updated"), + ("secondary-auditors-updated", "Secondary auditors updated"), + ("secondary-auditors-deleted", "Secondary auditors deleted"), + ("submitted", "Submitted to the FAC for processing"), + ("disseminated", "Copied to dissemination tables"), + ("tribal-consent-updated", "Tribal audit consent updated"), + ( + "flagged-submission-for-removal", + "Flagged submission for removal", + ), + ("cancel-removal-flag", "Cancel removal flag"), + ] + ), + ), + ] diff --git a/backend/audit/models/models.py b/backend/audit/models/models.py index e35b1d06e..252d1a3f2 100644 --- a/backend/audit/models/models.py +++ b/backend/audit/models/models.py @@ -171,6 +171,7 @@ class STATUS: CERTIFIED = "certified" SUBMITTED = "submitted" DISSEMINATED = "disseminated" + FLAGGED_FOR_REMOVAL = "flagged_for_removal" class SingleAuditChecklist(models.Model, GeneralInformationMixin): # type: ignore @@ -303,6 +304,7 @@ def get_statuses(self) -> type[STATUS]: # Constants: STATUS_CHOICES = ( (STATUS.IN_PROGRESS, "In Progress"), + (STATUS.FLAGGED_FOR_REMOVAL, "Flagged for Removal"), (STATUS.READY_FOR_CERTIFICATION, "Ready for Certification"), (STATUS.AUDITOR_CERTIFIED, "Auditor Certified"), (STATUS.AUDITEE_CERTIFIED, "Auditee Certified"), diff --git a/backend/audit/models/submission_event.py b/backend/audit/models/submission_event.py index d4d675bb0..7db884718 100644 --- a/backend/audit/models/submission_event.py +++ b/backend/audit/models/submission_event.py @@ -42,6 +42,8 @@ class EventType: SUBMITTED = "submitted" DISSEMINATED = "disseminated" TRIBAL_CONSENT_UPDATED = "tribal-consent-updated" + FLAGGED_SUBMISSION_FOR_REMOVAL = "flagged-submission-for-removal" + CANCEL_REMOVAL_FLAG = "cancel-removal-flag" EVENT_TYPES = ( (EventType.ACCESS_GRANTED, _("Access granted")), @@ -96,6 +98,11 @@ class EventType: (EventType.SUBMITTED, _("Submitted to the FAC for processing")), (EventType.DISSEMINATED, _("Copied to dissemination tables")), (EventType.TRIBAL_CONSENT_UPDATED, _("Tribal audit consent updated")), + ( + EventType.FLAGGED_SUBMISSION_FOR_REMOVAL, + _("Flagged submission for removal"), + ), + (EventType.CANCEL_REMOVAL_FLAG, _("Cancel removal flag")), ) sac = models.ForeignKey("audit.SingleAuditChecklist", on_delete=models.CASCADE) diff --git a/backend/audit/models/viewflow.py b/backend/audit/models/viewflow.py index 35e460f0a..1d12515b3 100644 --- a/backend/audit/models/viewflow.py +++ b/backend/audit/models/viewflow.py @@ -29,6 +29,41 @@ def sac_revert_from_submitted(sac): return False +def sac_revert_from_flagged_for_removal(sac, user): + """ + Transitions the submission_state for a SingleAuditChecklist back + to "in_progress" so the user can continue working on it. + This should be accessible to django admin. + """ + if sac.submission_status == STATUS.FLAGGED_FOR_REMOVAL: + flow = SingleAuditChecklistFlow(sac) + + flow.transition_to_in_progress_again() + + with CurationTracking(): + sac.save( + event_user=user, + event_type=SubmissionEvent.EventType.CANCEL_REMOVAL_FLAG, + ) + + +def sac_flag_for_removal(sac, user): + """ + Transitions the submission_state for a SingleAuditChecklist to "flagged_for_removal". + This should be accessible to django admin. + """ + if sac.submission_status == STATUS.IN_PROGRESS: + flow = SingleAuditChecklistFlow(sac) + + flow.transition_to_flagged_for_removal() + + with CurationTracking(): + sac.save( + event_user=user, + event_type=SubmissionEvent.EventType.FLAGGED_SUBMISSION_FOR_REMOVAL, + ) + + def sac_transition(request, sac, **kwargs): """ Transitions the submission_state for a SingleAuditChecklist (sac). @@ -54,6 +89,14 @@ def sac_transition(request, sac, **kwargs): ) return True + elif target == STATUS.FLAGGED_FOR_REMOVAL: + flow.transition_to_flagged_for_removal() + sac.save( + event_user=user, + event_type=SubmissionEvent.EventType.FLAGGED_SUBMISSION_FOR_REMOVAL, + ) + return True + elif target == STATUS.READY_FOR_CERTIFICATION: flow.transition_to_ready_for_certification() sac.save( @@ -127,11 +170,24 @@ def transition_to_ready_for_certification(self): self.sac.transition_name.append(STATUS.READY_FOR_CERTIFICATION) self.sac.transition_date.append(datetime.datetime.now(datetime.timezone.utc)) + @state.transition( + source=STATUS.IN_PROGRESS, + target=STATUS.FLAGGED_FOR_REMOVAL, + ) + def transition_to_flagged_for_removal(self): + """ + The permission checks verifying that the user attempting to do this has + the appropriate privileges will be done at the view level. + """ + self.sac.transition_name.append(STATUS.FLAGGED_FOR_REMOVAL) + self.sac.transition_date.append(datetime.datetime.now(datetime.timezone.utc)) + @state.transition( source=[ STATUS.READY_FOR_CERTIFICATION, STATUS.AUDITOR_CERTIFIED, STATUS.AUDITEE_CERTIFIED, + STATUS.FLAGGED_FOR_REMOVAL, ], target=STATUS.IN_PROGRESS, ) diff --git a/backend/audit/templates/audit/manage-submission.html b/backend/audit/templates/audit/manage-submission.html index 75f988348..dc143a50a 100644 --- a/backend/audit/templates/audit/manage-submission.html +++ b/backend/audit/templates/audit/manage-submission.html @@ -85,6 +85,8 @@

Manage user roles

href="{{ add_editor_url }}">Add editor Return to checklist + Return to Submissions {% include "audit-metadata.html" %} diff --git a/backend/audit/templates/audit/my_submissions.html b/backend/audit/templates/audit/my_submissions.html index b570b99fd..1ba43e74d 100644 --- a/backend/audit/templates/audit/my_submissions.html +++ b/backend/audit/templates/audit/my_submissions.html @@ -27,6 +27,8 @@

Audits in progress

data-open-modal>UEI Fiscal period end date + User Access + Delete @@ -46,6 +48,20 @@

Audits in progress

{{ item.report_id }} {{ item.auditee_uei }} {{ item.fiscal_year_end_date }} + + + + + + + + + + {% endfor %} diff --git a/backend/audit/templates/audit/remove-submission-in-progress.html b/backend/audit/templates/audit/remove-submission-in-progress.html new file mode 100644 index 000000000..ef32f58e5 --- /dev/null +++ b/backend/audit/templates/audit/remove-submission-in-progress.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% load static %} +{% load sprite_helper %} + +{% block content %} +
+

Confirm Report Removal

+

Are you sure you want to remove the following report?

+ +
+
+
    +
  • Auditee UEI: {{ auditee_uei }}
  • +
  • Auditee Name: {{ auditee_name }}
  • +
  • Report ID: {{ report_id }}
  • +
  • Fiscal Period End Date: {{ fiscal_year_end_date }}
  • +
+
+
+ +
+ {% csrf_token %} +
+ + Return to Submissions +
+
+
+{% endblock content %} diff --git a/backend/audit/test_admin.py b/backend/audit/test_admin.py index 10c3f4b78..be6e22edd 100644 --- a/backend/audit/test_admin.py +++ b/backend/audit/test_admin.py @@ -4,7 +4,12 @@ from django.contrib.messages.storage.fallback import FallbackStorage from .models import SacValidationWaiver, SingleAuditChecklist from .models.models import STATUS -from .admin import SacValidationWaiverAdmin +from .admin import ( + SACAdmin, + SacValidationWaiverAdmin, + flag_for_removal, + revert_to_in_progress, +) from django.utils import timezone from model_bakery import baker from django.contrib.sessions.middleware import SessionMiddleware @@ -161,3 +166,63 @@ def test_handle_auditee_certification(self): # Checking results self.sac.refresh_from_db() self.assertEqual(self.sac.submission_status, STATUS.AUDITEE_CERTIFIED) + + +class TestAdminActions(TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = SACAdmin(SingleAuditChecklist, self.site) + self.request = RequestFactory().get("/admin/audit/singleauditchecklist/") + self.request.user = baker.make(User, is_staff=True) + self.request.session = {} + self.request._messages = FallbackStorage(self.request) + + # Sample records + self.report1 = baker.make( + SingleAuditChecklist, + report_id="RPT001", + submission_status=STATUS.FLAGGED_FOR_REMOVAL, + ) + self.report2 = baker.make( + SingleAuditChecklist, + report_id="RPT002", + submission_status=STATUS.IN_PROGRESS, + ) + + def test_revert_to_in_progress_success(self): + queryset = SingleAuditChecklist.objects.filter(report_id="RPT001") + revert_to_in_progress(self.admin, self.request, queryset) + + self.report1.refresh_from_db() + self.assertEqual(self.report1.submission_status, STATUS.IN_PROGRESS) + messages = [m.message for m in self.request._messages] + self.assertIn( + "Successfully reverted report(s) (RPT001) back to In Progress.", messages + ) + + def test_revert_to_in_progress_failure(self): + queryset = SingleAuditChecklist.objects.filter(report_id="RPT002") + revert_to_in_progress(self.admin, self.request, queryset) + + self.report2.refresh_from_db() + self.assertEqual(self.report2.submission_status, STATUS.IN_PROGRESS) + messages = [m.message for m in self.request._messages] + self.assertIn("Report RPT002 is not flagged for removal.", messages) + + def test_flag_for_removal_success(self): + queryset = SingleAuditChecklist.objects.filter(report_id="RPT002") + flag_for_removal(self.admin, self.request, queryset) + + self.report2.refresh_from_db() + self.assertEqual(self.report2.submission_status, STATUS.FLAGGED_FOR_REMOVAL) + messages = [m.message for m in self.request._messages] + self.assertIn("Successfully flagged report(s) (RPT002) for removal.", messages) + + def test_flag_for_removal_already_flagged(self): + queryset = SingleAuditChecklist.objects.filter(report_id="RPT001") + flag_for_removal(self.admin, self.request, queryset) + + self.report1.refresh_from_db() + self.assertEqual(self.report1.submission_status, STATUS.FLAGGED_FOR_REMOVAL) + messages = [m.message for m in self.request._messages] + self.assertIn("Report(s) (RPT001) were already flagged.", messages) diff --git a/backend/audit/test_models.py b/backend/audit/test_models.py index 81a451dcb..ed5e56fd2 100644 --- a/backend/audit/test_models.py +++ b/backend/audit/test_models.py @@ -92,10 +92,18 @@ def test_submission_status_transitions(self): STATUS.READY_FOR_CERTIFICATION, STATUS.AUDITOR_CERTIFIED, STATUS.AUDITEE_CERTIFIED, + STATUS.FLAGGED_FOR_REMOVAL, ], STATUS.IN_PROGRESS, "transition_to_in_progress_again", ), + ( + [ + STATUS.IN_PROGRESS, + ], + STATUS.FLAGGED_FOR_REMOVAL, + "transition_to_flagged_for_removal", + ), ) now = datetime.now(timezone.utc) diff --git a/backend/audit/urls.py b/backend/audit/urls.py index 88e7b610a..d8c6260aa 100644 --- a/backend/audit/urls.py +++ b/backend/audit/urls.py @@ -110,6 +110,11 @@ def camel_to_hyphen(raw: str) -> str: views.RemoveEditorView.as_view(), name="RemoveEditorView", ), + path( + "manage-submission/remove-report/", + views.RemoveSubmissionView.as_view(), + name="RemoveSubmissionInProgress", + ), path( "workbook/xlsx//", views.PredisseminationXlsxDownloadView.as_view(), diff --git a/backend/audit/views/__init__.py b/backend/audit/views/__init__.py index a9a839264..bc3fe9c17 100644 --- a/backend/audit/views/__init__.py +++ b/backend/audit/views/__init__.py @@ -14,6 +14,7 @@ PredisseminationPdfDownloadView, PredisseminationSummaryReportDownloadView, ) +from .remove_submission_in_progress import RemoveSubmissionView from .submission_progress_view import ( # noqa SubmissionProgressView, submission_progress_check, @@ -67,4 +68,5 @@ TribalDataConsent, UnlockAfterCertificationView, UploadReportView, + RemoveSubmissionView, ] diff --git a/backend/audit/views/remove_submission_in_progress.py b/backend/audit/views/remove_submission_in_progress.py new file mode 100644 index 000000000..f0c94748d --- /dev/null +++ b/backend/audit/views/remove_submission_in_progress.py @@ -0,0 +1,64 @@ +import logging +from django.shortcuts import redirect, render, reverse +from django.views import generic +from django.core.exceptions import PermissionDenied + +from audit.mixins import ( + SingleAuditChecklistAccessRequiredMixin, +) +from audit.models import ( + Access, + SingleAuditChecklist, +) +from audit.views.views import verify_status +from audit.models.models import STATUS +from audit.models.viewflow import SingleAuditChecklistFlow + +logger = logging.getLogger(__name__) + + +class RemoveSubmissionView(SingleAuditChecklistAccessRequiredMixin, generic.View): + """ + View for removing an audit. + """ + + template = "audit/remove-submission-in-progress.html" + + @verify_status(STATUS.IN_PROGRESS) + def get(self, request, *args, **kwargs): + """ + Show the audit to be removed and confirmation form. + """ + report_id = kwargs["report_id"] + sac = SingleAuditChecklist.objects.get(report_id=report_id) + + if not Access.objects.filter( + email=request.user.email, sac=sac, role="editor" + ).exists(): + raise PermissionDenied("Only authorized auditors can remove audits.") + + context = { + "auditee_uei": sac.general_information["auditee_uei"], + "auditee_name": sac.general_information.get("auditee_name"), + "report_id": sac.report_id, + "fiscal_year_end_date": sac.general_information.get( + "auditee_fiscal_period_end" + ), + } + + return render(request, self.template, context) + + @verify_status(STATUS.IN_PROGRESS) + def post(self, request, *args, **kwargs): + """ + Remove the audit and redirect to the audits list. + """ + report_id = kwargs["report_id"] + sac = SingleAuditChecklist.objects.get(report_id=report_id) + + flow = SingleAuditChecklistFlow(sac) + flow.transition_to_flagged_for_removal() + sac.save() + url = reverse("audit:MySubmissions") + + return redirect(url) diff --git a/backend/audit/views/views.py b/backend/audit/views/views.py index 1d785128c..c5a87acf7 100644 --- a/backend/audit/views/views.py +++ b/backend/audit/views/views.py @@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from django.shortcuts import render, redirect from django.db import transaction -from django.db.models import F +from django.db.models import F, Q from django.db.transaction import TransactionManagementError from django.core.exceptions import BadRequest, PermissionDenied, ValidationError from django.contrib.auth.mixins import LoginRequiredMixin @@ -125,7 +125,9 @@ def fetch_my_submissions(cls, user): """ accesses = Access.objects.filter(user=user) sac_ids = [access.sac.id for access in accesses] - data = SingleAuditChecklist.objects.filter(id__in=sac_ids).values( + data = SingleAuditChecklist.objects.filter( + Q(id__in=sac_ids) & ~Q(submission_status=STATUS.FLAGGED_FOR_REMOVAL) + ).values( "report_id", "submission_status", auditee_uei=F("general_information__auditee_uei"),