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 %} +