Skip to content

Commit

Permalink
Merge pull request #760 from cisagov/rjm/680-admin-workshop
Browse files Browse the repository at this point in the history
Django admin MVP implementation: views and permissions
  • Loading branch information
rachidatecs committed Jul 7, 2023
2 parents acd8201 + 4eafe9a commit bf93ea7
Show file tree
Hide file tree
Showing 7 changed files with 411 additions and 13 deletions.
20 changes: 20 additions & 0 deletions docs/django-admin/roles.md
Original file line number Diff line number Diff line change
@@ -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
175 changes: 172 additions & 3 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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"]

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions src/registrar/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,23 @@
# 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
"django.contrib.admin",
# 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
Expand All @@ -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
Expand Down
86 changes: 84 additions & 2 deletions src/registrar/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading

0 comments on commit bf93ea7

Please sign in to comment.