Merge branch 'main' into meoward/2247-user-contact

This commit is contained in:
David Kennedy 2024-07-09 16:56:54 -04:00
commit d1a390cec9
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
15 changed files with 392 additions and 127 deletions

View file

@ -31,8 +31,8 @@ body:
attributes: attributes:
label: Links to other issues label: Links to other issues
description: | description: |
"Add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] 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... placeholder: "- 🔄 Relates to..."
- type: markdown - type: markdown
id: note id: note
attributes: attributes:

View file

@ -9,6 +9,7 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField 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 import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group 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 registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch 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.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
@ -166,6 +167,9 @@ class DomainRequestAdminForm(forms.ModelForm):
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
} }
labels = {
"action_needed_reason_email": "Auto-generated email",
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -444,8 +448,9 @@ class AdminSortFields:
sort_mapping = { sort_mapping = {
# == Contact == # # == Contact == #
"other_contacts": (Contact, _name_sort), "other_contacts": (Contact, _name_sort),
"senior_official": (Contact, _name_sort),
"submitter": (Contact, _name_sort), "submitter": (Contact, _name_sort),
# == Senior Official == #
"senior_official": (SeniorOfficial, _name_sort),
# == User == # # == User == #
"creator": (User, _name_sort), "creator": (User, _name_sort),
"user": (User, _name_sort), "user": (User, _name_sort),
@ -997,6 +1002,19 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().changelist_view(request, extra_context=extra_context) 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): class WebsiteResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file""" 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.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # 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 # Filters
list_filter = ( list_filter = (
StatusListFilter, StatusListFilter,
@ -1493,9 +1518,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"fields": [ "fields": [
"portfolio", "portfolio",
"sub_organization", "sub_organization",
"status_history",
"status", "status",
"rejection_reason", "rejection_reason",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email",
"investigator", "investigator",
"creator", "creator",
"submitter", "submitter",
@ -1575,6 +1602,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"action_needed_reason_email",
) )
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
@ -1893,6 +1922,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
extra_context = extra_context or {} extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries 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["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 # Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, 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" template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
subject_template = get_template(template_subject_path) subject_template = get_template(template_subject_path)
# Return the content of the rendered views if flag_is_active(None, "profile_feature"): # type: ignore
context = {"domain_request": domain_request} recipient = domain_request.creator
else:
recipient = domain_request.submitter
# Return the content of the rendered views
context = {"domain_request": domain_request, "recipient": recipient}
return { return {
"subject_text": subject_template.render(context=context), "subject_text": subject_template.render(context=context),
"email_body_text": 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.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -361,9 +361,12 @@ function initializeWidgetOnList(list, parentId) {
*/ */
(function (){ (function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); 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 statusSelect = document.getElementById('id_status')
let isRejected = statusSelect.value == "rejected" let isRejected = statusSelect.value == "rejected"
let isActionNeeded = statusSelect.value == "action needed" let isActionNeeded = statusSelect.value == "action needed"
@ -371,6 +374,7 @@ function initializeWidgetOnList(list, parentId) {
// Initial handling of rejectionReasonFormGroup display // Initial handling of rejectionReasonFormGroup display
showOrHideObject(rejectionReasonFormGroup, show=isRejected) showOrHideObject(rejectionReasonFormGroup, show=isRejected)
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() { statusSelect.addEventListener('change', function() {
@ -382,6 +386,7 @@ function initializeWidgetOnList(list, parentId) {
isActionNeeded = statusSelect.value == "action needed" isActionNeeded = statusSelect.value == "action needed"
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded) addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
}); });
@ -398,6 +403,7 @@ function initializeWidgetOnList(list, parentId) {
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason) showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
} }
}); });
}); });
@ -421,42 +427,6 @@ function initializeWidgetOnList(list, parentId) {
sessionStorage.removeItem(name); 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 /** 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. /** An IIFE that hooks to the show/hide button underneath action needed reason.
* which shows the auto generated email on action needed reason. * This shows the auto generated email on action needed reason.
*/ */
(function () { (function () {
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more"); 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 // Add a change listener to the action needed reason dropdown
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail); handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
} }

View file

@ -787,10 +787,16 @@ div.dja__model-description{
color: var(--link-fg); color: var(--link-fg);
} }
.textarea-wrapper {
width: 100%;
max-width: 610px;
}
.dja-readonly-textarea-container { .dja-readonly-textarea-container {
width: 100%;
textarea { textarea {
width: 100%; width: 100%;
min-width: 610px; max-width: 610px;
resize: none; resize: none;
cursor: auto; cursor: auto;
@ -827,3 +833,20 @@ div.dja__model-description{
// Many elements in django admin try to override this, so we need !important. // Many elements in django admin try to override this, so we need !important.
display: none !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;
}
}
}

View file

@ -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",
),
),
]

View file

@ -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,
),
]

View file

@ -19,6 +19,7 @@ from .waffle_flag import WaffleFlag
from .portfolio import Portfolio from .portfolio import Portfolio
from .domain_group import DomainGroup from .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
from .senior_official import SeniorOfficial
__all__ = [ __all__ = [
@ -42,6 +43,7 @@ __all__ = [
"Portfolio", "Portfolio",
"DomainGroup", "DomainGroup",
"Suborganization", "Suborganization",
"SeniorOfficial",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -64,3 +66,4 @@ auditlog.register(WaffleFlag)
auditlog.register(Portfolio) auditlog.register(Portfolio)
auditlog.register(DomainGroup) auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)

View file

@ -38,6 +38,15 @@ class Portfolio(TimeStampedModel):
default=FederalAgency.get_non_federal_agency, 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( organization_type = models.CharField(
max_length=255, max_length=255,
choices=OrganizationChoices.choices, choices=OrganizationChoices.choices,

View file

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

View file

@ -32,7 +32,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% for field in line %} {% for field in line %}
<div> <div>
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
{% block flex_container_start %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}"> <div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endblock flex_container_start %}
{% if field.is_checkbox %} {% if field.is_checkbox %}
{# .gov override #} {# .gov override #}
{% block field_checkbox %} {% block field_checkbox %}
@ -52,7 +54,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% endblock field_other%} {% endblock field_other%}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% block flex_container_end %}
</div> </div>
{% endblock flex_container_end %}
{% if field.field.help_text %} {% if field.field.help_text %}
{# .gov override #} {# .gov override #}

View file

@ -6,9 +6,85 @@
This is using a custom implementation fieldset.html (see admin/fieldset.html) This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %} {% endcomment %}
{% block flex_container_start %}
{% if field.field.name == "status_history" %}
<div class="flex-container flex-container--mobile-inline {% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% else %}
{% comment %} Default flex container element {% endcomment %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endif %}
{% endblock flex_container_start %}
{% block field_readonly %} {% block field_readonly %}
{% with all_contacts=original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %}
{% if field.field.name == "other_contacts" %} {% if field.field.name == "status_history" %}
{% if filtered_audit_log_entries %}
<div class="readonly">
<div class="usa-table-container--scrollable collapse--dgsimple collapsed margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Changed at</th>
</tr>
</thead>
<tbody>
{% for entry in filtered_audit_log_entries %}
<tr>
<td>
{% if entry.status %}
{{ entry.status|default:"Error" }}
{% else %}
Error
{% endif %}
{% if entry.rejection_reason %}
- {{ entry.rejection_reason|default:"Error" }}
{% endif %}
{% if entry.action_needed_reason %}
- {{ entry.action_needed_reason|default:"Error" }}
{% endif %}
</td>
<td>{{ entry.actor|default:"Error" }}</td>
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
{% else %}
<div class="readonly">
No changelog to display.
</div>
{% endif %}
{% elif field.field.name == "action_needed_reason_email" %}
<div class="readonly textarea-wrapper">
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-0 padding-top-0 margin-bottom-1 thin-border collapse--dgsimple collapsed">
<label class="max-full" for="action_needed_reason_email_view_more">
<strong>Sent to {% if has_profile_feature_flag %}creator{%else%}submitter{%endif%}</strong>
</label>
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
{{ original_object.action_needed_reason_email }}
</textarea>
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
{% elif field.field.name == "other_contacts" %}
{% if all_contacts.count > 2 %} {% if all_contacts.count > 2 %}
<div class="readonly"> <div class="readonly">
{% for contact in all_contacts %} {% for contact in all_contacts %}
@ -68,48 +144,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endblock field_readonly %} {% endblock field_readonly %}
{% block after_help_text %} {% block after_help_text %}
{% if field.field.name == "status" %} {% if field.field.name == "action_needed_reason_email" %}
<div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label>
<div>
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
{% if filtered_audit_log_entries %}
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Changed at</th>
</tr>
</thead>
<tbody>
{% for entry in filtered_audit_log_entries %}
<tr>
<td>
{% if entry.status %}
{{ entry.status|default:"Error" }}
{% else %}
Error
{% endif %}
{% if entry.rejection_reason %}
- {{ entry.rejection_reason|default:"Error" }}
{% endif %}
{% if entry.action_needed_reason %}
- {{ entry.action_needed_reason|default:"Error" }}
{% endif %}
</td>
<td>{{ entry.actor|default:"Error" }}</td>
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No changelog to display.</p>
{% endif %}
{% comment %} {% comment %}
Store the action needed reason emails in a json-based dictionary. Store the action needed reason emails in a json-based dictionary.
This allows us to change the action_needed_reason_email field dynamically, depending on value. This allows us to change the action_needed_reason_email field dynamically, depending on value.
@ -122,26 +157,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{{ action_needed_reason_emails|safe }} {{ action_needed_reason_emails|safe }}
</script> </script>
{% endif %} {% endif %}
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-2 thin-border display-none">
<label class="max-full" for="action_needed_reason_email_view_more">
<strong>Auto-generated email (sent to submitter)</strong>
</label>
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
{{ original_object.action_needed_reason_email }}
</textarea>
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
</div>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
</div>
{% elif field.field.name == "creator" %} {% elif field.field.name == "creator" %}
<div class="flex-container tablet:margin-top-2"> <div class="flex-container tablet:margin-top-2">
<label aria-label="Creator contact details"></label> <label aria-label="Creator contact details"></label>

View file

@ -18,7 +18,7 @@
completing your domain request might take around 15 minutes.</p> completing your domain request might take around 15 minutes.</p>
{% if has_profile_feature_flag %} {% if has_profile_feature_flag %}
<h2>How well reach you</h2> <h2>How well reach you</h2>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p> <p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review. If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
{% include "includes/profile_information.html" with user=user%} {% include "includes/profile_information.html" with user=user%}
{% endif %} {% endif %}

View file

@ -44,6 +44,7 @@ from registrar.models import (
UserGroup, UserGroup,
TransitionDomain, TransitionDomain,
) )
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff from registrar.models.verified_by_staff import VerifiedByStaff
from .common import ( from .common import (
@ -931,6 +932,32 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.mock_client = MockSESClient() self.mock_client = MockSESClient()
def test_domain_request_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title")
contact, _ = Contact.objects.get_or_create(user=self.staffuser)
domain_request = completed_domain_request(submitter=contact, name="city1.gov")
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
model_admin = AuditedAdmin(DomainRequest, self.site)
# Get the queryset that would be returned for the list
senior_offical_queryset = model_admin.formfield_for_foreignkey(
DomainInformation.senior_official.field, request
).queryset
# Make the list we're comparing on a bit prettier display-wise. Optional step.
current_sort_order = []
for official in senior_offical_queryset:
current_sort_order.append(f"{official.first_name} {official.last_name}")
expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
self.assertEqual(current_sort_order, expected_sort_order)
@less_console_noise_decorator @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
@ -2233,6 +2260,8 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"action_needed_reason_email",
"id", "id",
"created_at", "created_at",
"updated_at", "updated_at",
@ -2293,6 +2322,8 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"action_needed_reason_email",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",
@ -2323,6 +2354,8 @@ class TestDomainRequestAdmin(MockEppLib):
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"status_history",
"action_needed_reason_email",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -2719,6 +2752,7 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete() User.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
Website.objects.all().delete() Website.objects.all().delete()
SeniorOfficial.objects.all().delete()
self.mock_client.EMAILS_SENT.clear() self.mock_client.EMAILS_SENT.clear()
@ -2900,6 +2934,38 @@ class TestDomainInformationAdmin(TestCase):
Domain.objects.all().delete() Domain.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
SeniorOfficial.objects.all().delete()
def test_domain_information_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy")
SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy")
SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title")
contact, _ = Contact.objects.get_or_create(user=self.staffuser)
domain_request = completed_domain_request(
submitter=contact, name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
)
domain_request.approve()
domain_info = DomainInformation.objects.get(domain_request=domain_request)
request = self.factory.post("/admin/registrar/domaininformation/{}/change/".format(domain_info.pk))
model_admin = AuditedAdmin(DomainInformation, self.site)
# Get the queryset that would be returned for the list
senior_offical_queryset = model_admin.formfield_for_foreignkey(
DomainInformation.senior_official.field, request
).queryset
# Make the list we're comparing on a bit prettier display-wise. Optional step.
current_sort_order = []
for official in senior_offical_queryset:
current_sort_order.append(f"{official.first_name} {official.last_name}")
expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"]
self.assertEqual(current_sort_order, expected_sort_order)
@less_console_noise_decorator @less_console_noise_decorator
def test_admin_can_see_cisa_region_federal(self): def test_admin_can_see_cisa_region_federal(self):
@ -3650,6 +3716,7 @@ class AuditedAdminTest(TestCase):
self.site = AdminSite() self.site = AdminSite()
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.staffuser = create_user()
def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names): def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names):
with less_console_noise(): with less_console_noise():
@ -3701,7 +3768,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_request(self): def test_alphabetically_sorted_fk_fields_domain_request(self):
with less_console_noise(): with less_console_noise():
tested_fields = [ tested_fields = [
DomainRequest.senior_official.field, # Senior offical is commented out for now - this is alphabetized
# and this test does not accurately reflect that.
# DomainRequest.senior_official.field,
DomainRequest.submitter.field, DomainRequest.submitter.field,
# DomainRequest.investigator.field, # DomainRequest.investigator.field,
DomainRequest.creator.field, DomainRequest.creator.field,
@ -3759,7 +3828,9 @@ class AuditedAdminTest(TestCase):
def test_alphabetically_sorted_fk_fields_domain_information(self): def test_alphabetically_sorted_fk_fields_domain_information(self):
with less_console_noise(): with less_console_noise():
tested_fields = [ tested_fields = [
DomainInformation.senior_official.field, # Senior offical is commented out for now - this is alphabetized
# and this test does not accurately reflect that.
# DomainInformation.senior_official.field,
DomainInformation.submitter.field, DomainInformation.submitter.field,
# DomainInformation.creator.field, # DomainInformation.creator.field,
(DomainInformation.domain.field, ["name"]), (DomainInformation.domain.field, ["name"]),
@ -3792,7 +3863,6 @@ class AuditedAdminTest(TestCase):
# Conforms to the same object structure as desired_order # Conforms to the same object structure as desired_order
current_sort_order_coerced_type = [] current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset # This is necessary as .queryset and get_queryset
# return lists of different types/structures. # return lists of different types/structures.
# We need to parse this data and coerce them into the same type. # We need to parse this data and coerce them into the same type.
@ -3869,7 +3939,8 @@ class AuditedAdminTest(TestCase):
if last_name is None: if last_name is None:
return (first_name,) return (first_name,)
if first_name.split(queryset_shorthand)[1] == field_name: split_name = first_name.split(queryset_shorthand)
if len(split_name) == 2 and split_name[1] == field_name:
return returned_tuple return returned_tuple
else: else:
return None return None

View file

@ -601,6 +601,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
# Add a phone number # Add a phone number
finish_setup_form = finish_setup_page.form finish_setup_form = finish_setup_page.form
finish_setup_form["first_name"] = "firstname"
finish_setup_form["phone"] = "(201) 555-0123" finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO" finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example" finish_setup_form["last_name"] = "example"
@ -729,6 +730,8 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
self.assertContains(save_page, "Your profile has been updated.") self.assertContains(save_page, "Your profile has been updated.")
# We need to assert that logo is not clickable and links to manage your domain are not present # We need to assert that logo is not clickable and links to manage your domain are not present
# NOTE: "anage" is not a typo. It is to accomodate the fact that the "m" is uppercase in one
# instance and lowercase in the other.
self.assertContains(save_page, "anage your domains", count=2) self.assertContains(save_page, "anage your domains", count=2)
self.assertNotContains( self.assertNotContains(
save_page, "Before you can manage your domains, we need you to add contact information" save_page, "Before you can manage your domains, we need you to add contact information"

View file

@ -1,5 +1,4 @@
"""Views for a User Profile. """Views for a User Profile.
""" """
import logging import logging
@ -106,6 +105,7 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
"""If the form is invalid, conditionally display an additional error.""" """If the form is invalid, conditionally display an additional error."""
if hasattr(self.user, "finished_setup") and not self.user.finished_setup: if hasattr(self.user, "finished_setup") and not self.user.finished_setup:
messages.error(self.request, "Before you can manage your domain, we need you to add contact information.") messages.error(self.request, "Before you can manage your domain, we need you to add contact information.")
form.initial["redirect"] = form.data.get("redirect")
return super().form_invalid(form) return super().form_invalid(form)
def form_valid(self, form): def form_valid(self, form):