diff --git a/docs/django-admin/roles.md b/docs/django-admin/roles.md new file mode 100644 index 0000000000..4313803003 --- /dev/null +++ b/docs/django-admin/roles.md @@ -0,0 +1,20 @@ +# Django admin user roles + +Roles other than superuser should be defined in authentication and authorization groups in django admin + +## Superuser + +Full access + +## CISA analyst + +### Basic permission level + +Staff + +### Additional group permissions + +auditlog | log entry | can view log entry +registrar | contact | can view contact +registrar | domain application | can change domain application +registrar | domain | can view domain \ No newline at end of file diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 2c65d2125a..7a36475827 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -24,6 +24,68 @@ def history_view(self, request, object_id, extra_context=None): ) +class ListHeaderAdmin(AuditedAdmin): + + """Custom admin to add a descriptive subheader to list views.""" + + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + # Get the filtered values + filters = self.get_filters(request) + # Pass the filtered values to the template context + extra_context["filters"] = filters + extra_context["search_query"] = request.GET.get( + "q", "" + ) # Assuming the search query parameter is 'q' + return super().changelist_view(request, extra_context=extra_context) + + def get_filters(self, request): + """Retrieve the current set of parameters being used to filter the table + Returns: + dictionary objects in the format {parameter_name: string, + parameter_value: string} + TODO: convert investigator id to investigator username + """ + + filters = [] + # Retrieve the filter parameters + for param in request.GET.keys(): + # Exclude the default search parameter 'q' + if param != "q" and param != "o": + parameter_name = ( + param.replace("__exact", "") + .replace("_type", "") + .replace("__id", " id") + ) + + if parameter_name == "investigator id": + # Retrieves the corresponding contact from Users + id_value = request.GET.get(param) + try: + contact = models.User.objects.get(id=id_value) + investigator_name = contact.first_name + " " + contact.last_name + + filters.append( + { + "parameter_name": "investigator", + "parameter_value": investigator_name, + } + ) + except models.User.DoesNotExist: + pass + else: + # For other parameter names, append a dictionary with the original + # parameter_name and the corresponding parameter_value + filters.append( + { + "parameter_name": parameter_name, + "parameter_value": request.GET.get(param), + } + ) + return filters + + class UserContactInline(admin.StackedInline): """Edit a user's profile on the user page.""" @@ -52,10 +114,12 @@ class MyHostAdmin(AuditedAdmin): inlines = [HostIPInline] -class DomainAdmin(AuditedAdmin): +class DomainAdmin(ListHeaderAdmin): """Custom domain admin class to add extra buttons.""" + search_fields = ["name"] + search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" readonly_fields = ["state"] @@ -80,10 +144,107 @@ def response_change(self, request, obj): return super().response_change(request, obj) -class DomainApplicationAdmin(AuditedAdmin): +class ContactAdmin(ListHeaderAdmin): + + """Custom contact admin class to add search.""" + + search_fields = ["email", "first_name", "last_name"] + search_help_text = "Search by firstname, lastname or email." + + +class DomainApplicationAdmin(ListHeaderAdmin): """Customize the applications listing view.""" + # Columns + list_display = [ + "requested_domain", + "status", + "organization_type", + "created_at", + "submitter", + "investigator", + ] + + # Filters + list_filter = ("status", "organization_type", "investigator") + + # Search + search_fields = [ + "requested_domain__name", + "submitter__email", + "submitter__first_name", + "submitter__last_name", + ] + search_help_text = "Search by domain or submitter." + + # Detail view + fieldsets = [ + (None, {"fields": ["status", "investigator", "creator"]}), + ( + "Type of organization", + { + "fields": [ + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_agency", + "federal_type", + "is_election_board", + "type_of_work", + "more_organization_information", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + ] + }, + ), + ("Authorizing official", {"fields": ["authorizing_official"]}), + ("Current websites", {"fields": ["current_websites"]}), + (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), + ("Purpose of your domain", {"fields": ["purpose"]}), + ("Your contact information", {"fields": ["submitter"]}), + ("Other employees from your organization?", {"fields": ["other_contacts"]}), + ( + "No other employees from your organization?", + {"fields": ["no_other_contacts_rationale"]}, + ), + ("Anything else we should know?", {"fields": ["anything_else"]}), + ( + "Requirements for operating .gov domains", + {"fields": ["is_policy_acknowledged"]}, + ), + ] + + # Read only that we'll leverage for CISA Analysts + readonly_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", + ] + # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): if change: # Check if the application is being edited @@ -113,10 +274,18 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) + def get_readonly_fields(self, request, obj=None): + if request.user.is_superuser: + # Superusers have full access, no fields are read-only + return [] + else: + # Regular users can only view the specified fields + return self.readonly_fields + admin.site.register(models.User, MyUserAdmin) admin.site.register(models.UserDomainRole, AuditedAdmin) -admin.site.register(models.Contact, AuditedAdmin) +admin.site.register(models.Contact, ContactAdmin) admin.site.register(models.DomainInvitation, AuditedAdmin) admin.site.register(models.DomainInformation, AuditedAdmin) admin.site.register(models.Domain, DomainAdmin) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 4710b0c65c..90918c929e 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -78,6 +78,12 @@ # Installing them here makes them available for execution. # Do not access INSTALLED_APPS directly. Use `django.apps.apps` instead. INSTALLED_APPS = [ + # let's be sure to install our own application! + # it needs to be listed before django.contrib.admin + # otherwise Django would find the default template + # provided by django.contrib.admin first and use + # that instead of our custom templates. + "registrar", # Django automatic admin interface reads metadata # from database models to provide a quick, model-centric # interface where trusted users can manage content @@ -85,6 +91,10 @@ # vv Required by django.contrib.admin vv # the "user" model! *\o/* "django.contrib.auth", + # audit logging of changes to models + # it needs to be listed before django.contrib.contenttypes + # for a ContentType query in fixtures.py + "auditlog", # generic interface for Django models "django.contrib.contenttypes", # required for CSRF protection and many other things @@ -98,16 +108,12 @@ "django.contrib.staticfiles", # application used for integrating with Login.gov "djangooidc", - # audit logging of changes to models - "auditlog", # library to simplify form templating "widget_tweaks", # library for Finite State Machine statuses "django_fsm", # library for phone numbers "phonenumber_field", - # let's be sure to install our own application! - "registrar", # Our internal API application "api", # Only for generating documentation, uncomment to run manage.py generate_puml diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 41295df385..b47ed4aef8 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -10,6 +10,9 @@ Website, ) +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType + fake = Faker() logger = logging.getLogger(__name__) @@ -56,9 +59,37 @@ class UserFixture: }, ] + STAFF = [ + { + "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844", + "first_name": "Rachid-Analyst", + "last_name": "Mrad-Analyst", + }, + { + "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd", + "first_name": "Alysia-Analyst", + "last_name": "Alysia-Analyst", + }, + ] + + STAFF_PERMISSIONS = [ + { + "app_label": "auditlog", + "model": "logentry", + "permissions": ["view_logentry"], + }, + {"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]}, + { + "app_label": "registrar", + "model": "domainapplication", + "permissions": ["change_domainapplication"], + }, + {"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]}, + ] + @classmethod def load(cls): - logger.info("Going to load %s users" % str(len(cls.ADMINS))) + logger.info("Going to load %s superusers" % str(len(cls.ADMINS))) for admin in cls.ADMINS: try: user, _ = User.objects.get_or_create( @@ -73,7 +104,58 @@ def load(cls): logger.debug("User object created for %s" % admin["first_name"]) except Exception as e: logger.warning(e) - logger.debug("All users loaded.") + logger.info("All superusers loaded.") + + logger.info("Going to load %s CISA analysts (staff)" % str(len(cls.STAFF))) + for staff in cls.STAFF: + try: + user, _ = User.objects.get_or_create( + username=staff["username"], + ) + user.is_superuser = False + user.first_name = staff["first_name"] + user.last_name = staff["last_name"] + user.is_staff = True + user.is_active = True + + for permission in cls.STAFF_PERMISSIONS: + app_label = permission["app_label"] + model_name = permission["model"] + permissions = permission["permissions"] + + # Retrieve the content type for the app and model + content_type = ContentType.objects.get( + app_label=app_label, model=model_name + ) + + # Retrieve the permissions based on their codenames + permissions = Permission.objects.filter( + content_type=content_type, codename__in=permissions + ) + + # Assign the permissions to the user + user.user_permissions.add(*permissions) + + # Convert the permissions QuerySet to a list of codenames + permission_list = list( + permissions.values_list("codename", flat=True) + ) + + logger.debug( + app_label + + " | " + + model_name + + " | " + + ", ".join(permission_list) + + " added for user " + + staff["first_name"] + ) + + user.save() + logger.debug("User object created for %s" % staff["first_name"]) + except Exception as e: + logger.warning(e) + logger.info("All CISA analysts (staff) loaded.") class DomainApplicationFixture: diff --git a/src/registrar/templates/admin/change_list.html b/src/registrar/templates/admin/change_list.html new file mode 100644 index 0000000000..1026e7d604 --- /dev/null +++ b/src/registrar/templates/admin/change_list.html @@ -0,0 +1,26 @@ +{% extends "admin/change_list.html" %} + +{% block content_title %} +

{{ title }}

+

+ {{ cl.result_count }} + {% if cl.get_ordering_field_columns %} + sorted + {% endif %} + {% if cl.result_count == 1 %} + result + {% else %} + results + {% endif %} + {% if filters %} + filtered by + {% for filter_param in filters %} + {{ filter_param.parameter_name }} = {{ filter_param.parameter_value }} + {% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% if search_query %} + for {{ search_query }} + {% endif %} +

+{% endblock %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index dfc0787af2..c89f365639 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -8,7 +8,7 @@ from django.conf import settings from django.contrib.auth import get_user_model, login -from registrar.models import Contact, DraftDomain, Website, DomainApplication +from registrar.models import Contact, DraftDomain, Website, DomainApplication, User def get_handlers(): @@ -157,3 +157,16 @@ def completed_application( application.alternative_domains.add(alt) return application + + +def mock_user(): + """A simple user.""" + user_kwargs = dict( + id=4, + first_name="Rachid", + last_name="Mrad", + ) + + user, _ = User.objects.get_or_create(**user_kwargs) + + return user diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 4dc2070b5f..d5396a829b 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,8 +1,9 @@ -from django.test import TestCase, RequestFactory +from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite -from registrar.admin import DomainApplicationAdmin +from registrar.admin import DomainApplicationAdmin, ListHeaderAdmin from registrar.models import DomainApplication, DomainInformation, User -from .common import completed_application +from .common import completed_application, mock_user +from django.contrib.auth import get_user_model from django.conf import settings from unittest.mock import MagicMock @@ -13,6 +14,21 @@ class TestDomainApplicationAdmin(TestCase): def setUp(self): self.site = AdminSite() self.factory = RequestFactory() + self.admin = ListHeaderAdmin(model=DomainApplication, admin_site=None) + self.client = Client(HTTP_HOST="localhost:8080") + username = "admin" + first_name = "First" + last_name = "Last" + email = "info@example.com" + p = "adminpassword" + User = get_user_model() + self.superuser = User.objects.create_superuser( + username=username, + first_name=first_name, + last_name=last_name, + email=email, + password=p, + ) @boto3_mocking.patching def test_save_model_sends_submitted_email(self): @@ -162,3 +178,69 @@ def test_save_model_sends_approved_email(self): if DomainInformation.objects.get(id=application.pk) is not None: DomainInformation.objects.get(id=application.pk).delete() application.delete() + + def test_changelist_view(self): + # Have to get creative to get past linter + p = "adminpassword" + self.client.login(username="admin", password=p) + + # Mock a user + user = mock_user() + + # Make the request using the Client class + # which handles CSRF + # Follow=True handles the redirect + response = self.client.get( + "/admin/registrar/domainapplication/", + { + "status__exact": "started", + "investigator__id__exact": user.id, + "q": "Hello", + }, + follow=True, + ) + + # Assert that the filters and search_query are added to the extra_context + self.assertIn("filters", response.context) + self.assertIn("search_query", response.context) + # Assert the content of filters and search_query + filters = response.context["filters"] + search_query = response.context["search_query"] + self.assertEqual(search_query, "Hello") + self.assertEqual( + filters, + [ + {"parameter_name": "status", "parameter_value": "started"}, + { + "parameter_name": "investigator", + "parameter_value": user.first_name + " " + user.last_name, + }, + ], + ) + + def test_get_filters(self): + # Create a mock request object + request = self.factory.get("/admin/yourmodel/") + # Set the GET parameters for testing + request.GET = { + "status": "started", + "investigator": "Rachid Mrad", + "q": "search_value", + } + # Call the get_filters method + filters = self.admin.get_filters(request) + + # Assert the filters extracted from the request GET + self.assertEqual( + filters, + [ + {"parameter_name": "status", "parameter_value": "started"}, + {"parameter_name": "investigator", "parameter_value": "Rachid Mrad"}, + ], + ) + + def tearDown(self): + # delete any applications too + DomainApplication.objects.all().delete() + User.objects.all().delete() + self.superuser.delete()