diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml index 47fb2c226..254078246 100644 --- a/.github/ISSUE_TEMPLATE/issue-default.yml +++ b/.github/ISSUE_TEMPLATE/issue-default.yml @@ -31,8 +31,8 @@ body: attributes: label: Links to other issues description: | - "Add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)." - placeholder: 🔄 Relates to... + "With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)." + placeholder: "- 🔄 Relates to..." - type: markdown id: note attributes: diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 325fc53dd..049eb38b4 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,6 +9,7 @@ from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField +from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import Group @@ -18,7 +19,7 @@ from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models.user_domain_role import UserDomainRole from waffle.admin import FlagAdmin from waffle.models import Sample, Switch -from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website +from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -166,6 +167,9 @@ class DomainRequestAdminForm(forms.ModelForm): "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), } + labels = { + "action_needed_reason_email": "Auto-generated email", + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -444,8 +448,9 @@ class AdminSortFields: sort_mapping = { # == Contact == # "other_contacts": (Contact, _name_sort), - "senior_official": (Contact, _name_sort), "submitter": (Contact, _name_sort), + # == Senior Official == # + "senior_official": (SeniorOfficial, _name_sort), # == User == # "creator": (User, _name_sort), "user": (User, _name_sort), @@ -997,6 +1002,19 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().changelist_view(request, extra_context=extra_context) +class SeniorOfficialAdmin(ListHeaderAdmin): + """Custom Senior Official Admin class.""" + + # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. + search_fields = ["first_name", "last_name", "email"] + search_help_text = "Search by first name, last name or email." + list_display = ["first_name", "last_name", "email"] + + # this ordering effects the ordering of results + # in autocomplete_fields for Senior Official + ordering = ["first_name", "last_name"] + + class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -1467,6 +1485,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore + # This is just a placeholder. This field will be populated in the detail_table_fieldset view. + # This is not a field that exists on the model. + def status_history(self, obj): + return "No changelog to display." + + status_history.short_description = "Status History" # type: ignore + # Filters list_filter = ( StatusListFilter, @@ -1493,9 +1518,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "fields": [ "portfolio", "sub_organization", + "status_history", "status", "rejection_reason", "action_needed_reason", + "action_needed_reason_email", "investigator", "creator", "submitter", @@ -1575,6 +1602,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "alternative_domains", "is_election_board", "federal_agency", + "status_history", + "action_needed_reason_email", ) # Read only that we'll leverage for CISA Analysts @@ -1893,6 +1922,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): extra_context = extra_context or {} extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj) + extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") # Call the superclass method with updated extra_context return super().change_view(request, object_id, form_url, extra_context) @@ -1923,9 +1953,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt" subject_template = get_template(template_subject_path) - # Return the content of the rendered views - context = {"domain_request": domain_request} + if flag_is_active(None, "profile_feature"): # type: ignore + recipient = domain_request.creator + else: + recipient = domain_request.submitter + # Return the content of the rendered views + context = {"domain_request": domain_request, "recipient": recipient} return { "subject_text": subject_template.render(context=context), "email_body_text": template.render(context=context), @@ -2737,6 +2771,7 @@ admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) +admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 94fdd8b07..ad759f445 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -361,9 +361,12 @@ function initializeWidgetOnList(list, parentId) { */ (function (){ let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') + // This is the "action needed reason" field let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); + // This is the "auto-generated email" field + let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email') - if (rejectionReasonFormGroup && actionNeededReasonFormGroup) { + if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) { let statusSelect = document.getElementById('id_status') let isRejected = statusSelect.value == "rejected" let isActionNeeded = statusSelect.value == "action needed" @@ -371,6 +374,7 @@ function initializeWidgetOnList(list, parentId) { // Initial handling of rejectionReasonFormGroup display showOrHideObject(rejectionReasonFormGroup, show=isRejected) showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) + showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage statusSelect.addEventListener('change', function() { @@ -382,6 +386,7 @@ function initializeWidgetOnList(list, parentId) { isActionNeeded = statusSelect.value == "action needed" showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) + showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded) }); @@ -398,6 +403,7 @@ function initializeWidgetOnList(list, parentId) { let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason) + showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) } }); }); @@ -421,42 +427,6 @@ function initializeWidgetOnList(list, parentId) { sessionStorage.removeItem(name); } } - - document.addEventListener('DOMContentLoaded', function() { - let statusSelect = document.getElementById('id_status'); - - function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) { - if (!actionNeededReasonFormGroup || !statusSelect) { - return; - } - - let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container'); - let statusChangelog = document.getElementById('dja-status-changelog'); - - // On action needed, show the email that will be sent out - let showReasonEmailContainer = document.querySelector("#action_needed_reason_email_readonly") - - // Prepopulate values on page load. - if (statusSelect.value === "action needed") { - flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling); - showElement(showReasonEmailContainer); - } else { - // Move the changelog back to its original location - let statusFlexContainer = statusSelect.closest('.flex-container'); - statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling); - hideElement(showReasonEmailContainer); - } - - } - - // Call the function on page load - moveStatusChangelog(actionNeededReasonFormGroup, statusSelect); - - // Add event listener to handle changes to the selector itself - statusSelect.addEventListener('change', function() { - moveStatusChangelog(actionNeededReasonFormGroup, statusSelect); - }) - }); })(); /** An IIFE for toggling the submit bar on domain request forms @@ -552,13 +522,13 @@ function initializeWidgetOnList(list, parentId) { })(); -/** An IIFE that hooks up to the "show email" button. - * which shows the auto generated email on action needed reason. +/** An IIFE that hooks to the show/hide button underneath action needed reason. + * This shows the auto generated email on action needed reason. */ (function () { let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more"); - if(actionNeededReasonDropdown && actionNeededEmail && container) { + if(actionNeededReasonDropdown && actionNeededEmail) { // Add a change listener to the action needed reason dropdown handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail); } diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index aa8020ede..d7d116046 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -787,10 +787,16 @@ div.dja__model-description{ color: var(--link-fg); } +.textarea-wrapper { + width: 100%; + max-width: 610px; +} + .dja-readonly-textarea-container { + width: 100%; textarea { width: 100%; - min-width: 610px; + max-width: 610px; resize: none; cursor: auto; @@ -827,3 +833,20 @@ div.dja__model-description{ // Many elements in django admin try to override this, so we need !important. display: none !important; } + +.margin-top-0 { + margin-top: 0 !important; +} + +.padding-top-0 { + padding-top: 0 !important; +} + + +.flex-container { + @media screen and (min-width: 700px) and (max-width: 1150px) { + &.flex-container--mobile-inline { + display: inline !important; + } + } +} diff --git a/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py b/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py new file mode 100644 index 000000000..c344898c3 --- /dev/null +++ b/src/registrar/migrations/0110_seniorofficial_portfolio_senior_official.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.10 on 2024-07-02 21:03 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0109_domaininformation_sub_organization_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SeniorOfficial", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("first_name", models.CharField(verbose_name="first name")), + ("last_name", models.CharField(verbose_name="last name")), + ("title", models.CharField(verbose_name="title / role")), + ( + "phone", + phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + ("email", models.EmailField(blank=True, max_length=320, null=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="portfolio", + name="senior_official", + field=models.ForeignKey( + blank=True, + help_text="Associated senior official", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="registrar.seniorofficial", + ), + ), + ] diff --git a/src/registrar/migrations/0111_create_groups_v15.py b/src/registrar/migrations/0111_create_groups_v15.py new file mode 100644 index 000000000..6b21f4b0d --- /dev/null +++ b/src/registrar/migrations/0111_create_groups_v15.py @@ -0,0 +1,37 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0110_seniorofficial_portfolio_senior_official"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 376739826..a68633aff 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -19,6 +19,7 @@ from .waffle_flag import WaffleFlag from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization +from .senior_official import SeniorOfficial __all__ = [ @@ -42,6 +43,7 @@ __all__ = [ "Portfolio", "DomainGroup", "Suborganization", + "SeniorOfficial", ] auditlog.register(Contact) @@ -64,3 +66,4 @@ auditlog.register(WaffleFlag) auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) +auditlog.register(SeniorOfficial) diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 0ea036bb7..c72f95c33 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -38,6 +38,15 @@ class Portfolio(TimeStampedModel): default=FederalAgency.get_non_federal_agency, ) + senior_official = models.ForeignKey( + "registrar.SeniorOfficial", + on_delete=models.PROTECT, + help_text="Associated senior official", + unique=False, + null=True, + blank=True, + ) + organization_type = models.CharField( max_length=255, choices=OrganizationChoices.choices, diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py new file mode 100644 index 000000000..3cb064790 --- /dev/null +++ b/src/registrar/models/senior_official.py @@ -0,0 +1,50 @@ +from django.db import models + +from .utility.time_stamped_model import TimeStampedModel +from phonenumber_field.modelfields import PhoneNumberField # type: ignore + + +class SeniorOfficial(TimeStampedModel): + """ + Senior Official is a distinct Contact-like entity (NOT to be inherited + from Contacts) developed for the unique role these individuals have in + managing Portfolios. + """ + + first_name = models.CharField( + null=False, + blank=False, + verbose_name="first name", + ) + last_name = models.CharField( + null=False, + blank=False, + verbose_name="last name", + ) + title = models.CharField( + null=False, + blank=False, + verbose_name="title / role", + ) + phone = PhoneNumberField( + null=True, + blank=True, + ) + email = models.EmailField( + null=True, + blank=True, + max_length=320, + ) + + def get_formatted_name(self): + """Returns the contact's name in Western order.""" + names = [n for n in [self.first_name, self.last_name] if n] + return " ".join(names) if names else "Unknown" + + def __str__(self): + if self.first_name or self.last_name: + return self.get_formatted_name() + elif self.pk: + return str(self.pk) + else: + return "" diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 89537b098..40cd98ca8 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -32,7 +32,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/ {% for field in line %}