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(