diff --git a/backend/audit/templates/audit/manage-submission-change-access.html b/backend/audit/templates/audit/manage-submission-change-access.html
new file mode 100644
index 0000000000..4e2a16a2df
--- /dev/null
+++ b/backend/audit/templates/audit/manage-submission-change-access.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+{% load static %}
+{% block content %}
+
+ {% include "audit-metadata.html" %}
+{% endblock content %}
diff --git a/backend/audit/test_manage_submission_access_view.py b/backend/audit/test_manage_submission_access_view.py
new file mode 100644
index 0000000000..a6a1dd7e50
--- /dev/null
+++ b/backend/audit/test_manage_submission_access_view.py
@@ -0,0 +1,165 @@
+from django.contrib.auth.models import User as DjangoUser
+from django.test import TestCase
+from django.urls import reverse
+
+from model_bakery import baker
+from .models import (
+ Access,
+ DeletedAccess,
+ SingleAuditChecklist,
+ User,
+)
+
+
+def _make_test_users_by_email(emails: list[str]) -> list[DjangoUser]:
+ return [baker.make(User, email=email) for email in emails]
+
+
+def _make_access(sac: SingleAuditChecklist, role: str, user: DjangoUser) -> Access:
+ return baker.make(Access, user=user, email=user.email, sac=sac, role=role)
+
+
+def _make_user_and_sac(**kwargs):
+ user = baker.make(User)
+ sac = baker.make(SingleAuditChecklist, **kwargs)
+ return user, sac
+
+
+class ChangeAuditorCertifyingOfficialViewTests(TestCase):
+ """
+ GET and POST tests for changing auditor certifying official.
+ """
+
+ role = "certifying_auditor_contact"
+ other_role = "certifying_auditee_contact"
+ view = "audit:ChangeAuditorCertifyingOfficial"
+
+ def test_basic_get(self):
+ """
+ A user should be able to access this page for a SAC they're associated with.
+ """
+ user, sac = _make_user_and_sac()
+ baker.make(Access, user=user, sac=sac, role="editor")
+ sac.general_information = {"auditee_uei": "YESIAMAREALUEI"}
+ sac.save()
+ current_cac = baker.make(Access, sac=sac, role=self.role)
+
+ self.client.force_login(user)
+ url = reverse(self.view, kwargs={"report_id": sac.report_id})
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("YESIAMAREALUEI", response.content.decode("UTF-8"))
+ self.assertIn(current_cac.email, response.content.decode("UTF-8"))
+
+ def test_basic_post(self):
+ """
+ Submitting the form with a new email address should delete the existing
+ Access, create a DeletedAccess, and create a new Access.
+ """
+ user = baker.make(User, email="removing_user@example.com")
+ sac = baker.make(SingleAuditChecklist)
+ baker.make(Access, user=user, sac=sac, role="editor")
+ baker.make(
+ Access,
+ sac=sac,
+ role=self.other_role,
+ email="contact@example.com",
+ )
+ sac.general_information = {"auditee_uei": "YESIAMAREALUEI"}
+ sac.save()
+ current_cac = baker.make(Access, sac=sac, role=self.role)
+
+ self.client.force_login(user)
+
+ data = {
+ "fullname": "The New CAC",
+ "email": "newcacuser@example.com",
+ }
+
+ url = reverse(self.view, kwargs={"report_id": sac.report_id})
+ response = self.client.post(url, data=data)
+ self.assertEqual(302, response.status_code)
+
+ newaccess = Access.objects.get(
+ sac=sac, fullname=data["fullname"], email=data["email"]
+ )
+ self.assertEqual(self.role, newaccess.role)
+ oldaccess = DeletedAccess.objects.get(
+ sac=sac,
+ fullname=current_cac.fullname,
+ email=current_cac.email,
+ )
+ self.assertEqual(self.role, oldaccess.role)
+
+ def test_bad_email_post(self):
+ """
+ Submitting an email address that's already in use for the other role should
+ result in a 400 and returning to the form page.
+ """
+ new_email = "newcacuser@example.com"
+ user = baker.make(User, email="removing_user@example.com")
+ sac = baker.make(SingleAuditChecklist)
+ baker.make(Access, user=user, sac=sac, role="editor")
+ baker.make(Access, sac=sac, role=self.other_role, email=new_email)
+ baker.make(Access, sac=sac, role=self.role)
+ sac.general_information = {"auditee_uei": "YESIAMAREALUEI"}
+ sac.save()
+
+ self.client.force_login(user)
+
+ data = {"fullname": "The New CAC", "email": new_email}
+
+ url = reverse(self.view, kwargs={"report_id": sac.report_id})
+ response = self.client.post(url, data=data)
+ self.assertEqual(400, response.status_code)
+
+ def test_login_required(self):
+ """When an unauthenticated request is made"""
+
+ response = self.client.get(
+ reverse(
+ self.view,
+ kwargs={"report_id": "12345"},
+ )
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_bad_report_id_returns_403(self):
+ """
+ When a request is made for a malformed or nonexistent report_id,
+ a 403 error should be returned
+ """
+ user = baker.make(User)
+
+ self.client.force_login(user)
+
+ response = self.client.get(
+ reverse(self.view, kwargs={"report_id": "this is not a report id"})
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_inaccessible_audit_returns_403(self):
+ """When a request is made for an audit that is inaccessible for this user, a 403 error should be returned"""
+ user, sac = _make_user_and_sac()
+
+ self.client.force_login(user)
+ response = self.client.post(
+ reverse(self.view, kwargs={"report_id": sac.report_id})
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+
+class ChangeAuditeeCertifyingOfficialViewTests(
+ ChangeAuditorCertifyingOfficialViewTests
+):
+ """
+ GET and POST tests for changing auditee certifying official.
+ """
+
+ role = "certifying_auditee_contact"
+ other_role = "certifying_auditor_contact"
+ view = "audit:ChangeAuditeeCertifyingOfficial"
diff --git a/backend/audit/urls.py b/backend/audit/urls.py
index d5bae3a423..ce36f23d00 100644
--- a/backend/audit/urls.py
+++ b/backend/audit/urls.py
@@ -85,6 +85,16 @@ def camel_to_hyphen(raw: str) -> str:
views.UnlockAfterCertificationView.as_view(),
name="UnlockAfterCertification",
),
+ path(
+ "manage-submission/auditor-certifying-official/",
+ views.ChangeAuditorCertifyingOfficialView.as_view(),
+ name="ChangeAuditorCertifyingOfficial",
+ ),
+ path(
+ "manage-submission/auditee-certifying-official/",
+ views.ChangeAuditeeCertifyingOfficialView.as_view(),
+ name="ChangeAuditeeCertifyingOfficial",
+ ),
]
for form_section in FORM_SECTIONS:
diff --git a/backend/audit/views/__init__.py b/backend/audit/views/__init__.py
index 361ff5cc9b..c3a6ecdb68 100644
--- a/backend/audit/views/__init__.py
+++ b/backend/audit/views/__init__.py
@@ -1,4 +1,8 @@
from .home import Home
+from .manage_submission_access import (
+ ChangeAuditeeCertifyingOfficialView,
+ ChangeAuditorCertifyingOfficialView,
+)
from .no_robots import no_robots
from .submission_progress_view import ( # noqa
SubmissionProgressView,
@@ -31,6 +35,8 @@
AuditorCertificationStep1View,
AuditorCertificationStep2View,
CertificationView,
+ ChangeAuditeeCertifyingOfficialView,
+ ChangeAuditorCertifyingOfficialView,
CrossValidationView,
EditSubmission,
ExcelFileHandlerView,
diff --git a/backend/audit/views/manage_submission_access.py b/backend/audit/views/manage_submission_access.py
new file mode 100644
index 0000000000..d5ee7c5fb7
--- /dev/null
+++ b/backend/audit/views/manage_submission_access.py
@@ -0,0 +1,126 @@
+from django import forms
+from django.db import transaction
+from django.shortcuts import redirect, render, reverse
+from django.views import generic
+
+from audit.mixins import (
+ SingleAuditChecklistAccessRequiredMixin,
+)
+from audit.models import (
+ ACCESS_ROLES,
+ Access,
+ SingleAuditChecklist,
+)
+
+
+class ChangeAccessForm(forms.Form):
+ """
+ Form for changing access. The view class, not this class, has the responsibility for handling whether we’re deleting access (in the cases where only one user can have the role) or adding access (in the cases where multiple users can have the role).
+ """
+
+ fullname = forms.CharField()
+ email = forms.EmailField()
+ # email_confirm = forms.EmailField()
+
+ # def clean(self):
+ # cleaned = super().clean()
+ # if cleaned.get("email") != cleaned.get("email_confirm"):
+ # raise ValidationError(
+ # "Email address and confirmed email address must match"
+ # )
+
+
+class ChangeAuditorCertifyingOfficialView(
+ SingleAuditChecklistAccessRequiredMixin, generic.View
+):
+ """
+ View for changing the auditor certifying official
+ """
+
+ role = "certifying_auditor_contact"
+ other_role = "certifying_auditee_contact"
+
+ def get(self, request, *args, **kwargs):
+ """
+ Show the current auditor certifying official and the form.
+ """
+ report_id = kwargs["report_id"]
+ sac = SingleAuditChecklist.objects.get(report_id=report_id)
+ access = Access.objects.get(sac=sac, role=self.role)
+ friendly_role = [r for r in ACCESS_ROLES if r[0] == self.role][0][1]
+ context = {
+ "role": self.role,
+ "friendly_role": friendly_role,
+ "auditee_uei": sac.general_information["auditee_uei"],
+ "auditee_name": sac.general_information.get("auditee_name"),
+ "certifier_name": access.fullname,
+ "email": access.email,
+ "report_id": report_id,
+ "errors": [],
+ }
+
+ return render(request, "audit/manage-submission-change-access.html", context)
+
+ def post(self, request, *args, **kwargs):
+ """
+ Change the current auditor certifying official and redirect to submission
+ progress.
+ """
+ report_id = kwargs["report_id"]
+ sac = SingleAuditChecklist.objects.get(report_id=report_id)
+ form = ChangeAccessForm(request.POST)
+ form.full_clean()
+ url = reverse("audit:SubmissionProgress", kwargs={"report_id": report_id})
+
+ if not self.other_role:
+ Access(
+ sac=sac,
+ role=self.role,
+ fullname=form.cleaned_data["fullname"],
+ email=form.cleaned_data["email"],
+ ).save()
+ return redirect(url)
+
+ access = Access.objects.get(sac=sac, role=self.role)
+ other_access = Access.objects.get(sac=sac, role=self.other_role)
+ if form.cleaned_data["email"] == other_access.email:
+ friendly_role = [r for r in ACCESS_ROLES if r[0] == self.role][0][1]
+ context = {
+ "role": self.role,
+ "friendly_role": friendly_role,
+ "auditee_uei": sac.general_information["auditee_uei"],
+ "auditee_name": sac.general_information.get("auditee_name"),
+ "certifier_name": access.fullname,
+ "email": access.email,
+ "report_id": report_id,
+ "errors": [
+ "Cannot use the same email address for both certifying officials."
+ ],
+ }
+ return render(
+ request,
+ "audit/manage-submission-change-access.html",
+ context,
+ status=400,
+ )
+
+ with transaction.atomic():
+ access.delete(removing_user=request.user, removal_event="access-change")
+
+ Access(
+ sac=sac,
+ role=self.role,
+ fullname=form.cleaned_data["fullname"],
+ email=form.cleaned_data["email"],
+ ).save()
+
+ return redirect(url)
+
+
+class ChangeAuditeeCertifyingOfficialView(ChangeAuditorCertifyingOfficialView):
+ """
+ View for changing the auditee certifying official
+ """
+
+ role = "certifying_auditee_contact"
+ other_role = "certifying_auditor_contact"