mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 18:56:15 +02:00
Merge pull request #934 from cisagov/rjm/797-ineligible-status
797 - Ineligible status
This commit is contained in:
commit
93ec99185b
13 changed files with 558 additions and 70 deletions
|
@ -103,6 +103,36 @@ class MyUserAdmin(BaseUserAdmin):
|
||||||
|
|
||||||
inlines = [UserContactInline]
|
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):
|
def get_list_display(self, request):
|
||||||
if not request.user.is_superuser:
|
if not request.user.is_superuser:
|
||||||
# Customize the list display for staff users
|
# Customize the list display for staff users
|
||||||
|
@ -176,6 +206,10 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
|
|
||||||
"""Customize the applications listing view."""
|
"""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
|
# Columns
|
||||||
list_display = [
|
list_display = [
|
||||||
"requested_domain",
|
"requested_domain",
|
||||||
|
@ -249,7 +283,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
]
|
]
|
||||||
|
|
||||||
# Read only that we'll leverage for CISA Analysts
|
# Read only that we'll leverage for CISA Analysts
|
||||||
readonly_fields = [
|
analyst_readonly_fields = [
|
||||||
"creator",
|
"creator",
|
||||||
"type_of_work",
|
"type_of_work",
|
||||||
"more_organization_information",
|
"more_organization_information",
|
||||||
|
@ -267,49 +301,81 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
|
|
||||||
# Trigger action when a fieldset is changed
|
# Trigger action when a fieldset is changed
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
if change: # Check if the application is being edited
|
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||||
# Get the original application from the database
|
if change: # Check if the application is being edited
|
||||||
original_obj = models.DomainApplication.objects.get(pk=obj.pk)
|
# 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 != original_obj.status:
|
||||||
if obj.status == models.DomainApplication.STARTED:
|
status_method_mapping = {
|
||||||
# No conditions
|
models.DomainApplication.STARTED: None,
|
||||||
pass
|
models.DomainApplication.SUBMITTED: obj.submit,
|
||||||
elif obj.status == models.DomainApplication.SUBMITTED:
|
models.DomainApplication.IN_REVIEW: obj.in_review,
|
||||||
# This is an fsm in model which will throw an error if the
|
models.DomainApplication.ACTION_NEEDED: obj.action_needed,
|
||||||
# transition condition is violated, so we roll back the
|
models.DomainApplication.APPROVED: obj.approve,
|
||||||
# status to what it was before the admin user changed it and
|
models.DomainApplication.WITHDRAWN: obj.withdraw,
|
||||||
# let the fsm method set it. Same comment applies to
|
models.DomainApplication.REJECTED: obj.reject,
|
||||||
# transition method calls below.
|
models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice,
|
||||||
obj.status = original_obj.status
|
}
|
||||||
obj.submit()
|
selected_method = status_method_mapping.get(obj.status)
|
||||||
elif obj.status == models.DomainApplication.IN_REVIEW:
|
if selected_method is None:
|
||||||
obj.status = original_obj.status
|
logger.warning("Unknown status selected in django admin")
|
||||||
obj.in_review()
|
else:
|
||||||
elif obj.status == models.DomainApplication.ACTION_NEEDED:
|
# This is an fsm in model which will throw an error if the
|
||||||
obj.status = original_obj.status
|
# transition condition is violated, so we roll back the
|
||||||
obj.action_needed()
|
# status to what it was before the admin user changed it and
|
||||||
elif obj.status == models.DomainApplication.APPROVED:
|
# let the fsm method set it.
|
||||||
obj.status = original_obj.status
|
obj.status = original_obj.status
|
||||||
obj.approve()
|
selected_method()
|
||||||
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")
|
|
||||||
|
|
||||||
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):
|
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:
|
if request.user.is_superuser:
|
||||||
# Superusers have full access, no fields are read-only
|
return readonly_fields
|
||||||
return []
|
|
||||||
else:
|
else:
|
||||||
# Regular users can only view the specified fields
|
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||||
return self.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)
|
admin.site.register(models.User, MyUserAdmin)
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
23
src/registrar/migrations/0030_alter_user_status.py
Normal file
23
src/registrar/migrations/0030_alter_user_status.py
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,9 @@ 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.
|
||||||
|
@ -608,6 +612,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
"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/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 ###
|
# ## Form policies ###
|
||||||
#
|
#
|
||||||
# These methods control what questions need to be answered by applicants
|
# These methods control what questions need to be answered by applicants
|
||||||
|
|
|
@ -17,6 +17,18 @@ class User(AbstractUser):
|
||||||
but can be customized later.
|
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(
|
domains = models.ManyToManyField(
|
||||||
"registrar.Domain",
|
"registrar.Domain",
|
||||||
through="registrar.UserDomainRole",
|
through="registrar.UserDomainRole",
|
||||||
|
@ -39,6 +51,17 @@ class User(AbstractUser):
|
||||||
else:
|
else:
|
||||||
return self.username
|
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):
|
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.
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
{% elif domainapplication.status == 'in review' %} In Review
|
{% elif domainapplication.status == 'in review' %} In Review
|
||||||
{% elif domainapplication.status == 'rejected' %} Rejected
|
{% elif domainapplication.status == 'rejected' %} Rejected
|
||||||
{% elif domainapplication.status == 'submitted' %} Submitted
|
{% elif domainapplication.status == 'submitted' %} Submitted
|
||||||
|
{% elif domainapplication.status == 'ineligible' %} Ineligible
|
||||||
{% else %}ERROR Please contact technical support/dev
|
{% else %}ERROR Please contact technical support/dev
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -22,6 +22,7 @@ from .common import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
@ -35,6 +36,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.site = AdminSite()
|
self.site = AdminSite()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
self.admin = DomainApplicationAdmin(
|
||||||
|
model=DomainApplication, admin_site=self.site
|
||||||
|
)
|
||||||
|
self.superuser = create_superuser()
|
||||||
|
self.staffuser = create_user()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
def test_save_model_sends_submitted_email(self):
|
def test_save_model_sends_submitted_email(self):
|
||||||
|
@ -54,14 +60,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.SUBMITTED
|
application.status = DomainApplication.SUBMITTED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Access the arguments passed to send_email
|
||||||
call_args = mock_client_instance.send_email.call_args
|
call_args = mock_client_instance.send_email.call_args
|
||||||
|
@ -100,14 +103,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.IN_REVIEW
|
application.status = DomainApplication.IN_REVIEW
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Access the arguments passed to send_email
|
||||||
call_args = mock_client_instance.send_email.call_args
|
call_args = mock_client_instance.send_email.call_args
|
||||||
|
@ -146,14 +146,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.APPROVED
|
application.status = DomainApplication.APPROVED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Access the arguments passed to send_email
|
||||||
call_args = mock_client_instance.send_email.call_args
|
call_args = mock_client_instance.send_email.call_args
|
||||||
|
@ -187,14 +184,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.APPROVED
|
application.status = DomainApplication.APPROVED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Test that approved domain exists and equals requested domain
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -219,14 +213,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.ACTION_NEEDED
|
application.status = DomainApplication.ACTION_NEEDED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Access the arguments passed to send_email
|
||||||
call_args = mock_client_instance.send_email.call_args
|
call_args = mock_client_instance.send_email.call_args
|
||||||
|
@ -268,14 +259,11 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
"/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
|
# Modify the application's property
|
||||||
application.status = DomainApplication.REJECTED
|
application.status = DomainApplication.REJECTED
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
# 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
|
# Access the arguments passed to send_email
|
||||||
call_args = mock_client_instance.send_email.call_args
|
call_args = mock_client_instance.send_email.call_args
|
||||||
|
@ -296,6 +284,155 @@ class TestDomainApplicationAdmin(TestCase):
|
||||||
# Perform assertions on the mock call itself
|
# Perform assertions on the mock call itself
|
||||||
mock_client_instance.send_email.assert_called_once()
|
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):
|
def tearDown(self):
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
DomainApplication.objects.all().delete()
|
DomainApplication.objects.all().delete()
|
||||||
|
@ -375,7 +512,6 @@ class ListHeaderAdminTest(TestCase):
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
DomainApplication.objects.all().delete()
|
DomainApplication.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
self.superuser.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class MyUserAdminTest(TestCase):
|
class MyUserAdminTest(TestCase):
|
||||||
|
|
|
@ -171,6 +171,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
|
||||||
against transition rules"""
|
against transition rules"""
|
||||||
|
@ -225,6 +234,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
|
||||||
against transition rules"""
|
against transition rules"""
|
||||||
|
@ -270,6 +288,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
|
||||||
against transition rules"""
|
against transition rules"""
|
||||||
|
@ -351,6 +378,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
|
||||||
against transition rules"""
|
against transition rules"""
|
||||||
|
@ -396,6 +432,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):
|
||||||
|
|
||||||
|
|
|
@ -101,6 +101,18 @@ class LoggedInTests(TestWithUser):
|
||||||
"What kind of U.S.-based government organization do you represent?",
|
"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):
|
class DomainApplicationTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
|
@ -1423,6 +1435,18 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
|
||||||
success_page, "The security email for this domain have been updated"
|
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):
|
class TestApplicationStatus(TestWithUser, WebTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -1447,6 +1471,27 @@ class TestApplicationStatus(TestWithUser, WebTest):
|
||||||
self.assertContains(detail_page, "Admin Tester")
|
self.assertContains(detail_page, "Admin Tester")
|
||||||
self.assertContains(detail_page, "Status:")
|
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):
|
def test_application_withdraw(self):
|
||||||
"""Checking application status page"""
|
"""Checking application status page"""
|
||||||
application = completed_application(
|
application = completed_application(
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
@ -60,6 +60,8 @@ class ApplicationWizard(TemplateView):
|
||||||
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)
|
||||||
# NB: this is included here for reference. Do not change it without
|
# NB: this is included here for reference. Do not change it without
|
||||||
|
|
|
@ -5,4 +5,5 @@ from .permission_views import (
|
||||||
DomainPermissionView,
|
DomainPermissionView,
|
||||||
DomainApplicationPermissionView,
|
DomainApplicationPermissionView,
|
||||||
DomainInvitationPermissionDeleteView,
|
DomainInvitationPermissionDeleteView,
|
||||||
|
ApplicationWizardPermissionView,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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_restricted():
|
||||||
# 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
|
||||||
|
@ -71,6 +71,23 @@ class DomainApplicationPermission(PermissionsLoginMixin):
|
||||||
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_restricted():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DomainInvitationPermission(PermissionsLoginMixin):
|
class DomainInvitationPermission(PermissionsLoginMixin):
|
||||||
|
|
||||||
"""Does the logged-in user have access to this domain invitation?
|
"""Does the logged-in user have access to this domain invitation?
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,6 +54,23 @@ class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, a
|
||||||
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(
|
||||||
DomainInvitationPermission, DeleteView, abc.ABC
|
DomainInvitationPermission, DeleteView, abc.ABC
|
||||||
):
|
):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue