Repository: cisagov/manage.get.gov

Issue:
Unlock request pages that users have already visited
### Story

As a requester
I want to be able to quickly navigate through request pages I have already visited
so that I do not have to waste time going through each page


### Acceptance Criteria

Given that I am a requestor who is revisiting an application I am working on (or a requestor who is revisiting my application under guidance from analysts to complete an action) 
When I click to edit my application
Then I should be able to click through the navigation on the right to revisit any page I had already visited once

### Additional Context

If a user ever leaves the application (ex. going back to the dashboard), navigation progress will be lost and the previously 'checked' pages will turn back into 'locked' pages. 

Demo:
![video1725342723](https://github.com/cisagov/manage.get.gov/assets/140533113/3fa09f53-c9be-4855-b100-aab2f7f62780)

### Issue Links

- [ ] Depends on Spike #1624


Discussion:
FYI on a related discussion https://github.com/cisagov/manage.get.gov/issues/393 

Resolving patch:
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -92,6 +92,12 @@ def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None
         "Deleted": domain.deleted,
     }
 
+    # user_emails = [user.email for user in domain.permissions]
+
+    # Dynamically add user emails to the FIELDS dictionary
+    # for i, user_email in enumerate(user_emails, start=1):
+    #   FIELDS[f"User{i} email"] = user_email
+
     row = [FIELDS.get(column, "") for column in columns]
     return row
 
@@ -127,6 +133,16 @@ def write_body(
         else:
             logger.warning("csv_export -> Domain was none for PublicContact")
 
+    # all_user_nums = 0
+    # for domain_info in all_domain_infos:
+    #     user_num = len(domain_info.domain.permissions)
+    #     all_user_nums.append(user_num)
+
+    #     if user_num > highest_user_nums:
+    #         highest_user_nums = user_num
+
+    # Build the header here passing to it highest_user_nums
+
     # Reduce the memory overhead when performing the write operation
     paginator = Paginator(all_domain_infos, 1000)
     for page_num in paginator.page_range:
diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py
--- a/src/registrar/views/application.py
+++ b/src/registrar/views/application.py
@@ -159,7 +159,11 @@ def application(self) -> DomainApplication:
     def storage(self):
         # marking session as modified on every access
         # so that updates to nested keys are always saved
-        self.request.session.modified = True
+        # Also - check that self.request.session has the attr
+        # modified to account for test environments calling
+        # view methods
+        if hasattr(self.request.session, "modified"):
+            self.request.session.modified = True
         return self.request.session.setdefault(self.prefix, {})
 
     @storage.setter
@@ -211,6 +215,7 @@ def get(self, request, *args, **kwargs):
         if current_url == self.EDIT_URL_NAME and "id" in kwargs:
             del self.storage
             self.storage["application_id"] = kwargs["id"]
+            self.storage["step_history"] = self.db_check_for_unlocking_steps()
 
         # if accessing this class directly, redirect to the first step
         #     in other words, if `ApplicationWizard` is called as view
@@ -269,6 +274,7 @@ def get_forms(self, step=None, use_post=False, use_db=False, files=None):
         and from the database if `use_db` is True (provided that record exists).
         An empty form will be provided if neither of those are true.
         """
+
         kwargs = {
             "files": files,
             "prefix": self.steps.current,
@@ -329,6 +335,43 @@ def pending_applications(self):
         ]
         return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
 
+    def db_check_for_unlocking_steps(self):
+        """Helper for get_context_data
+
+        Queries the DB for an application and returns a list of unlocked steps."""
+        history_dict = {
+            "organization_type": self.application.organization_type is not None,
+            "tribal_government": self.application.tribe_name is not None,
+            "organization_federal": self.application.federal_type is not None,
+            "organization_election": self.application.is_election_board is not None,
+            "organization_contact": (
+                self.application.federal_agency is not None
+                or self.application.organization_name is not None
+                or self.application.address_line1 is not None
+                or self.application.city is not None
+                or self.application.state_territory is not None
+                or self.application.zipcode is not None
+                or self.application.urbanization is not None
+            ),
+            "about_your_organization": self.application.about_your_organization is not None,
+            "authorizing_official": self.application.authorizing_official is not None,
+            "current_sites": (
+                self.application.current_websites.exists() or self.application.requested_domain is not None
+            ),
+            "dotgov_domain": self.application.requested_domain is not None,
+            "purpose": self.application.purpose is not None,
+            "your_contact": self.application.submitter is not None,
+            "other_contacts": (
+                self.application.other_contacts.exists() or self.application.no_other_contacts_rationale is not None
+            ),
+            "anything_else": (
+                self.application.anything_else is not None or self.application.is_policy_acknowledged is not None
+            ),
+            "requirements": self.application.is_policy_acknowledged is not None,
+            "review": self.application.is_policy_acknowledged is not None,
+        }
+        return [key for key, value in history_dict.items() if value]
+
     def get_context_data(self):
         """Define context for access on all wizard pages."""
         # Build the submit button that we'll pass to the modal.
@@ -338,6 +381,7 @@ def get_context_data(self):
             modal_heading = "You are about to submit a domain request for " + str(self.application.requested_domain)
         else:
             modal_heading = "You are about to submit an incomplete request"
+
         return {
             "form_titles": self.TITLES,
             "steps": self.steps,


Test patch:
diff --git a/src/registrar/tests/test_views_application.py b/src/registrar/tests/test_views_application.py
--- a/src/registrar/tests/test_views_application.py
+++ b/src/registrar/tests/test_views_application.py
@@ -1,4 +1,5 @@
 from unittest import skip
+from unittest.mock import Mock
 
 from django.conf import settings
 from django.urls import reverse
@@ -9,6 +10,7 @@
 
 from registrar.models import (
     DomainApplication,
+    DraftDomain,
     Domain,
     DomainInformation,
     Contact,
@@ -2197,3 +2199,131 @@ def test_approved_application_not_in_active_requests(self):
         # domain object, so we do not expect to see 'city.gov'
         # in either the Domains or Requests tables.
         self.assertNotContains(home_page, "city.gov")
+
+
+class TestWizardUnlockingSteps(TestWithUser, WebTest):
+    def setUp(self):
+        super().setUp()
+        self.app.set_user(self.user.username)
+        self.wizard = ApplicationWizard()
+        # Mock the request object, its user, and session attributes appropriately
+        self.wizard.request = Mock(user=self.user, session={})
+
+    def tearDown(self):
+        super().tearDown()
+
+    def test_unlocked_steps_empty_application(self):
+        """Test when all fields in the application are empty."""
+        unlocked_steps = self.wizard.db_check_for_unlocking_steps()
+        expected_dict = []
+        self.assertEqual(unlocked_steps, expected_dict)
+
+    def test_unlocked_steps_full_application(self):
+        """Test when all fields in the application are filled."""
+
+        completed_application(status=DomainApplication.ApplicationStatus.STARTED, user=self.user)
+        # Make a request to the home page
+        home_page = self.app.get("/")
+        # django-webtest does not handle cookie-based sessions well because it keeps
+        # resetting the session key on each new request, thus destroying the concept
+        # of a "session". We are going to do it manually, saving the session ID here
+        # and then setting the cookie on each request.
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Assert that the response contains "city.gov"
+        self.assertContains(home_page, "city.gov")
+
+        # Click the "Edit" link
+        response = home_page.click("Edit", index=0)
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Check if the response is a redirect
+        if response.status_code == 302:
+            # Follow the redirect manually
+            try:
+                detail_page = response.follow()
+
+                self.wizard.get_context_data()
+            except Exception as err:
+                # Handle any potential errors while following the redirect
+                self.fail(f"Error following the redirect {err}")
+
+            # Now 'detail_page' contains the response after following the redirect
+            self.assertEqual(detail_page.status_code, 200)
+
+            # 10 unlocked steps, one active step, the review step will have link_usa but not check_circle
+            self.assertContains(detail_page, "#check_circle", count=10)
+            # Type of organization
+            self.assertContains(detail_page, "usa-current", count=1)
+            self.assertContains(detail_page, "link_usa-checked", count=11)
+
+        else:
+            self.fail(f"Expected a redirect, but got a different response: {response}")
+
+    def test_unlocked_steps_partial_application(self):
+        """Test when some fields in the application are filled."""
+
+        # Create the site and contacts to delete (orphaned)
+        contact = Contact.objects.create(
+            first_name="Henry",
+            last_name="Mcfakerson",
+        )
+        # Create two non-orphaned contacts
+        contact_2 = Contact.objects.create(
+            first_name="Saturn",
+            last_name="Mars",
+        )
+
+        # Attach a user object to a contact (should not be deleted)
+        contact_user, _ = Contact.objects.get_or_create(user=self.user)
+
+        site = DraftDomain.objects.create(name="igorville.gov")
+        application = DomainApplication.objects.create(
+            creator=self.user,
+            requested_domain=site,
+            status=DomainApplication.ApplicationStatus.WITHDRAWN,
+            authorizing_official=contact,
+            submitter=contact_user,
+        )
+        application.other_contacts.set([contact_2])
+
+        # Make a request to the home page
+        home_page = self.app.get("/")
+        # django-webtest does not handle cookie-based sessions well because it keeps
+        # resetting the session key on each new request, thus destroying the concept
+        # of a "session". We are going to do it manually, saving the session ID here
+        # and then setting the cookie on each request.
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Assert that the response contains "city.gov"
+        self.assertContains(home_page, "igorville.gov")
+
+        # Click the "Edit" link
+        response = home_page.click("Edit", index=0)
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Check if the response is a redirect
+        if response.status_code == 302:
+            # Follow the redirect manually
+            try:
+                detail_page = response.follow()
+
+                self.wizard.get_context_data()
+            except Exception as err:
+                # Handle any potential errors while following the redirect
+                self.fail(f"Error following the redirect {err}")
+
+            # Now 'detail_page' contains the response after following the redirect
+            self.assertEqual(detail_page.status_code, 200)
+
+            # 5 unlocked steps (ao, domain, submitter, other contacts, and current sites
+            # which unlocks if domain exists), one active step, the review step is locked
+            self.assertContains(detail_page, "#check_circle", count=5)
+            # Type of organization
+            self.assertContains(detail_page, "usa-current", count=1)
+            self.assertContains(detail_page, "link_usa-checked", count=5)
+
+        else:
+            self.fail(f"Expected a redirect, but got a different response: {response}")