From c98392baacfcdf365469e4804b6cd18d8dfacb37 Mon Sep 17 00:00:00 2001 From: rachidatecs Date: Fri, 18 Aug 2023 17:24:34 -0400 Subject: [PATCH] Add ineligible status on domain application, on user. Trigger fsm transition on domain application which triggers status ineligible on user. Edit permissions on domains and newly created application wizard perm class to block access to ineligible users. Run migrations. --- src/registrar/admin.py | 12 +++ ...r_status_alter_domainapplication_status.py | 42 ++++++++ src/registrar/models/domain_application.py | 15 ++- src/registrar/models/user.py | 27 +++++ src/registrar/tests/test_models.py | 99 +++++++++++++++++++ src/registrar/views/application.py | 6 +- src/registrar/views/utility/__init__.py | 1 + src/registrar/views/utility/mixins.py | 23 ++++- .../views/utility/permission_views.py | 18 +++- 9 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src/registrar/migrations/0029_user_status_alter_domainapplication_status.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 96b8aaa33..8426b7e40 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -98,6 +98,15 @@ class MyUserAdmin(BaseUserAdmin): """Custom user admin class to use our inlines.""" inlines = [UserContactInline] + + list_display = ("email", "first_name", "last_name", "is_staff", "is_superuser", "status") + + fieldsets = ( + (None, {'fields': ('username', 'password', 'status')}), # Add the 'status' field here + ('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: @@ -295,6 +304,9 @@ class DomainApplicationAdmin(ListHeaderAdmin): elif obj.status == models.DomainApplication.REJECTED: obj.status = original_obj.status obj.reject() + elif obj.status == models.DomainApplication.INELIGIBLE: + obj.status = original_obj.status + obj.reject_with_prejudice() else: logger.warning("Unknown status selected in django admin") 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/models/domain_application.py b/src/registrar/models/domain_application.py index 67f1ee5d9..7a52d3185 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,7 @@ 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. @@ -602,6 +604,17 @@ class DomainApplication(TimeStampedModel): "emails/status_change_rejected.txt", "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 and from submitting new apllications""" + + self.creator.block_user() + + # ## Form policies ### # diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 4cd8b6c90..b6402a2bf 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -16,6 +16,20 @@ class User(AbstractUser): A custom user model that performs identically to the default user model but can be customized later. """ + + # #### Constants for choice fields #### + INELIGIBLE = 'ineligible' + STATUS_CHOICES = ( + (INELIGIBLE, INELIGIBLE), + ) + + 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", @@ -38,6 +52,19 @@ class User(AbstractUser): return self.email else: return self.username + + def block_user(self): + self.status = "ineligible" + self.save() + + def unblock_user(self): + self.status = None + self.save() + + def is_blocked(self): + if self.status == "ineligible": + return True + return False def first_login(self): """Callback when the user is authenticated for the very first time. diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 997d5f4e2..8274908fa 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -170,6 +170,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 @@ -224,6 +233,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 @@ -269,6 +287,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 @@ -350,6 +377,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 @@ -395,6 +431,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/views/application.py b/src/registrar/views/application.py index 256f4be40..fc9443847 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. @@ -59,6 +59,8 @@ class ApplicationWizard(TemplateView): Any method not marked as internal can be overridden in a subclass, 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) diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 6c656c614..b782f59fd 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..715514bd8 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_blocked(): + return False # if we need to check more about the nature of role, do it here. return True @@ -69,6 +69,23 @@ class DomainApplicationPermission(PermissionsLoginMixin): return False 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_blocked(): + return False + + return True class DomainInvitationPermission(PermissionsLoginMixin): diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index e52ed102c..d7b846377 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 ) @@ -51,6 +52,21 @@ class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, a @abc.abstractmethod def template_name(self): 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(