Merge branch 'main' into hotgov/2493-basic-org-view

This commit is contained in:
zandercymatics 2024-08-14 08:12:24 -06:00
commit 3263602a16
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
41 changed files with 778 additions and 172 deletions

View file

@ -493,6 +493,8 @@ class CustomLogEntryAdmin(LogEntryAdmin):
# return super().change_view(request, object_id, form_url, extra_context=extra_context)
# TODO #2571 - this should be refactored. This is shared among every class that inherits this,
# and it breaks the senior_official field because it exists both as model "Contact" and "SeniorOfficial".
class AdminSortFields:
_name_sort = ["first_name", "last_name", "email"]
@ -555,15 +557,16 @@ class AuditedAdmin(admin.ModelAdmin):
)
)
def formfield_for_manytomany(self, db_field, request, **kwargs):
def formfield_for_manytomany(self, db_field, request, use_admin_sort_fields=True, **kwargs):
"""customize the behavior of formfields with manytomany relationships. the customized
behavior includes sorting of objects in lists as well as customizing helper text"""
# Define a queryset. Note that in the super of this,
# a new queryset will only be generated if one does not exist.
# Thus, the order in which we define queryset matters.
queryset = AdminSortFields.get_queryset(db_field)
if queryset:
if queryset and use_admin_sort_fields:
kwargs["queryset"] = queryset
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
@ -574,7 +577,7 @@ class AuditedAdmin(admin.ModelAdmin):
)
return formfield
def formfield_for_foreignkey(self, db_field, request, **kwargs):
def formfield_for_foreignkey(self, db_field, request, use_admin_sort_fields=True, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
@ -582,7 +585,7 @@ class AuditedAdmin(admin.ModelAdmin):
# a new queryset will only be generated if one does not exist.
# Thus, the order in which we define queryset matters.
queryset = AdminSortFields.get_queryset(db_field)
if queryset:
if queryset and use_admin_sort_fields:
kwargs["queryset"] = queryset
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@ -1544,6 +1547,17 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
# TODO #2571
# Remove this check on senior_official if this underlying model changes from
# "Contact" to "SeniorOfficial" or if we refactor AdminSortFields.
# Removing this will cause the list on django admin to return SeniorOffical
# objects rather than Contact objects.
use_sort = db_field.name != "senior_official"
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
class DomainRequestResource(FsmModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -2211,6 +2225,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return None
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
# TODO #2571
# Remove this check on senior_official if this underlying model changes from
# "Contact" to "SeniorOfficial" or if we refactor AdminSortFields.
# Removing this will cause the list on django admin to return SeniorOffical
# objects rather than Contact objects.
use_sort = db_field.name != "senior_official"
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class."""
@ -2260,6 +2285,7 @@ class DomainInformationInline(admin.StackedInline):
def formfield_for_manytomany(self, db_field, request, **kwargs):
"""customize the behavior of formfields with manytomany relationships. the customized
behavior includes sorting of objects in lists as well as customizing helper text"""
queryset = AdminSortFields.get_queryset(db_field)
if queryset:
kwargs["queryset"] = queryset
@ -2274,8 +2300,12 @@ class DomainInformationInline(admin.StackedInline):
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
# Remove this check on senior_official if this underlying model changes from
# "Contact" to "SeniorOfficial" or if we refactor AdminSortFields.
# Removing this will cause the list on django admin to return SeniorOffical
# objects rather than Contact objects.
queryset = AdminSortFields.get_queryset(db_field)
if queryset:
if queryset and db_field.name != "senior_official":
kwargs["queryset"] = queryset
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@ -2848,6 +2878,7 @@ class PortfolioAdmin(ListHeaderAdmin):
autocomplete_fields = [
"creator",
"federal_agency",
"senior_official",
]
def change_view(self, request, object_id, form_url="", extra_context=None):

View file

@ -1985,3 +1985,122 @@ document.addEventListener('DOMContentLoaded', function() {
showInputOnErrorFields();
})();
/**
* An IIFE that changes the default clear behavior on comboboxes to the input field.
* We want the search bar to act soley as a search bar.
*/
(function loadInitialValuesForComboBoxes() {
var overrideDefaultClearButton = true;
var isTyping = false;
document.addEventListener('DOMContentLoaded', (event) => {
handleAllComboBoxElements();
});
function handleAllComboBoxElements() {
const comboBoxElements = document.querySelectorAll(".usa-combo-box");
comboBoxElements.forEach(comboBox => {
const input = comboBox.querySelector("input");
const select = comboBox.querySelector("select");
if (!input || !select) {
console.warn("No combobox element found");
return;
}
// Set the initial value of the combobox
let initialValue = select.getAttribute("data-default-value");
let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
if (!clearInputButton) {
console.warn("No clear element found");
return;
}
// Override the default clear button behavior such that it no longer clears the input,
// it just resets to the data-initial-value.
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = document.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
// Configure the observer to watch for changes in the dropdown list
const config = { childList: true, subtree: true };
observer.observe(dropdownList, config);
// Input event listener to detect typing
input.addEventListener("input", () => {
isTyping = true;
});
// Blur event listener to reset typing state
input.addEventListener("blur", () => {
isTyping = false;
});
// Hide the reset button when there is nothing to reset.
// Do this once on init, then everytime a change occurs.
updateClearButtonVisibility(select, initialValue, clearInputButton)
select.addEventListener("change", () => {
updateClearButtonVisibility(select, initialValue, clearInputButton)
});
// Change the default input behaviour - have it reset to the data default instead
clearInputButton.addEventListener("click", (e) => {
if (overrideDefaultClearButton && initialValue) {
e.preventDefault();
e.stopPropagation();
input.click();
// Find the dropdown option with the desired value
const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
if (dropdownOptions) {
dropdownOptions.forEach(option => {
if (option.getAttribute("data-value") === initialValue) {
// Simulate a click event on the dropdown option
option.click();
}
});
}
}
});
});
}
function updateClearButtonVisibility(select, initialValue, clearInputButton) {
if (select.value === initialValue) {
hideElement(clearInputButton);
}else {
showElement(clearInputButton)
}
}
function addBlankOption(clearInputButton, dropdownList, initialValue) {
if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
const blankOption = document.createElement("li");
blankOption.setAttribute("role", "option");
blankOption.setAttribute("data-value", "");
blankOption.classList.add("usa-combo-box__list-option");
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "---------";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
overrideDefaultClearButton = false;
// Trigger the default clear behavior
clearInputButton.click();
overrideDefaultClearButton = true;
});
}
}
})();

View file

@ -25,7 +25,7 @@
}
}
h3.register-form-review-header {
.register-form-review-header {
color: color('primary-dark');
margin-top: units(2);
margin-bottom: 0;

View file

@ -79,6 +79,11 @@ urlpatterns = [
views.PortfolioOrganizationView.as_view(),
name="organization",
),
path(
"senior-official/",
views.PortfolioSeniorOfficialView.as_view(),
name="senior-official",
),
path(
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
@ -197,6 +202,11 @@ urlpatterns = [
views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address",
),
path(
"domain/<int:pk>/suborganization",
views.DomainSubOrganizationView.as_view(),
name="domain-suborganization",
),
path(
"domain/<int:pk>/senior-official",
views.DomainSeniorOfficialView.as_view(),

View file

@ -9,6 +9,7 @@ from .domain import (
DomainDnssecForm,
DomainDsdataFormset,
DomainDsdataForm,
DomainSuborganizationForm,
)
from .portfolio import (
PortfolioOrgAddressForm,

View file

@ -6,6 +6,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator, RegexVa
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.suborganization import Suborganization
from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import (
NameserverError,
@ -153,6 +154,42 @@ class DomainNameserverForm(forms.Form):
self.add_error("ip", str(e))
class DomainSuborganizationForm(forms.ModelForm):
"""Form for updating the suborganization"""
sub_organization = forms.ModelChoiceField(
queryset=Suborganization.objects.none(),
required=False,
widget=forms.Select(),
)
class Meta:
model = DomainInformation
fields = [
"sub_organization",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
portfolio = self.instance.portfolio if self.instance else None
self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio)
# Set initial value
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].initial = self.instance.sub_organization
# Set custom form label
self.fields["sub_organization"].label = "Suborganization name"
# Use the combobox rather than the regular select widget
self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html"
# Set data-default-value attribute
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk
class BaseNameserverFormset(forms.BaseFormSet):
def clean(self):
"""
@ -321,10 +358,14 @@ class SeniorOfficialContactForm(ContactForm):
"""Form for updating senior official contacts."""
JOIN = "senior_official"
full_name = forms.CharField(label="Full name", required=False)
def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.id:
self.fields["full_name"].initial = self.instance.get_formatted_name()
# Overriding bc phone not required in this form
self.fields["phone"] = forms.IntegerField(required=False)
@ -347,6 +388,12 @@ class SeniorOfficialContactForm(ContactForm):
if disable_fields:
DomainHelper.mass_disable_fields(fields=self.fields, disable_required=True, disable_maxlength=True)
def clean(self):
"""Clean override to remove unused fields"""
cleaned_data = super().clean()
cleaned_data.pop("full_name", None)
return cleaned_data
def save(self, commit=True):
"""
Override the save() method of the BaseModelForm.

View file

@ -4,7 +4,7 @@ import logging
from django import forms
from django.core.validators import RegexValidator
from ..models import DomainInformation, Portfolio
from ..models import DomainInformation, Portfolio, SeniorOfficial
logger = logging.getLogger(__name__)
@ -67,3 +67,31 @@ class PortfolioOrgAddressForm(forms.ModelForm):
self.fields[field_name].required = True
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
class PortfolioSeniorOfficialForm(forms.ModelForm):
"""
Form for updating the portfolio senior official.
This form is readonly for now.
"""
JOIN = "senior_official"
full_name = forms.CharField(label="Full name", required=False)
class Meta:
model = SeniorOfficial
fields = [
"title",
"email",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.id:
self.fields["full_name"].initial = self.instance.get_formatted_name()
def clean(self):
"""Clean override to remove unused fields"""
cleaned_data = super().clean()
cleaned_data.pop("full_name", None)
return cleaned_data

View file

@ -35,7 +35,6 @@ class Command(BaseCommand):
Note:
- If the row is missing SO data - it will not be added.
- Given we can add the row, any blank first_name will be replaced with "-".
""", # noqa: W291
prompt_title="Do you wish to load records into the SeniorOfficial table?",
)
@ -64,7 +63,11 @@ class Command(BaseCommand):
# Clean the returned data
for key, value in so_kwargs.items():
if isinstance(value, str):
so_kwargs[key] = value.strip()
clean_string = value.strip()
if clean_string:
so_kwargs[key] = clean_string
else:
so_kwargs[key] = None
# Handle the federal_agency record seperately (db call)
agency_name = row.get("Agency").strip() if row.get("Agency") else None
@ -95,17 +98,11 @@ class Command(BaseCommand):
def create_senior_official(self, so_kwargs):
"""Creates a senior official object from kwargs but does not add it to the DB"""
# WORKAROUND: Placeholder value for first name,
# as not having these makes it impossible to access through DJA.
old_first_name = so_kwargs["first_name"]
if not so_kwargs["first_name"]:
so_kwargs["first_name"] = "-"
# Create a new SeniorOfficial object
new_so = SeniorOfficial(**so_kwargs)
# Store a variable for the console logger
if all([old_first_name, new_so.last_name]):
if all([new_so.first_name, new_so.last_name]):
record_display = new_so
else:
record_display = so_kwargs

View file

@ -0,0 +1,66 @@
# Generated by Django 4.2.10 on 2024-08-08 14:14
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0116_federalagency_initials_federalagency_is_fceb_and_more"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="portfolio_additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="user",
name="portfolio_additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
]

View file

@ -3,6 +3,7 @@ import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
from django.forms import ValidationError
from registrar.models.domain_information import DomainInformation
from registrar.models.user_domain_role import UserDomainRole
@ -74,12 +75,17 @@ class User(AbstractUser):
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@ -224,6 +230,16 @@ class User(AbstractUser):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
if self.portfolio is None and self._get_portfolio_permissions():
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
if self.portfolio is not None and not self._get_portfolio_permissions():
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
@ -270,6 +286,13 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
# Field specific permission checks
def has_view_suborganization(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
def has_edit_suborganization(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification

View file

@ -26,3 +26,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
# Domain: field specific permissions
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"

View file

@ -0,0 +1,14 @@
{% comment %}
This is a custom widget for USWDS's comboboxes.
USWDS comboboxes are basically just selects with a "usa-combo-box" div wrapper.
We can further customize these by applying attributes to this parent element,
for now we just carry the attribute to both the parent element and the select.
{% endcomment %}
<div class="usa-combo-box"
{% for name, value in widget.attrs.items %}
{{ name }}="{{ value }}"
{% endfor %}
>
{% include "django/forms/widgets/select.html" %}
</div>

View file

@ -54,7 +54,7 @@
{% block domain_content %}
<h1 class="break-word">{{ domain.name }}</h1>
<h1 class="break-word">Domain Overview</h1>
{% endblock %} {# domain_content #}
{% endif %}

View file

@ -1,10 +1,11 @@
{% extends "domain_base.html" %}
{% load static url_helpers %}
{% load custom_filters %}
{% block domain_content %}
{{ block.super }}
<div class="margin-top-4 tablet:grid-col-10">
<h2 class="text-bold text-primary-dark">{{ domain.name }}</h2>
<div
class="usa-summary-box dotgov-status-box padding-bottom-0 margin-top-3 padding-left-2{% if not domain.is_expired %}{% if domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} dotgov-status-box--action-need{% endif %}{% endif %}"
role="region"
@ -56,19 +57,24 @@
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %}
{% else %}
{% if is_editable %}
<h2 class="margin-top-3"> DNS name servers </h2>
<h3 class="margin-top-3"> DNS name servers </h3>
<p> No DNS name servers have been added yet. Before your domain can be used well need information about your domain name servers.</p>
<a class="usa-button margin-bottom-1" href="{{url}}"> Add DNS name servers </a>
{% else %}
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=is_editable %}
{% endif %}
{% endif %}
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% if domain.dnssecdata is not None %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
{% endif %}
{% if portfolio %}
{% comment %} TODO - uncomment in #2352 and add to edit_link
{% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% endcomment %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link="#" editable=is_editable %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}

View file

@ -4,45 +4,9 @@
{% block title %}Senior official | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %}
<h1>Senior official</h1>
<p>Your senior official is a person within your organization who can
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-a-senior-official-within-your-organization' %}">who can serve as a senior official</a>.</p>
{% if generic_org_type == "federal" or generic_org_type == "tribal" %}
<p>
The senior official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% include "includes/senior_official.html" with can_edit=False include_read_more_text=True %}
{% else %}
{% include "includes/required_fields.html" %}
{% include "includes/senior_official.html" with can_edit=True include_read_more_text=True %}
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% if generic_org_type == "federal" or generic_org_type == "tribal" %}
{# If all fields are disabled, add SR content #}
<div class="usa-sr-only" aria-labelledby="id_first_name" id="sr-so-first-name">{{ form.first_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_last_name" id="sr-so-last-name">{{ form.last_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_title" id="sr-so-title">{{ form.title.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_email" id="sr-so-email">{{ form.email.value }}</div>
{% endif %}
{% input_with_errors form.first_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% input_with_errors form.email %}
{% if generic_org_type != "federal" and generic_org_type != "tribal" %}
<button type="submit" class="usa-button">Save</button>
{% endif %}
</form>
{% endblock %} {# domain_content #}
{% endblock %}

View file

@ -60,9 +60,12 @@
{% if portfolio %}
{% with url="#" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %}
{% with url_name="domain-suborganization" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}
{% endif %}
{% else %}
{% with url_name="domain-senior-official" %}
{% include "includes/domain_sidenav_item.html" with item_text="Senior official" %}

View file

@ -0,0 +1,29 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Suborganization{% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %}
<h1>Suborganization</h1>
<p>
The name of your suborganization will be publicly listed as the domain registrant.
This list of suborganizations has been populated the .gov program.
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% if has_domains_portfolio_permission and request.user.has_edit_suborganization %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.sub_organization %}
<button type="submit" class="usa-button">Save</button>
</form>
{% else %}
{% with description="The suborganization for this domain can only be updated by a organization administrator."%}
{% include "includes/input_read_only.html" with field=form.sub_organization value=suborganization_name label_description=description%}
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -47,5 +47,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -30,5 +30,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -31,5 +31,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -32,5 +32,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -38,5 +38,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -26,5 +26,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -49,5 +49,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -77,5 +77,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -38,5 +38,5 @@ The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -60,5 +60,5 @@ The .gov team
Domain management <https://manage.get.gov>
Get.gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -152,7 +152,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if portfolio %}
{% if portfolio and request.user.has_view_suborganization %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th

View file

@ -4,4 +4,11 @@ Template include for read-only form fields
<h4 class="read-only-label">{{ field.label }}</h4>
<p class="read-only-value">{{ field.value }}</p>
{% if label_description %}
<p class="usa-hint margin-top-0 margin-bottom-05">{{ label_description }}</p>
{% endif %}
{% comment %}
This allows us to customize the displayed value.
For instance, Select fields will display the id by default.
{% endcomment %}
<p class="read-only-value">{{ value|default:field.value }}</p>

View file

@ -0,0 +1,49 @@
{% load static field_helpers url_helpers %}
{% if can_edit %}
{% include "includes/form_errors.html" with form=form %}
{% endif %}
<h1>Senior Official</h1>
<p>
Your senior official is a person within your organization who can authorize domain requests.
{% if include_read_more_text %}
This person must be in a role of significant, executive responsibility within the organization.
Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-a-senior-official-within-your-organization' %}">who can serve as a senior official</a>.
{% endif %}
</p>
{% if can_edit %}
{% include "includes/required_fields.html" %}
{% else %}
<p>
The senior official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% endif %}
{% if can_edit %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% input_with_errors form.email %}
<button type="submit" class="usa-button">Save</button>
</form>
{% elif not form.full_name.value and not form.title.value and not form.email.value %}
<h4>No senior official was found.</h4>
{% else %}
{% if form.full_name.value is not None %}
{% include "includes/input_read_only.html" with field=form.full_name %}
{% endif %}
{% if form.title.value is not None %}
{% include "includes/input_read_only.html" with field=form.title %}
{% endif %}
{% if form.email.value is not None %}
{% include "includes/input_read_only.html" with field=form.email %}
{% endif %}
{% endif %}

View file

@ -7,7 +7,7 @@
{% if heading_level %}
<{{ heading_level }}
{% else %}
<h2
<h3
{% endif %}
class="summary-item__title
font-sans-md
@ -19,10 +19,10 @@
{% if heading_level %}
</{{ heading_level }}>
{% else %}
</h2>
</h3>
{% endif %}
{% if sub_header_text %}
<h3 class="register-form-review-header">{{ sub_header_text }}</h3>
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
{% endif %}
{% if address %}
{% include "includes/organization_address.html" with organization=value %}

View file

@ -13,7 +13,9 @@
</li>
<li class="usa-sidenav__item">
<a href="#"
{% url 'senior-official' as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Senior official
</a>

View file

@ -0,0 +1,24 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %}
{% load static %}
{% block portfolio_content %}
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
<p class="font-body-md margin-top-0 margin-bottom-2
text-primary-darker text-semibold"
>
<span class="usa-sr-only"> Portfolio name:</span> {{ portfolio }}
</p>
{% include 'portfolio_organization_sidebar.html' %}
</div>
<div class="tablet:grid-col-9">
{% include "includes/senior_official.html" with can_edit=False %}
</div>
</div>
{% endblock %}

View file

@ -150,3 +150,12 @@ def format_phone(value):
@register.filter
def in_path(url, path):
return url in path
@register.filter(name="and")
def and_filter(value, arg):
"""
Implements logical AND operation in templates.
Usage: {{ value|and:arg }}
"""
return bool(value and arg)

View file

@ -1,3 +1,4 @@
from django.forms import ValidationError
from django.test import TestCase
from django.db.utils import IntegrityError
from django.db import transaction
@ -1348,6 +1349,7 @@ class TestUser(TestCase):
self.user.phone = None
self.assertFalse(self.user.has_contact_info())
@less_console_noise_decorator
def test_has_portfolio_permission(self):
"""
0. Returns False when user does not have a permission
@ -1401,6 +1403,37 @@ class TestUser(TestCase):
Portfolio.objects.all().delete()
@less_console_noise_decorator
def test_user_with_portfolio_but_no_roles(self):
# Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio = portfolio
self.user.portfolio_roles = []
# Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm:
self.user.clean()
self.assertEqual(
cm.exception.message, "When portfolio is assigned, portfolio roles or additional permissions are required."
)
Portfolio.objects.all().delete()
@less_console_noise_decorator
def test_user_with_portfolio_roles_but_no_portfolio(self):
# Create an instance of User with a portfolio role but no portfolio
self.user.portfolio = None
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
# Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm:
self.user.clean()
self.assertEqual(
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
)
class TestContact(TestCase):
@less_console_noise_decorator

View file

@ -1128,7 +1128,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
def test_domain_senior_official(self):
"""Can load domain's senior official page."""
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Senior official", count=14)
self.assertContains(page, "Senior official", count=3)
@less_console_noise_decorator
def test_domain_senior_official_content(self):
@ -1192,14 +1192,14 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.assertTrue("disabled" in form[field_name].attrs)
@less_console_noise_decorator
def test_domain_edit_senior_official_federal(self):
def test_domain_cannot_edit_senior_official_when_federal(self):
"""Tests that no edit can occur when the underlying domain is federal"""
# Set the org type to federal
self.domain_information.generic_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.save()
# Add an SO. We can do this at the model level, just not the form level.
# Add an SO
self.domain_information.senior_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
)
@ -1207,49 +1207,13 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.save()
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
so_form = so_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(so_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
so_form["first_name"] = "Orange"
so_form["last_name"] = "Smoothie"
so_form["title"] = "Cat"
so_form["email"] = "somebody@igorville.gov"
submission = so_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.senior_official.first_name)
self.assertEqual("Tester", self.domain_information.senior_official.last_name)
self.assertEqual("CIO", self.domain_information.senior_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email)
self.assertContains(so_page, "Apple Tester")
self.assertContains(so_page, "CIO")
self.assertContains(so_page, "nobody@igorville.gov")
self.assertNotContains(so_page, "Save")
@less_console_noise_decorator
def test_domain_edit_senior_official_tribal(self):
def test_domain_cannot_edit_senior_official_tribal(self):
"""Tests that no edit can occur when the underlying domain is tribal"""
# Set the org type to federal
@ -1264,46 +1228,10 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.save()
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
so_form = so_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(so_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
so_form["first_name"] = "Orange"
so_form["last_name"] = "Smoothie"
so_form["title"] = "Cat"
so_form["email"] = "somebody@igorville.gov"
submission = so_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.senior_official.first_name)
self.assertEqual("Tester", self.domain_information.senior_official.last_name)
self.assertEqual("CIO", self.domain_information.senior_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email)
self.assertContains(so_page, "Apple Tester")
self.assertContains(so_page, "CIO")
self.assertContains(so_page, "nobody@igorville.gov")
self.assertNotContains(so_page, "Save")
@less_console_noise_decorator
def test_domain_edit_senior_official_creates_new(self):
@ -1526,6 +1454,110 @@ class TestDomainOrganization(TestDomainOverview):
class TestDomainSuborganization(TestDomainOverview):
"""Tests the Suborganization page for portfolio users"""
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_edit_suborganization_field(self):
"""Ensure that org admins can edit the suborganization field"""
# Create a portfolio and two suborgs
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
suborg = Suborganization.objects.create(portfolio=portfolio, name="Vanilla")
suborg_2 = Suborganization.objects.create(portfolio=portfolio, name="Chocolate")
# Create an unrelated portfolio
unrelated_portfolio = Portfolio.objects.create(creator=self.user, organization_name="Fruit")
unrelated_suborg = Suborganization.objects.create(portfolio=unrelated_portfolio, name="Apple")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = portfolio
self.domain_information.sub_organization = suborg
# Add a organization_name to test if the old value still displays
self.domain_information.organization_name = "Broccoli"
self.domain_information.save()
self.domain_information.refresh_from_db()
# Add portfolio perms to the user object
self.user.portfolio = portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
# The page should contain the choices Vanilla and Chocolate
self.assertContains(page, "Vanilla")
self.assertContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
# Assert that the right option is selected. This component uses data-default-value.
self.assertContains(page, f'data-default-value="{suborg.id}"')
# Try changing the suborg
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
page.form["sub_organization"] = suborg_2.id
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = page.form.submit().follow()
# The page should contain the choices Vanilla and Chocolate
self.assertContains(page, "Vanilla")
self.assertContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
# Assert that the right option is selected
self.assertContains(page, f'data-default-value="{suborg_2.id}"')
self.domain_information.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg_2)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_view_suborganization_field(self):
"""Only org admins can edit the suborg field, ensure that others cannot"""
# Create a portfolio and two suborgs
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
suborg = Suborganization.objects.create(portfolio=portfolio, name="Vanilla")
Suborganization.objects.create(portfolio=portfolio, name="Chocolate")
# Create an unrelated portfolio
unrelated_portfolio = Portfolio.objects.create(creator=self.user, organization_name="Fruit")
unrelated_suborg = Suborganization.objects.create(portfolio=unrelated_portfolio, name="Apple")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = portfolio
self.domain_information.sub_organization = suborg
# Add a organization_name to test if the old value still displays
self.domain_information.organization_name = "Broccoli"
self.domain_information.save()
self.domain_information.refresh_from_db()
# Add portfolio perms to the user object
self.user.portfolio = portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
self.user.save()
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
# The page should display the readonly option
self.assertContains(page, "Vanilla")
# The page shouldn't contain these choices
self.assertNotContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
self.assertNotContains(page, "Save")
self.assertContains(
page, "The suborganization for this domain can only be updated by a organization administrator."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_has_suborganization_field_on_overview_with_flag(self):

View file

@ -1,7 +1,7 @@
from django.urls import reverse
from api.tests.common import less_console_noise_decorator
from registrar.config import settings
from registrar.models.portfolio import Portfolio
from registrar.models import Portfolio, SeniorOfficial
from django_webtest import WebTest # type: ignore
from registrar.models import (
DomainRequest,
@ -38,6 +38,36 @@ class TestPortfolio(WebTest):
User.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_portfolio_senior_official(self):
"""Tests that the senior official page on portfolio contains the content we expect"""
self.app.set_user(self.user.username)
so = SeniorOfficial.objects.create(
first_name="Saturn", last_name="Enceladus", title="Planet/Moon", email="spacedivision@igorville.com"
)
self.portfolio.senior_official = so
self.portfolio.save()
self.portfolio.refresh_from_db()
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
so_portfolio_page = self.app.get(reverse("senior-official"))
# Assert that we're on the right page
self.assertContains(so_portfolio_page, "Senior official")
self.assertContains(so_portfolio_page, "Saturn Enceladus")
self.assertContains(so_portfolio_page, "Planet/Moon")
self.assertContains(so_portfolio_page, "spacedivision@igorville.com")
self.assertNotContains(so_portfolio_page, "Save")
self.portfolio.delete()
so.delete()
@less_console_noise_decorator
def test_middleware_does_not_redirect_if_no_permission(self):
"""Test that user with no portfolio permission is not redirected when attempting to access home"""

View file

@ -3,6 +3,7 @@ from .domain import (
DomainView,
DomainSeniorOfficialView,
DomainOrgNameAddressView,
DomainSubOrganizationView,
DomainDNSView,
DomainNameserversView,
DomainDNSSECView,

View file

@ -15,7 +15,7 @@ from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic.edit import FormMixin
from django.conf import settings
from registrar.forms.domain import DomainSuborganizationForm
from registrar.models import (
Domain,
DomainRequest,
@ -242,6 +242,51 @@ class DomainOrgNameAddressView(DomainFormBaseView):
return super().has_permission()
class DomainSubOrganizationView(DomainFormBaseView):
"""Suborganization view"""
model = Domain
template_name = "domain_suborganization.html"
context_object_name = "domain"
form_class = DomainSuborganizationForm
def has_permission(self):
"""Override for the has_permission class to exclude non-portfolio users"""
# non-org users shouldn't have access to this page
is_org_user = self.request.user.is_org_user(self.request)
if self.request.user.portfolio and is_org_user:
return super().has_permission()
else:
return False
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
if self.object and self.object.domain_info and self.object.domain_info.sub_organization:
context["suborganization_name"] = self.object.domain_info.sub_organization.name
return context
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.organization_name instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.object.domain_info
return form_kwargs
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-suborganization", kwargs={"pk": self.object.pk})
def form_valid(self, form):
"""The form is valid, save the organization name and mailing address."""
form.save()
messages.success(self.request, "The suborganization name for this domain has been updated.")
# superclass has the redirect
return super().form_valid(form)
class DomainSeniorOfficialView(DomainFormBaseView):
"""Domain senior official editing view."""

View file

@ -3,7 +3,7 @@ from django.http import Http404
from django.shortcuts import render
from django.urls import reverse
from django.contrib import messages
from registrar.forms.portfolio import PortfolioOrgAddressForm
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
from registrar.models import Portfolio, User
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.views.utility.permission_views import (
@ -125,3 +125,34 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
def get_success_url(self):
"""Redirect to the overview page for the portfolio."""
return reverse("organization")
class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin):
"""
View to handle displaying and updating the portfolio's senior official details.
For now, this view is readonly.
"""
model = Portfolio
template_name = "portfolio_senior_official.html"
form_class = PortfolioSeniorOfficialForm
context_object_name = "portfolio"
def get_object(self, queryset=None):
"""Get the portfolio object based on the request user."""
portfolio = self.request.user.portfolio
if portfolio is None:
raise Http404("No organization found for this user")
return portfolio
def get_form_kwargs(self):
"""Include the instance in the form kwargs."""
kwargs = super().get_form_kwargs()
kwargs["instance"] = self.get_object().senior_official
return kwargs
def get(self, request, *args, **kwargs):
"""Handle GET requests to display the form."""
self.object = self.get_object()
form = self.get_form()
return self.render_to_response(self.get_context_data(form=form))

View file

@ -71,6 +71,7 @@
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/suborganization/
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers