diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 56f5310e0..2e81d1d4b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,8 +1,6 @@ from datetime import date import logging import copy -import json -from django.template.loader import get_template from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce @@ -10,7 +8,7 @@ from django.http import HttpResponseRedirect from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField -from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission +from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages @@ -22,6 +20,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.utility.admin_helpers import get_all_action_needed_reason_emails, get_action_needed_reason_default_email from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes @@ -1902,9 +1901,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Set the action_needed_reason_email to the default if nothing exists. # Since this check occurs after save, if the user enters a value then we won't update. - default_email = self._get_action_needed_reason_default_email(obj, obj.action_needed_reason) + default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason) if obj.action_needed_reason_email: - emails = self.get_all_action_needed_reason_emails(obj) + emails = get_all_action_needed_reason_emails(request, obj) is_custom_email = obj.action_needed_reason_email not in emails.values() if not is_custom_email: obj.action_needed_reason_email = default_email @@ -2134,8 +2133,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Initialize extra_context and add filtered entries extra_context = extra_context or {} extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries - emails = self.get_all_action_needed_reason_emails(obj) - extra_context["action_needed_reason_emails"] = json.dumps(emails) # Denote if an action needed email was sent or not email_sent = request.session.get("action_needed_email_sent", False) @@ -2146,39 +2143,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Call the superclass method with updated extra_context return super().change_view(request, object_id, form_url, extra_context) - def get_all_action_needed_reason_emails(self, domain_request): - """Returns a json dictionary of every action needed reason and its associated email - for this particular domain request.""" - - emails = {} - for action_needed_reason in domain_request.ActionNeededReasons: - # Map the action_needed_reason to its default email - emails[action_needed_reason.value] = self._get_action_needed_reason_default_email( - domain_request, action_needed_reason.value - ) - - return emails - - def _get_action_needed_reason_default_email(self, domain_request, action_needed_reason): - """Returns the default email associated with the given action needed reason""" - if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER: - return None - - recipient = domain_request.creator - - # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient} - - # Get the email body - template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" - - email_body_text = get_template(template_path).render(context=context) - email_body_text_cleaned = None - if email_body_text: - email_body_text_cleaned = email_body_text.strip().lstrip("\n") - - return email_body_text_cleaned - def process_log_entry(self, log_entry): """Process a log entry and return filtered entry dictionary if applicable.""" changes = log_entry.changes @@ -2289,10 +2253,58 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation - fieldsets = DomainInformationAdmin.fieldsets - readonly_fields = DomainInformationAdmin.readonly_fields - analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields - autocomplete_fields = DomainInformationAdmin.autocomplete_fields + fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets)) + analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields) + autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields) + + def get_domain_managers(self, obj): + user_domain_roles = UserDomainRole.objects.filter(domain=obj.domain) + user_ids = user_domain_roles.values_list("user_id", flat=True) + domain_managers = User.objects.filter(id__in=user_ids) + return domain_managers + + def get_domain_invitations(self, obj): + domain_invitations = DomainInvitation.objects.filter( + domain=obj.domain, status=DomainInvitation.DomainInvitationStatus.INVITED + ) + return domain_invitations + + def domain_managers(self, obj): + """Get domain managers for the domain, unpack and return an HTML block.""" + domain_managers = self.get_domain_managers(obj) + if not domain_managers: + return "No domain managers found." + + domain_manager_details = "" + for domain_manager in domain_managers: + full_name = domain_manager.get_formatted_name() + change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) + domain_manager_details += "" + domain_manager_details += f'" + domain_manager_details += f"" + domain_manager_details += "" + domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' + domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" + return format_html(domain_manager_details) + + domain_managers.short_description = "Domain managers" # type: ignore + + def invited_domain_managers(self, obj): + """Get emails which have been invited to the domain, unpack and return an HTML block.""" + domain_invitations = self.get_domain_invitations(obj) + if not domain_invitations: + return "No invited domain managers found." + + domain_invitation_details = "" + "" + for domain_invitation in domain_invitations: + domain_invitation_details += "" + domain_invitation_details += f"" + domain_invitation_details += f"" + domain_invitation_details += "" + domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" + return format_html(domain_invitation_details) + + invited_domain_managers.short_description = "Invited domain managers" # type: ignore def has_change_permission(self, request, obj=None): """Custom has_change_permission override so that we can specify that @@ -2332,7 +2344,9 @@ class DomainInformationInline(admin.StackedInline): return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_readonly_fields(self, request, obj=None): - return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) + readonly_fields = copy.deepcopy(DomainInformationAdmin.get_readonly_fields(self, request, obj=None)) + readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore + return readonly_fields # Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets # since that has all the logic for excluding certain fields according to user permissions. @@ -2341,13 +2355,34 @@ class DomainInformationInline(admin.StackedInline): def get_fieldsets(self, request, obj=None): # Grab fieldsets from DomainInformationAdmin so that it handles all logic # for permission-based field visibility. - modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None) + modified_fieldsets = copy.deepcopy(DomainInformationAdmin.get_fieldsets(self, request, obj=None)) - # remove .gov domain from fieldset + # Modify fieldset sections in place + for index, (title, options) in enumerate(modified_fieldsets): + if title is None: + options["fields"] = [ + field for field in options["fields"] if field not in ["creator", "domain_request", "notes"] + ] + elif title == "Contacts": + options["fields"] = [ + field + for field in options["fields"] + if field not in ["other_contacts", "no_other_contacts_rationale"] + ] + options["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore + elif title == "Background info": + # move domain request and notes to background + options["fields"].extend(["domain_request", "notes"]) # type: ignore + + # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): if title == ".gov domain": - del modified_fieldsets[index] - break + # remove .gov domain from fieldset + modified_fieldsets.pop(index) + elif title == "Background info": + # move Background info to the bottom of the list + fieldsets_to_move = modified_fieldsets.pop(index) + modified_fieldsets.append(fieldsets_to_move) return modified_fieldsets @@ -2405,13 +2440,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): fieldsets = ( ( None, - {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]}, + {"fields": ["state", "expiration_date", "first_ready", "deleted", "dnssecdata", "nameservers"]}, ), ) - # this ordering effects the ordering of results in autocomplete_fields for domain - ordering = ["name"] - def generic_org_type(self, obj): return obj.domain_info.get_generic_org_type_display() @@ -2432,6 +2464,28 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): organization_name.admin_order_field = "domain_info__organization_name" # type: ignore + def dnssecdata(self, obj): + return "Yes" if obj.dnssecdata else "No" + + dnssecdata.short_description = "DNSSEC enabled" # type: ignore + + # Custom method to display formatted nameservers + def nameservers(self, obj): + if not obj.nameservers: + return "No nameservers" + + formatted_nameservers = [] + for server, ip_list in obj.nameservers: + server_display = str(server) + if ip_list: + server_display += f" [{', '.join(ip_list)}]" + formatted_nameservers.append(server_display) + + # Join the formatted strings with line breaks + return "\n".join(formatted_nameservers) + + nameservers.short_description = "Name servers" # type: ignore + def custom_election_board(self, obj): domain_info = getattr(obj, "domain_info", None) if domain_info: @@ -2458,7 +2512,15 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): search_fields = ["name"] search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" - readonly_fields = ("state", "expiration_date", "first_ready", "deleted", "federal_agency") + readonly_fields = ( + "state", + "expiration_date", + "first_ready", + "deleted", + "federal_agency", + "dnssecdata", + "nameservers", + ) # Table ordering ordering = ["name"] diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 25e35b73b..73f3dded1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -504,167 +504,111 @@ function initializeWidgetOnList(list, parentId) { /** An IIFE that hooks to the show/hide button underneath action needed reason. * This shows the auto generated email on action needed reason. */ -(function () { - // Since this is an iife, these vars will be removed from memory afterwards - var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); - - // Placeholder text (for certain "action needed" reasons that do not involve e=mails) - var placeholderText = document.querySelector("#action-needed-reason-email-placeholder-text") +document.addEventListener('DOMContentLoaded', function() { + const dropdown = document.getElementById("id_action_needed_reason"); + const textarea = document.getElementById("id_action_needed_reason_email") + const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null + const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder"); + const directEditButton = document.querySelector('.field-action_needed_reason_email__edit'); + const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger'); + const modalConfirm = document.getElementById('confirm-edit-email'); + const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); + let lastSentEmailContent = document.getElementById("last-sent-email-content"); + const initialDropdownValue = dropdown ? dropdown.value : null; + const initialEmailValue = textarea.value; - // E-mail divs and textarea components - var actionNeededEmail = document.querySelector("#id_action_needed_reason_email") - var actionNeededEmailReadonly = document.querySelector("#action-needed-reason-email-readonly") - var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea") - - // Edit e-mail modal (and its confirmation button) - var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button") - - // Headers and footers (which change depending on if the e-mail was sent or not) - var actionNeededEmailHeader = document.querySelector("#action-needed-email-header") - var actionNeededEmailHeaderOnSave = document.querySelector("#action-needed-email-header-email-sent") - var actionNeededEmailFooter = document.querySelector("#action-needed-email-footer") - - let emailWasSent = document.getElementById("action-needed-email-sent"); - let lastSentEmailText = document.getElementById("action-needed-email-last-sent-text"); - - // Get the list of e-mails associated with each action-needed dropdown value - let emailData = document.getElementById('action-needed-emails-data'); - if (!emailData) { - return; - } - let actionNeededEmailData = emailData.textContent; - if(!actionNeededEmailData) { - return; - } - let actionNeededEmailsJson = JSON.parse(actionNeededEmailData); - - const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null - const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`; - const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null; - const oldEmailValue = actionNeededEmailData ? actionNeededEmailData.value : null; - - if(actionNeededReasonDropdown && actionNeededEmail && domainRequestId) { - // Add a change listener to dom load - document.addEventListener('DOMContentLoaded', function() { - let reason = actionNeededReasonDropdown.value; - - // Handle the session boolean (to enable/disable editing) - if (emailWasSent && emailWasSent.value === "True") { - // An email was sent out - store that information in a session variable - addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true); - } - - // Show an editable email field or a readonly one - updateActionNeededEmailDisplay(reason) - }); - - // editEmailButton.addEventListener("click", function() { - // if (!checkEmailAlreadySent()) { - // showEmail(canEdit=true) - // } - // }); - - confirmEditEmailButton.addEventListener("click", function() { - // Show editable view - showEmail(canEdit=true) - }); - - - // Add a change listener to the action needed reason dropdown - actionNeededReasonDropdown.addEventListener("change", function() { - let reason = actionNeededReasonDropdown.value; - let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null; - - if (reason && emailBody) { - // Reset the session object on change since change refreshes the email content. - if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) { - // Replace the email content - actionNeededEmail.value = emailBody; - actionNeededEmailReadonlyTextarea.value = emailBody; - hideEmailAlreadySentView(); - } - } - - // Show either a preview of the email or some text describing no email will be sent - updateActionNeededEmailDisplay(reason) - }); + // We will use the const to control the modal + let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); + // We will use the function to control the label and help + function isEmailAlreadySent() { + return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); } - function checkEmailAlreadySent() - { - lastEmailSent = lastSentEmailText.value.replace(/\s+/g, '') - currentEmailInTextArea = actionNeededEmail.value.replace(/\s+/g, '') - return lastEmailSent === currentEmailInTextArea - } + if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return; + const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value; - // Shows a readonly preview of the email with updated messaging to indicate this email was sent - function showEmailAlreadySentView() - { - hideElement(actionNeededEmailHeader) - showElement(actionNeededEmailHeaderOnSave) - actionNeededEmailFooter.innerHTML = "This email has been sent to the creator of this request"; - } - - // Shows a readonly preview of the email with updated messaging to indicate this email was sent - function hideEmailAlreadySentView() - { - showElement(actionNeededEmailHeader) - hideElement(actionNeededEmailHeaderOnSave) - actionNeededEmailFooter.innerHTML = "This email will be sent to the creator of this request after saving"; - } - - // Shows either a preview of the email or some text describing no email will be sent. - // If the email doesn't exist or if we're of reason "other", display that no email was sent. - function updateActionNeededEmailDisplay(reason) { - hideElement(actionNeededEmail.parentElement) - - if (reason) { - if (reason === "other") { - // Hide email preview and show this text instead - showPlaceholderText("No email will be sent"); - } - else { - // Always show readonly view of email to start - showEmail(canEdit=false) - if(checkEmailAlreadySent()) - { - showEmailAlreadySentView(); - } - } + function updateUserInterface(reason) { + if (!reason) { + // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text + formLabel.innerHTML = "Email:"; + textareaPlaceholder.innerHTML = "Select an action needed reason to see email"; + showElement(textareaPlaceholder); + hideElement(directEditButton); + hideElement(modalTrigger); + hideElement(textarea); + } else if (reason === 'other') { + // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text + formLabel.innerHTML = "Email:"; + textareaPlaceholder.innerHTML = "No email will be sent"; + showElement(textareaPlaceholder); + hideElement(directEditButton); + hideElement(modalTrigger); + hideElement(textarea); } else { - // Hide email preview and show this text instead - showPlaceholderText("Select an action needed reason to see email"); + // A triggering selection is selected, all hands on board: + textarea.setAttribute('readonly', true); + showElement(textarea); + hideElement(textareaPlaceholder); + + if (isEmailAlreadySentConst) { + hideElement(directEditButton); + showElement(modalTrigger); + } else { + showElement(directEditButton); + hideElement(modalTrigger); + } + if (isEmailAlreadySent()) { + formLabel.innerHTML = "Email sent to creator:"; + } else { + formLabel.innerHTML = "Email:"; + } } } - // Shows either a readonly view (canEdit=false) or editable view (canEdit=true) of the action needed email - function showEmail(canEdit) - { - if(!canEdit) - { - showElement(actionNeededEmailReadonly) - hideElement(actionNeededEmail.parentElement) - } - else - { - hideElement(actionNeededEmailReadonly) - showElement(actionNeededEmail.parentElement) - } - showElement(actionNeededEmailFooter) // this is the same for both views, so it was separated out - hideElement(placeholderText) - } + // Initialize UI + updateUserInterface(dropdown.value); - // Hides preview of action needed email and instead displays the given text (innerHTML) - function showPlaceholderText(innerHTML) - { - hideElement(actionNeededEmail.parentElement) - hideElement(actionNeededEmailReadonly) - hideElement(actionNeededEmailFooter) + dropdown.addEventListener("change", function() { + const reason = dropdown.value; + // Update the UI + updateUserInterface(reason); + if (reason && reason !== "other") { + // If it's not the initial value + if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) { + // Replace the email content + fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + }else { + textarea.value = data.action_needed_email; + } + updateUserInterface(reason); + }) + .catch(error => { + console.error("Error action needed email: ", error) + }); + } + } - placeholderText.innerHTML = innerHTML; - showElement(placeholderText) - } -})(); + }); + + modalConfirm.addEventListener("click", () => { + textarea.removeAttribute('readonly'); + textarea.focus(); + hideElement(directEditButton); + hideElement(modalTrigger); + }); + directEditButton.addEventListener("click", () => { + textarea.removeAttribute('readonly'); + textarea.focus(); + hideElement(directEditButton); + hideElement(modalTrigger); + }); +}); /** An IIFE for copy summary button (appears in DomainRegistry models) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 3859b3270..c0d50bac1 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -894,23 +894,6 @@ div.dja__model-description{ } } -.vertical-separator { - min-height: 20px; - height: 100%; - width: 1px; - background-color: #d1d2d2; - vertical-align: middle -} - -.usa-summary-box_admin { - color: var(--body-fg); - border-color: var(--summary-box-border); - background-color: var(--summary-box-bg); - min-width: fit-content; - padding: .5rem; - border-radius: .25rem; -} - .text-faded { color: #{$dhs-gray-60}; } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index f45770cf6..76c77955f 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -28,6 +28,7 @@ from registrar.views.transfer_user import TransferUserView from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json, + get_action_needed_email_for_user_json, ) from registrar.views.domains_json import get_domains_json from registrar.views.utility import always_404 @@ -153,6 +154,11 @@ urlpatterns = [ get_federal_and_portfolio_types_from_federal_agency_json, name="get-federal-and-portfolio-types-from-federal-agency-json", ), + path( + "admin/api/get-action-needed-email-for-user-json/", + get_action_needed_email_for_user_json, + name="get-action-needed-email-for-user-json", + ), path("admin/", admin.site.urls), path( "reports/export_data_type_user/", diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index 0396326d9..afdd9e6c2 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -8,6 +8,8 @@ {# Store the current object id so we can access it easier #} + {% url 'get-action-needed-email-for-user-json' as url %} + {% for fieldset in adminform %} {% comment %} TODO: this will eventually need to be changed to something like this diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index e03203f4b..f6ada5166 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -66,24 +66,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) No changelog to display. {% endif %} - {% elif field.field.name == "action_needed_reason_email" %} -
- - -
{% elif field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}
@@ -137,6 +119,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} {% endwith %}
+ {% elif field.field.name == "display_admins" or field.field.name == "domain_managers" or field.field.namd == "invited_domain_managers" %} +
{{ field.contents|safe }}
+ {% elif field.field.name == "display_members" %} +
+ {% if display_members_summary %} + {{ display_members_summary }} + {% else %} +

No additional members found.

+ {% endif %} +
{% else %}
{{ field.contents }}
{% endif %} @@ -145,131 +137,102 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% block field_other %} {% if field.field.name == "action_needed_reason_email" %} -
-
- - + +
+ –
-
-