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.

This commit is contained in:
rachidatecs 2023-08-18 17:24:34 -04:00
parent 87bb71a214
commit c98392baac
No known key found for this signature in database
GPG key ID: 3CEBBFA7325E5525
9 changed files with 236 additions and 7 deletions

View file

@ -98,6 +98,15 @@ class MyUserAdmin(BaseUserAdmin):
"""Custom user admin class to use our inlines.""" """Custom user admin class to use our inlines."""
inlines = [UserContactInline] 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): def get_list_display(self, request):
if not request.user.is_superuser: if not request.user.is_superuser:
@ -295,6 +304,9 @@ class DomainApplicationAdmin(ListHeaderAdmin):
elif obj.status == models.DomainApplication.REJECTED: elif obj.status == models.DomainApplication.REJECTED:
obj.status = original_obj.status obj.status = original_obj.status
obj.reject() obj.reject()
elif obj.status == models.DomainApplication.INELIGIBLE:
obj.status = original_obj.status
obj.reject_with_prejudice()
else: else:
logger.warning("Unknown status selected in django admin") logger.warning("Unknown status selected in django admin")

View file

@ -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,
),
),
]

View file

@ -26,6 +26,7 @@ class DomainApplication(TimeStampedModel):
APPROVED = "approved" APPROVED = "approved"
WITHDRAWN = "withdrawn" WITHDRAWN = "withdrawn"
REJECTED = "rejected" REJECTED = "rejected"
INELIGIBLE = "ineligible"
STATUS_CHOICES = [ STATUS_CHOICES = [
(STARTED, STARTED), (STARTED, STARTED),
(SUBMITTED, SUBMITTED), (SUBMITTED, SUBMITTED),
@ -34,6 +35,7 @@ class DomainApplication(TimeStampedModel):
(APPROVED, APPROVED), (APPROVED, APPROVED),
(WITHDRAWN, WITHDRAWN), (WITHDRAWN, WITHDRAWN),
(REJECTED, REJECTED), (REJECTED, REJECTED),
(INELIGIBLE, INELIGIBLE)
] ]
class StateTerritoryChoices(models.TextChoices): class StateTerritoryChoices(models.TextChoices):
@ -554,7 +556,7 @@ class DomainApplication(TimeStampedModel):
) )
@transition( @transition(
field="status", source=[SUBMITTED, IN_REVIEW, REJECTED], target=APPROVED field="status", source=[SUBMITTED, IN_REVIEW, REJECTED, INELIGIBLE], target=APPROVED
) )
def approve(self): def approve(self):
"""Approve an application that has been submitted. """Approve an application that has been submitted.
@ -602,6 +604,17 @@ class DomainApplication(TimeStampedModel):
"emails/status_change_rejected.txt", "emails/status_change_rejected.txt",
"emails/status_change_rejected_subject.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 ### # ## Form policies ###
# #

View file

@ -16,6 +16,20 @@ class User(AbstractUser):
A custom user model that performs identically to the default user model A custom user model that performs identically to the default user model
but can be customized later. 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( domains = models.ManyToManyField(
"registrar.Domain", "registrar.Domain",
@ -38,6 +52,19 @@ class User(AbstractUser):
return self.email return self.email
else: else:
return self.username 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): def first_login(self):
"""Callback when the user is authenticated for the very first time. """Callback when the user is authenticated for the very first time.

View file

@ -170,6 +170,15 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.submit() 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): def test_transition_not_allowed_started_in_review(self):
"""Create an application with status started and call in_review """Create an application with status started and call in_review
@ -224,6 +233,15 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.in_review() 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): def test_transition_not_allowed_started_action_needed(self):
"""Create an application with status started and call action_needed """Create an application with status started and call action_needed
@ -269,6 +287,15 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.action_needed() 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): def test_transition_not_allowed_started_approved(self):
"""Create an application with status started and call approve """Create an application with status started and call approve
@ -350,6 +377,15 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.withdraw() 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): def test_transition_not_allowed_started_rejected(self):
"""Create an application with status started and call reject """Create an application with status started and call reject
@ -395,6 +431,69 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.reject() 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): class TestPermissions(TestCase):

View file

@ -12,7 +12,7 @@ from registrar.models import DomainApplication
from registrar.utility import StrEnum from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper from registrar.views.utility import StepsHelper
from .utility import DomainApplicationPermissionView from .utility import DomainApplicationPermissionView, ApplicationWizardPermissionView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ class Step(StrEnum):
REVIEW = "review" REVIEW = "review"
class ApplicationWizard(TemplateView): class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
""" """
A common set of methods and configuration. 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, Any method not marked as internal can be overridden in a subclass,
although not without consulting the base implementation, first. although not without consulting the base implementation, first.
""" """
template_name = ""
# uniquely namespace the wizard in urls.py # uniquely namespace the wizard in urls.py
# (this is not seen _in_ urls, only for Django's internal naming) # (this is not seen _in_ urls, only for Django's internal naming)

View file

@ -5,4 +5,5 @@ from .permission_views import (
DomainPermissionView, DomainPermissionView,
DomainApplicationPermissionView, DomainApplicationPermissionView,
DomainInvitationPermissionDeleteView, DomainInvitationPermissionDeleteView,
ApplicationWizardPermissionView
) )

View file

@ -39,9 +39,9 @@ class DomainPermission(PermissionsLoginMixin):
).exists(): ).exists():
return False return False
# ticket 796 # The user has an ineligible flag
# if domain.application__status != 'approved' if self.request.user.is_blocked():
# return false return False
# if we need to check more about the nature of role, do it here. # if we need to check more about the nature of role, do it here.
return True return True
@ -69,6 +69,23 @@ class DomainApplicationPermission(PermissionsLoginMixin):
return False return False
return True 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): class DomainInvitationPermission(PermissionsLoginMixin):

View file

@ -2,7 +2,7 @@
import abc # abstract base class 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 from registrar.models import Domain, DomainApplication, DomainInvitation
@ -10,6 +10,7 @@ from .mixins import (
DomainPermission, DomainPermission,
DomainApplicationPermission, DomainApplicationPermission,
DomainInvitationPermission, DomainInvitationPermission,
ApplicationWizardPermission
) )
@ -51,6 +52,21 @@ class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, a
@abc.abstractmethod @abc.abstractmethod
def template_name(self): def template_name(self):
raise NotImplementedError 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( class DomainInvitationPermissionDeleteView(