diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e38393a5a..4696a15bf 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -103,6 +103,36 @@ class MyUserAdmin(BaseUserAdmin): inlines = [UserContactInline] + list_display = ( + "email", + "first_name", + "last_name", + "is_staff", + "is_superuser", + "status", + ) + + fieldsets = ( + ( + None, + {"fields": ("username", "password", "status")}, + ), + ("Personal Info", {"fields": ("first_name", "last_name", "email")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ("Important dates", {"fields": ("last_login", "date_joined")}), + ) + def get_list_display(self, request): if not request.user.is_superuser: # Customize the list display for staff users @@ -176,6 +206,10 @@ class DomainApplicationAdmin(ListHeaderAdmin): """Customize the applications listing view.""" + # Set multi-selects 'read-only' (hide selects and show data) + # based on user perms and application creator's status + # form = DomainApplicationForm + # Columns list_display = [ "requested_domain", @@ -249,7 +283,7 @@ class DomainApplicationAdmin(ListHeaderAdmin): ] # Read only that we'll leverage for CISA Analysts - readonly_fields = [ + analyst_readonly_fields = [ "creator", "type_of_work", "more_organization_information", @@ -267,49 +301,81 @@ class DomainApplicationAdmin(ListHeaderAdmin): # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): - if change: # Check if the application is being edited - # Get the original application from the database - original_obj = models.DomainApplication.objects.get(pk=obj.pk) + if obj and obj.creator.status != models.User.RESTRICTED: + if change: # Check if the application is being edited + # Get the original application from the database + original_obj = models.DomainApplication.objects.get(pk=obj.pk) - if obj.status != original_obj.status: - if obj.status == models.DomainApplication.STARTED: - # No conditions - pass - elif obj.status == models.DomainApplication.SUBMITTED: - # This is an fsm in model which will throw an error if the - # transition condition is violated, so we roll back the - # status to what it was before the admin user changed it and - # let the fsm method set it. Same comment applies to - # transition method calls below. - obj.status = original_obj.status - obj.submit() - elif obj.status == models.DomainApplication.IN_REVIEW: - obj.status = original_obj.status - obj.in_review() - elif obj.status == models.DomainApplication.ACTION_NEEDED: - obj.status = original_obj.status - obj.action_needed() - elif obj.status == models.DomainApplication.APPROVED: - obj.status = original_obj.status - obj.approve() - elif obj.status == models.DomainApplication.WITHDRAWN: - obj.status = original_obj.status - obj.withdraw() - elif obj.status == models.DomainApplication.REJECTED: - obj.status = original_obj.status - obj.reject() - else: - logger.warning("Unknown status selected in django admin") + if obj.status != original_obj.status: + status_method_mapping = { + models.DomainApplication.STARTED: None, + models.DomainApplication.SUBMITTED: obj.submit, + models.DomainApplication.IN_REVIEW: obj.in_review, + models.DomainApplication.ACTION_NEEDED: obj.action_needed, + models.DomainApplication.APPROVED: obj.approve, + models.DomainApplication.WITHDRAWN: obj.withdraw, + models.DomainApplication.REJECTED: obj.reject, + models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice, + } + selected_method = status_method_mapping.get(obj.status) + if selected_method is None: + logger.warning("Unknown status selected in django admin") + else: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we roll back the + # status to what it was before the admin user changed it and + # let the fsm method set it. + obj.status = original_obj.status + selected_method() - super().save_model(request, obj, form, change) + super().save_model(request, obj, form, change) + else: + # Clear the success message + messages.set_level(request, messages.ERROR) + + messages.error( + request, + "This action is not permitted for applications " + + "with a restricted creator.", + ) def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 2 conditions that determine which fields are read-only: + admin user permissions and the application creator's status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + + readonly_fields = list(self.readonly_fields) + + # Check if the creator is restricted + if obj and obj.creator.status == models.User.RESTRICTED: + # For fields like CharField, IntegerField, etc., the widget used is + # straightforward and the readonly_fields list can control their behavior + readonly_fields.extend([field.name for field in self.model._meta.fields]) + # Add the multi-select fields to readonly_fields: + # Complex fields like ManyToManyField require special handling + readonly_fields.extend( + ["current_websites", "other_contacts", "alternative_domains"] + ) + if request.user.is_superuser: - # Superusers have full access, no fields are read-only - return [] + return readonly_fields else: - # Regular users can only view the specified fields - return self.readonly_fields + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + + def display_restricted_warning(self, request, obj): + if obj and obj.creator.status == models.User.RESTRICTED: + messages.warning( + request, + "Cannot edit an application with a restricted creator.", + ) + + def change_view(self, request, object_id, form_url="", extra_context=None): + obj = self.get_object(request, object_id) + self.display_restricted_warning(request, obj) + return super().change_view(request, object_id, form_url, extra_context) admin.site.register(models.User, MyUserAdmin) diff --git a/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py new file mode 100644 index 000000000..504358665 --- /dev/null +++ b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.1 on 2023-08-18 16:59 + +from django.db import migrations, models +import django_fsm + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0028_alter_domainapplication_status"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("ineligible", "ineligible")], + default=None, + max_length=10, + null=True, + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="status", + field=django_fsm.FSMField( + choices=[ + ("started", "started"), + ("submitted", "submitted"), + ("in review", "in review"), + ("action needed", "action needed"), + ("approved", "approved"), + ("withdrawn", "withdrawn"), + ("rejected", "rejected"), + ("ineligible", "ineligible"), + ], + default="started", + max_length=50, + ), + ), + ] diff --git a/src/registrar/migrations/0030_alter_user_status.py b/src/registrar/migrations/0030_alter_user_status.py new file mode 100644 index 000000000..7dd27bfa4 --- /dev/null +++ b/src/registrar/migrations/0030_alter_user_status.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.1 on 2023-08-29 17:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0029_user_status_alter_domainapplication_status"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("restricted", "restricted")], + default=None, + max_length=10, + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 32657a49d..b1230b703 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -26,6 +26,7 @@ class DomainApplication(TimeStampedModel): APPROVED = "approved" WITHDRAWN = "withdrawn" REJECTED = "rejected" + INELIGIBLE = "ineligible" STATUS_CHOICES = [ (STARTED, STARTED), (SUBMITTED, SUBMITTED), @@ -34,6 +35,7 @@ class DomainApplication(TimeStampedModel): (APPROVED, APPROVED), (WITHDRAWN, WITHDRAWN), (REJECTED, REJECTED), + (INELIGIBLE, INELIGIBLE), ] class StateTerritoryChoices(models.TextChoices): @@ -554,7 +556,9 @@ class DomainApplication(TimeStampedModel): ) @transition( - field="status", source=[SUBMITTED, IN_REVIEW, REJECTED], target=APPROVED + field="status", + source=[SUBMITTED, IN_REVIEW, REJECTED, INELIGIBLE], + target=APPROVED, ) def approve(self): """Approve an application that has been submitted. @@ -608,6 +612,17 @@ class DomainApplication(TimeStampedModel): "emails/status_change_rejected_subject.txt", ) + @transition(field="status", source=[IN_REVIEW, APPROVED], target=INELIGIBLE) + def reject_with_prejudice(self): + """The applicant is a bad actor, reject with prejudice. + + No email As a side effect, but we block the applicant from editing + any existing domains/applications and from submitting new aplications. + We do this by setting an ineligible status on the user, which the + permissions classes test against""" + + self.creator.restrict_user() + # ## Form policies ### # # These methods control what questions need to be answered by applicants diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 4cd8b6c90..5cf1dd71f 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -17,6 +17,18 @@ class User(AbstractUser): but can be customized later. """ + # #### Constants for choice fields #### + RESTRICTED = "restricted" + STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) + + status = models.CharField( + max_length=10, + choices=STATUS_CHOICES, + default=None, # Set the default value to None + null=True, # Allow the field to be null + blank=True, # Allow the field to be blank + ) + domains = models.ManyToManyField( "registrar.Domain", through="registrar.UserDomainRole", @@ -39,6 +51,17 @@ class User(AbstractUser): else: return self.username + def restrict_user(self): + self.status = self.RESTRICTED + self.save() + + def unrestrict_user(self): + self.status = None + self.save() + + def is_restricted(self): + return self.status == self.RESTRICTED + def first_login(self): """Callback when the user is authenticated for the very first time. diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html index 99f6a1d4c..c95f6a98d 100644 --- a/src/registrar/templates/application_status.html +++ b/src/registrar/templates/application_status.html @@ -23,6 +23,7 @@ {% elif domainapplication.status == 'in review' %} In Review {% elif domainapplication.status == 'rejected' %} Rejected {% elif domainapplication.status == 'submitted' %} Submitted + {% elif domainapplication.status == 'ineligible' %} Ineligible {% else %}ERROR Please contact technical support/dev {% endif %}

diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index a27dcb741..fc5478dd9 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -22,6 +22,7 @@ from .common import ( ) from django.contrib.auth import get_user_model +from unittest.mock import patch from django.conf import settings from unittest.mock import MagicMock @@ -35,6 +36,11 @@ class TestDomainApplicationAdmin(TestCase): def setUp(self): self.site = AdminSite() self.factory = RequestFactory() + self.admin = DomainApplicationAdmin( + model=DomainApplication, admin_site=self.site + ) + self.superuser = create_superuser() + self.staffuser = create_user() @boto3_mocking.patching def test_save_model_sends_submitted_email(self): @@ -54,14 +60,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.SUBMITTED # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Access the arguments passed to send_email call_args = mock_client_instance.send_email.call_args @@ -100,14 +103,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.IN_REVIEW # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Access the arguments passed to send_email call_args = mock_client_instance.send_email.call_args @@ -146,14 +146,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.APPROVED # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Access the arguments passed to send_email call_args = mock_client_instance.send_email.call_args @@ -187,14 +184,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.APPROVED # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Test that approved domain exists and equals requested domain self.assertEqual( @@ -219,14 +213,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.ACTION_NEEDED # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Access the arguments passed to send_email call_args = mock_client_instance.send_email.call_args @@ -268,14 +259,11 @@ class TestDomainApplicationAdmin(TestCase): "/admin/registrar/domainapplication/{}/change/".format(application.pk) ) - # Create an instance of the model admin - model_admin = DomainApplicationAdmin(DomainApplication, self.site) - # Modify the application's property application.status = DomainApplication.REJECTED # Use the model admin's save_model method - model_admin.save_model(request, application, form=None, change=True) + self.admin.save_model(request, application, form=None, change=True) # Access the arguments passed to send_email call_args = mock_client_instance.send_email.call_args @@ -296,6 +284,155 @@ class TestDomainApplicationAdmin(TestCase): # Perform assertions on the mock call itself mock_client_instance.send_email.assert_called_once() + def test_save_model_sets_restricted_status_on_user(self): + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample application + application = completed_application(status=DomainApplication.IN_REVIEW) + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domainapplication/{}/change/".format(application.pk) + ) + + # Modify the application's property + application.status = DomainApplication.INELIGIBLE + + # Use the model admin's save_model method + self.admin.save_model(request, application, form=None, change=True) + + # Test that approved domain exists and equals requested domain + self.assertEqual(application.creator.status, "restricted") + + def test_readonly_when_restricted_creator(self): + application = completed_application(status=DomainApplication.IN_REVIEW) + application.creator.status = User.RESTRICTED + application.creator.save() + + request = self.factory.get("/") + request.user = self.superuser + + readonly_fields = self.admin.get_readonly_fields(request, application) + + expected_fields = [ + "id", + "created_at", + "updated_at", + "status", + "creator", + "investigator", + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_agency", + "federal_type", + "is_election_board", + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + "type_of_work", + "more_organization_information", + "authorizing_official", + "approved_domain", + "requested_domain", + "submitter", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "current_websites", + "other_contacts", + "alternative_domains", + ] + + self.assertEqual(readonly_fields, expected_fields) + + def test_readonly_fields_for_analyst(self): + request = self.factory.get("/") # Use the correct method and path + request.user = self.staffuser + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [ + "creator", + "type_of_work", + "more_organization_information", + "address_line1", + "address_line2", + "zipcode", + "requested_domain", + "alternative_domains", + "purpose", + "submitter", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + ] + + self.assertEqual(readonly_fields, expected_fields) + + def test_readonly_fields_for_superuser(self): + request = self.factory.get("/") # Use the correct method and path + request.user = self.superuser + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [] + + self.assertEqual(readonly_fields, expected_fields) + + def test_saving_when_restricted_creator(self): + # Create an instance of the model + application = completed_application(status=DomainApplication.IN_REVIEW) + application.creator.status = User.RESTRICTED + application.creator.save() + + # Create a request object with a superuser + request = self.factory.get("/") + request.user = self.superuser + + with patch("django.contrib.messages.error") as mock_error: + # Simulate saving the model + self.admin.save_model(request, application, None, False) + + # Assert that the error message was called with the correct argument + mock_error.assert_called_once_with( + request, + "This action is not permitted for applications " + + "with a restricted creator.", + ) + + # Assert that the status has not changed + self.assertEqual(application.status, DomainApplication.IN_REVIEW) + + def test_change_view_with_restricted_creator(self): + # Create an instance of the model + application = completed_application(status=DomainApplication.IN_REVIEW) + application.creator.status = User.RESTRICTED + application.creator.save() + + with patch("django.contrib.messages.warning") as mock_warning: + # Create a request object with a superuser + request = self.factory.get( + "/admin/your_app/domainapplication/{}/change/".format(application.pk) + ) + request.user = self.superuser + + self.admin.display_restricted_warning(request, application) + + # Assert that the error message was called with the correct argument + mock_warning.assert_called_once_with( + request, + "Cannot edit an application with a restricted creator.", + ) + def tearDown(self): DomainInformation.objects.all().delete() DomainApplication.objects.all().delete() @@ -375,7 +512,6 @@ class ListHeaderAdminTest(TestCase): DomainInformation.objects.all().delete() DomainApplication.objects.all().delete() User.objects.all().delete() - self.superuser.delete() class MyUserAdminTest(TestCase): diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 997d5f4e2..ca1191061 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -171,6 +171,15 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.submit() + def test_transition_not_allowed_ineligible_submitted(self): + """Create an application with status ineligible and call submit + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.submit() + def test_transition_not_allowed_started_in_review(self): """Create an application with status started and call in_review against transition rules""" @@ -225,6 +234,15 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.in_review() + def test_transition_not_allowed_ineligible_in_review(self): + """Create an application with status ineligible and call in_review + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.in_review() + def test_transition_not_allowed_started_action_needed(self): """Create an application with status started and call action_needed against transition rules""" @@ -270,6 +288,15 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.action_needed() + def test_transition_not_allowed_ineligible_action_needed(self): + """Create an application with status ineligible and call action_needed + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.action_needed() + def test_transition_not_allowed_started_approved(self): """Create an application with status started and call approve against transition rules""" @@ -351,6 +378,15 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.withdraw() + def test_transition_not_allowed_ineligible_withdrawn(self): + """Create an application with status ineligible and call withdraw + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.withdraw() + def test_transition_not_allowed_started_rejected(self): """Create an application with status started and call reject against transition rules""" @@ -396,6 +432,69 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.reject() + def test_transition_not_allowed_ineligible_rejected(self): + """Create an application with status ineligible and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_started_ineligible(self): + """Create an application with status started and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.STARTED) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_submitted_ineligible(self): + """Create an application with status submitted and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.SUBMITTED) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_action_needed_ineligible(self): + """Create an application with status action needed and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.ACTION_NEEDED) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_withdrawn_ineligible(self): + """Create an application with status withdrawn and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.WITHDRAWN) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_rejected_ineligible(self): + """Create an application with status rejected and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.REJECTED) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + + def test_transition_not_allowed_ineligible_ineligible(self): + """Create an application with status ineligible and call reject + against transition rules""" + + application = completed_application(status=DomainApplication.INELIGIBLE) + + with self.assertRaises(TransitionNotAllowed): + application.reject_with_prejudice() + class TestPermissions(TestCase): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b4795de72..9ce44f674 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -101,6 +101,18 @@ class LoggedInTests(TestWithUser): "What kind of U.S.-based government organization do you represent?", ) + def test_domain_application_form_with_ineligible_user(self): + """Application form not accessible for an ineligible user. + This test should be solid enough since all application wizard + views share the same permissions class""" + self.user.status = User.RESTRICTED + self.user.save() + + with less_console_noise(): + response = self.client.get("/register/", follow=True) + print(response.status_code) + self.assertEqual(response.status_code, 403) + class DomainApplicationTests(TestWithUser, WebTest): @@ -1423,6 +1435,18 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest): success_page, "The security email for this domain have been updated" ) + def test_domain_overview_blocked_for_ineligible_user(self): + """We could easily duplicate this test for all domain management + views, but a single url test should be solid enough since all domain + management pages share the same permissions class""" + self.user.status = User.RESTRICTED + self.user.save() + home_page = self.app.get("/") + self.assertContains(home_page, "igorville.gov") + with less_console_noise(): + response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) + self.assertEqual(response.status_code, 403) + class TestApplicationStatus(TestWithUser, WebTest): def setUp(self): @@ -1447,6 +1471,27 @@ class TestApplicationStatus(TestWithUser, WebTest): self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") + def test_application_status_with_ineligible_user(self): + """Checking application status page whith a blocked user. + The user should still have access to view.""" + self.user.status = "ineligible" + self.user.save() + + application = completed_application( + status=DomainApplication.SUBMITTED, user=self.user + ) + application.save() + + home_page = self.app.get("/") + self.assertContains(home_page, "city.gov") + # click the "Manage" link + detail_page = home_page.click("Manage") + self.assertContains(detail_page, "city.gov") + self.assertContains(detail_page, "Chief Tester") + self.assertContains(detail_page, "testy@town.com") + self.assertContains(detail_page, "Admin Tester") + self.assertContains(detail_page, "Status:") + def test_application_withdraw(self): """Checking application status page""" application = completed_application( diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py index 256f4be40..23d7348e9 100644 --- a/src/registrar/views/application.py +++ b/src/registrar/views/application.py @@ -12,7 +12,7 @@ from registrar.models import DomainApplication from registrar.utility import StrEnum from registrar.views.utility import StepsHelper -from .utility import DomainApplicationPermissionView +from .utility import DomainApplicationPermissionView, ApplicationWizardPermissionView logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ class Step(StrEnum): REVIEW = "review" -class ApplicationWizard(TemplateView): +class ApplicationWizard(ApplicationWizardPermissionView, TemplateView): """ A common set of methods and configuration. @@ -60,6 +60,8 @@ class ApplicationWizard(TemplateView): although not without consulting the base implementation, first. """ + template_name = "" + # uniquely namespace the wizard in urls.py # (this is not seen _in_ urls, only for Django's internal naming) # NB: this is included here for reference. Do not change it without diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 6c656c614..71d3edb91 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -5,4 +5,5 @@ from .permission_views import ( DomainPermissionView, DomainApplicationPermissionView, DomainInvitationPermissionDeleteView, + ApplicationWizardPermissionView, ) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 05da75d35..363709a21 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -39,9 +39,9 @@ class DomainPermission(PermissionsLoginMixin): ).exists(): return False - # ticket 796 - # if domain.application__status != 'approved' - # return false + # The user has an ineligible flag + if self.request.user.is_restricted(): + return False # if we need to check more about the nature of role, do it here. return True @@ -71,6 +71,23 @@ class DomainApplicationPermission(PermissionsLoginMixin): return True +class ApplicationWizardPermission(PermissionsLoginMixin): + + """Does the logged-in user have permission to start or edit an application?""" + + def has_permission(self): + """Check if this user has permission to start or edit an application. + + The user is in self.request.user + """ + + # The user has an ineligible flag + if self.request.user.is_restricted(): + return False + + return True + + class DomainInvitationPermission(PermissionsLoginMixin): """Does the logged-in user have access to this domain invitation? diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index e52ed102c..0ef4ff4e5 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -2,7 +2,7 @@ import abc # abstract base class -from django.views.generic import DetailView, DeleteView +from django.views.generic import DetailView, DeleteView, TemplateView from registrar.models import Domain, DomainApplication, DomainInvitation @@ -10,6 +10,7 @@ from .mixins import ( DomainPermission, DomainApplicationPermission, DomainInvitationPermission, + ApplicationWizardPermission, ) @@ -53,6 +54,23 @@ class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, a raise NotImplementedError +class ApplicationWizardPermissionView( + ApplicationWizardPermission, TemplateView, abc.ABC +): + + """Abstract base view for the application form that enforces permissions + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ + + # Abstract property enforces NotImplementedError on an attribute. + @property + @abc.abstractmethod + def template_name(self): + raise NotImplementedError + + class DomainInvitationPermissionDeleteView( DomainInvitationPermission, DeleteView, abc.ABC ):