Merge pull request #760 from cisagov/rjm/680-admin-workshop

Django admin MVP implementation: views and permissions
This commit is contained in:
rachidatecs 2023-07-07 09:56:12 -04:00 committed by GitHub
commit bf93ea7a28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 411 additions and 13 deletions

View file

@ -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

View file

@ -24,6 +24,68 @@ class AuditedAdmin(admin.ModelAdmin):
)
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 @@ class DomainAdmin(AuditedAdmin):
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 @@ class DomainApplicationAdmin(AuditedAdmin):
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)

View file

@ -78,6 +78,12 @@ DEBUG = env_debug
# 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 @@ INSTALLED_APPS = [
# 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 @@ INSTALLED_APPS = [
"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

View file

@ -10,6 +10,9 @@ from registrar.models import (
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 @@ class UserFixture:
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:

View file

@ -0,0 +1,26 @@
{% extends "admin/change_list.html" %}
{% block content_title %}
<h1>{{ title }}</h1>
<h2>
{{ 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 %}
</h2>
{% endblock %}

View file

@ -8,7 +8,7 @@ from typing import List, Dict
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

View file

@ -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 @@ class TestDomainApplicationAdmin(TestCase):
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()