From 82691a84de19d6d0d2c160230fc0f8232d8f65b5 Mon Sep 17 00:00:00 2001 From: Bobby Novak <176936850+rnovak338@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:57:50 -0500 Subject: [PATCH] Django Admin -> Changes to replace Admin API (#4473) * Initial Commit - Migrated logic pertaining to all Django Admin changes from API PR. * Linting * Adjust staffusers.json based on helpdesk users * Update staffusers.json * Feedback v1 - Removed matthew.jadud@gsa.gov from staffusers. - Removed requirement for user to exist when adding a new `userpermission`. * Address #4458 - Fixed 500 errors from occurring when searching the dissemination tables. - Extended searchable fields when searching tables in the dissemination section. * Support for django admin logs - Lowercasing emails that are added to TribalApiAccessKeyIds. - Creating logs for CRUD operations through the Django Admin Panel, using the factory `LogEntry` model. - New signal for listening to new `LogEntry` records, then modifying its contents for more readable output. * Linting --- .github/ISSUE_TEMPLATE/offboarding.md | 2 +- .github/ISSUE_TEMPLATE/onboarding.md | 5 +- backend/config/staffusers.json | 21 ++++ backend/dissemination/admin.py | 51 ++++++-- backend/run.sh | 5 + backend/support/admin.py | 103 ++++++++++++++++ backend/users/admin.py | 33 ++++- .../management/commands/create_staffusers.py | 115 ++++++++++++++++++ backend/users/models.py | 1 + 9 files changed, 318 insertions(+), 18 deletions(-) create mode 100644 backend/config/staffusers.json create mode 100644 backend/users/management/commands/create_staffusers.py diff --git a/.github/ISSUE_TEMPLATE/offboarding.md b/.github/ISSUE_TEMPLATE/offboarding.md index f18d732bb8..97a0b1616b 100644 --- a/.github/ISSUE_TEMPLATE/offboarding.md +++ b/.github/ISSUE_TEMPLATE/offboarding.md @@ -17,7 +17,7 @@ assignees: '' - [ ] Remove from active Figma projects - [ ] Remove from Mural - [ ] Remove from Zendesk [here](https://fac-gov.zendesk.com/admin/people/team/members). -- [ ] Remove from the list of staff users in the Django Admin app [here](https://app.fac.gov/admin/users/staffuser/). +- [ ] Remove email from the list of Django Admin users in [staffusers.json](../../backend/config/staffusers.json). - [ ] Check for and remove admin access in the FAC application: this may include designated permissions and/or checked-in API access ([for example](https://github.com/GSA-TTS/FAC/blob/fb0e7bdf1cb1807291e6b6eef068e97b4574078c/backend/support/api/admin_api_v1_1_0/create_access_tables.sql#L21)) ## For GitHub contributors diff --git a/.github/ISSUE_TEMPLATE/onboarding.md b/.github/ISSUE_TEMPLATE/onboarding.md index 86b23a05d6..47b0a7d024 100644 --- a/.github/ISSUE_TEMPLATE/onboarding.md +++ b/.github/ISSUE_TEMPLATE/onboarding.md @@ -80,6 +80,8 @@ Note: If you're not able to do any of these yourself, you're still responsible f - [ ] [Add as a form manager to the touchpoints recruitment intercept](https://touchpoints.app.cloud.gov/admin/forms/9412c559/permissions) **For engineers, also...** +- [ ] Make sure you have a `login.gov` account and have logged into the FAC application at least once. + - [ ] Then, add your email to the `readonly` list in [staffusers.json](../../backend/config/staffusers.json). - [ ] [Add as a member of the FAC group in New Relic](https://one.newrelic.com/admin-portal/organizations/users-list) (@GSA-TTS/fac-admins can do this) **For product leads/owners, also...** @@ -88,5 +90,6 @@ Note: If you're not able to do any of these yourself, you're still responsible f - [ ] Also give them the `Maintainer` role in [the FAC-team team in GitHub](https://github.com/orgs/GSA-TTS/teams/fac-team/members). **For helpdesk, also...** -- [ ] Add them to the list of staff users for [Django Admin](https://app.fac.gov/admin/users/staffuser/). +- [ ] Make sure you have a `login.gov` account and have logged into the FAC application at least once. + - [ ] Then, add your email to the `helpdesk` list in [staffusers.json](../../backend/config/staffusers.json). - [ ] Give them access to the [Help Desk](https://fac-gov.zendesk.com/admin/people/team/members) as a team member. diff --git a/backend/config/staffusers.json b/backend/config/staffusers.json new file mode 100644 index 0000000000..56b09e9297 --- /dev/null +++ b/backend/config/staffusers.json @@ -0,0 +1,21 @@ +{ + "readonly": [ + "anastasia.gradova@gsa.gov", + "jason.rothacker@gsa.gov", + "philip.dominguez@gsa.gov" + ], + "helpdesk": [ + "alexander.steel@gsa.gov", + "robert.novak@gsa.gov", + "analyn.delossantos@gsa.gov", + "hassandeme.mamasambo@gsa.gov", + "marissa.henderson@gsa.gov", + "james.p.mason@gsa.gov", + "james.person@gsa.gov", + "leigh.cox@gsa.gov", + "rochelle.ribeiro@gsa.gov" + ], + "superuser": [ + "daniel.swick@gsa.gov" + ] +} diff --git a/backend/dissemination/admin.py b/backend/dissemination/admin.py index cc1e4115a2..f8a2358b56 100644 --- a/backend/dissemination/admin.py +++ b/backend/dissemination/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin - from dissemination.models import ( AdditionalEin, AdditionalUei, @@ -11,7 +10,9 @@ Note, Passthrough, SecondaryAuditor, + TribalApiAccessKeyIds, ) +import datetime class AdditionalEinAdmin(admin.ModelAdmin): @@ -35,7 +36,7 @@ def has_view_permission(self, request, obj=None): "additional_ein", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "additional_ein") class AdditionalUeiAdmin(admin.ModelAdmin): @@ -59,7 +60,7 @@ def has_view_permission(self, request, obj=None): "additional_uei", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "additional_uei") class CapTextAdmin(admin.ModelAdmin): @@ -83,7 +84,7 @@ def has_view_permission(self, request, obj=None): "finding_ref_number", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "finding_ref_number") class FederalAwardAdmin(admin.ModelAdmin): @@ -107,7 +108,10 @@ def has_view_permission(self, request, obj=None): "award_reference", ) - search_fields = ("report_id",) + search_fields = ( + "report_id__report_id", + "award_reference", + ) class FindingAdmin(admin.ModelAdmin): @@ -132,7 +136,7 @@ def has_view_permission(self, request, obj=None): "reference_number", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "award_reference", "reference_number") class FindingTextAdmin(admin.ModelAdmin): @@ -156,7 +160,7 @@ def has_view_permission(self, request, obj=None): "finding_ref_number", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "finding_ref_number") class GeneralAdmin(admin.ModelAdmin): @@ -181,7 +185,7 @@ def has_view_permission(self, request, obj=None): "date_created", ) - search_fields = ("report_id",) + search_fields = ("report_id", "auditee_name", "date_created") class NoteAdmin(admin.ModelAdmin): @@ -205,7 +209,7 @@ def has_view_permission(self, request, obj=None): "note_title", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "note_title") class PassThroughAdmin(admin.ModelAdmin): @@ -230,7 +234,7 @@ def has_view_permission(self, request, obj=None): "passthrough_id", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "award_reference", "passthrough_id") class SecondaryAuditorAdmin(admin.ModelAdmin): @@ -254,7 +258,31 @@ def has_view_permission(self, request, obj=None): "auditor_ein", ) - search_fields = ("report_id",) + search_fields = ("report_id__report_id", "auditor_ein") + + +class TribalApiAccessKeyIdsAdmin(admin.ModelAdmin): + + list_display = ( + "email", + "key_id", + "date_added", + ) + + search_fields = ( + "email", + "key_id", + ) + + fields = [ + "email", + "key_id", + ] + + def save_model(self, request, obj, form, change): + obj.email = obj.email.lower() + obj.date_added = datetime.date.today() + super().save_model(request, obj, form, change) admin.site.register(AdditionalEin, AdditionalEinAdmin) @@ -267,3 +295,4 @@ def has_view_permission(self, request, obj=None): admin.site.register(Note, NoteAdmin) admin.site.register(Passthrough, PassThroughAdmin) admin.site.register(SecondaryAuditor, SecondaryAuditorAdmin) +admin.site.register(TribalApiAccessKeyIds, TribalApiAccessKeyIdsAdmin) diff --git a/backend/run.sh b/backend/run.sh index cfd0fad650..791b7695f6 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -49,6 +49,11 @@ gonogo "curation_audit_tracking_init" seed_cog_baseline gonogo "seed_cog_baseline" +##### +# CREATE STAFF USERS +# Prepares staff users for Django admin +python manage.py create_staffusers + ##### # LAUNCH THE APP # We will have died long ago if things didn't work. diff --git a/backend/support/admin.py b/backend/support/admin.py index f0a65dea03..2ace7c3301 100644 --- a/backend/support/admin.py +++ b/backend/support/admin.py @@ -1,8 +1,25 @@ from django.contrib import admin +from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver from audit.models import SingleAuditChecklist +from dissemination.models import TribalApiAccessKeyIds +from users.models import UserPermission from .models import CognizantBaseline, CognizantAssignment, AssignmentTypeCode +import json +from datetime import date + + +class DateEncoder(json.JSONEncoder): + """Encode date types in admin logs.""" + + def default(self, obj): + if isinstance(obj, date): + return obj.isoformat() + return super().default(obj) + class SupportAdmin(admin.ModelAdmin): def has_module_permission(self, request, obj=None): @@ -106,3 +123,89 @@ def has_change_permission(self, request, obj=None): def has_add_permission(self, request, obj=None): return request.user.is_staff + + +@admin.register(LogEntry) +class LogEntryAdmin(SupportAdmin): + """ + Displays the changelog for actions made from the Admin Panel. + """ + + date_hierarchy = "action_time" + ordering = ["-action_time"] + list_display = [ + "action_time", + "staff_user", + "record_affected", + "event", + "content", + ] + search_fields = ( + "action_time", + "user__email", + "object_repr", + "action_flag", + "change_message", + ) + + def staff_user(self, obj): + return obj.user.email + + def record_affected(self, obj): + return obj.object_repr + + def event(self, obj): + if obj.action_flag == ADDITION: + return "Created" + elif obj.action_flag == DELETION: + return "Deleted" + elif obj.action_flag == CHANGE: + res = "Updated" + if obj.change_message: + _json = json.loads(obj.change_message)[0] + if "changed" in _json: + res = "Updated\n" + for field in _json["changed"]["fields"]: + res += f"\n- {field}" + return res + return "-" + + def content(self, obj): + if obj.change_message: + _json = json.loads(obj.change_message)[0] + if "content" in _json: + return _json["content"] + return "-" + + +@receiver([post_delete, post_save], sender=LogEntry) +def add_custom_field_to_log(sender, instance, created, **kwargs): + """ + Modify content of the log depending on what model(s) were changed. + """ + + if created: + model_class = instance.content_type.model_class() + qset = model_class.objects.filter(pk=instance.object_id) + if qset.exists(): + obj = qset.first() + + # update content of record after save occurred. + change_message_json = json.loads(instance.change_message) + + if model_class == UserPermission: + change_message_json[0]["content"] = list( + qset.values("email", "permission__slug") + ) + elif model_class == TribalApiAccessKeyIds: + change_message_json[0]["content"] = list(qset.values("email", "key_id")) + else: + change_message_json[0]["content"] = list(qset.values("id")) + + # record still exists. + if obj: + change_message_json[0]["id"] = obj.pk + + # write changes to instance. + instance.change_message = json.dumps(change_message_json, cls=DateEncoder) + instance.save() diff --git a/backend/users/admin.py b/backend/users/admin.py index 64261d48eb..57d0e02550 100644 --- a/backend/users/admin.py +++ b/backend/users/admin.py @@ -17,20 +17,38 @@ class PermissionAdmin(admin.ModelAdmin): @admin.register(User) class UserAdmin(admin.ModelAdmin): - list_display = ["email", "can_read_tribal", "last_login", "date_joined"] - list_filter = ["is_staff", "is_superuser"] - exclude = ["groups", "user_permissions", "password"] + list_display = [ + "email", + "can_read_tribal", + "last_login", + "date_joined", + "assigned_groups", + ] + list_filter = ["is_staff", "is_superuser", "groups"] + exclude = ["user_permissions", "password"] readonly_fields = ["date_joined", "last_login"] search_fields = ("email", "username") def can_read_tribal(self, obj): return _can_read_tribal(obj) + def assigned_groups(self, obj): + return ", ".join([g.name for g in obj.groups.all()]) + @admin.register(UserPermission) class UserPermissionAdmin(admin.ModelAdmin): list_display = ["user", "email", "permission"] search_fields = ("email", "permission", "user") + fields = ["email", "permission"] + + def save_model(self, request, obj, form, change): + obj.email = obj.email.lower() + try: + obj.user = User.objects.get(email=obj.email) + except User.DoesNotExist: + pass + super().save_model(request, obj, form, change) @admin.register(StaffUserLog) @@ -57,8 +75,7 @@ def has_delete_permission(self, request, obj=None): class StaffUserAdmin(admin.ModelAdmin): list_display = [ "staff_email", - "added_by_email", - "date_added", + "privilege", ] fields = [ "staff_email", @@ -91,3 +108,9 @@ def has_add_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None): return request.user.is_superuser + + def privilege(self, obj): + user = User.objects.get(email=obj.staff_email, is_staff=True) + if user.is_superuser: + return "Superuser" + return ", ".join([g.name for g in user.groups.all()]) diff --git a/backend/users/management/commands/create_staffusers.py b/backend/users/management/commands/create_staffusers.py new file mode 100644 index 0000000000..0a77188d5a --- /dev/null +++ b/backend/users/management/commands/create_staffusers.py @@ -0,0 +1,115 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.core.management.base import BaseCommand +from django.db import transaction +from users.models import StaffUser +import json +import logging +import os + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + + def handle(self, *args, **kwargs): + """Create a group with readonly permissions.""" + group_readonly, created = Group.objects.get_or_create(name="Read-only") + readonly_codenames = [ + "view_access", + "view_deletedaccess", + "view_singleauditchecklist", + "view_sacvalidationwaiver", + "view_ueivalidationwaiver", + "view_additionalein", + "view_additionaluei", + "view_captext", + "view_federalaward", + "view_findingtext", + "view_finding", + "view_general", + "view_note", + "view_passthrough", + "view_secondaryauditor", + "view_cognizantassignment", + "view_cognizantbaseline", + "view_staffuser", + "view_userpermission", + "view_tribalapiaccesskeyids", + ] + group_readonly.permissions.clear() + for code in readonly_codenames: + group_readonly.permissions.add(Permission.objects.get(codename=code)) + group_readonly.save() + + """Create a group with helpdesk permissions.""" + group_helpdesk, created = Group.objects.get_or_create(name="Helpdesk") + helpdesk_codenames = readonly_codenames + [ + "add_userpermission", + "change_userpermission", + "delete_userpermission", + "add_tribalapiaccesskeyids", + "change_tribalapiaccesskeyids", + "delete_tribalapiaccesskeyids", + "add_sacvalidationwaiver", + "add_ueivalidationwaiver", + "add_cognizantassignment", + ] + group_helpdesk.permissions.clear() + for code in helpdesk_codenames: + group_helpdesk.permissions.add(Permission.objects.get(codename=code)) + group_helpdesk.save() + + # read in staffusers JSON. + user_list = None + with open( + os.path.join(settings.BASE_DIR, "config/staffusers.json"), "r" + ) as file: + user_list = json.load(file) + + if user_list: + + # clear superuser privileges. + superusers = User.objects.filter(is_superuser=True) + for superuser in superusers: + superuser.is_superuser = False + superuser.save() + + # clear existing staff users. + StaffUser.objects.all().delete() + + for role in user_list: + for email in user_list[role]: + + # create staff user for each role. + with transaction.atomic(): + + StaffUser( + staff_email=email, + ).save() + + # attempt to update the user. + try: + user = User.objects.get(email=email, is_staff=True) + + user.groups.clear() + match role: + case "readonly": + user.groups.add(group_readonly) + case "helpdesk": + user.groups.clear() + user.groups.add(group_helpdesk) + case "superuser": + user.is_superuser = True + + user.save() + logger.info(f"Synced {email} to a StaffUser role.") + + # for whatever reason, this failed. Revert staffuser creation. + except User.DoesNotExist: + transaction.set_rollback(True) + logger.warning( + f"StaffUser not created for {email}, they have not logged in yet." + ) diff --git a/backend/users/models.py b/backend/users/models.py index de502a92db..e0c0534b1f 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -70,6 +70,7 @@ def delete(self, *args, **kwargs): try: user = User.objects.get(email=self.staff_email) user.is_staff = False + user.is_superuser = False user.save() except User.DoesNotExist: pass # silently ignore. Nothing to do.