mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-21 02:06:03 +02:00
Merge pull request #760 from cisagov/rjm/680-admin-workshop
Django admin MVP implementation: views and permissions
This commit is contained in:
commit
bf93ea7a28
7 changed files with 411 additions and 13 deletions
20
docs/django-admin/roles.md
Normal file
20
docs/django-admin/roles.md
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
26
src/registrar/templates/admin/change_list.html
Normal file
26
src/registrar/templates/admin/change_list.html
Normal 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 %}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue