mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-23 19:20:47 +02:00
Merge branch 'main' into hotgov/2493-basic-org-view
This commit is contained in:
commit
3263602a16
41 changed files with 778 additions and 172 deletions
|
@ -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):
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
h3.register-form-review-header {
|
||||
.register-form-review-header {
|
||||
color: color('primary-dark');
|
||||
margin-top: units(2);
|
||||
margin-bottom: 0;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -9,6 +9,7 @@ from .domain import (
|
|||
DomainDnssecForm,
|
||||
DomainDsdataFormset,
|
||||
DomainDsdataForm,
|
||||
DomainSuborganizationForm,
|
||||
)
|
||||
from .portfolio import (
|
||||
PortfolioOrgAddressForm,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
14
src/registrar/templates/django/forms/widgets/combobox.html
Normal file
14
src/registrar/templates/django/forms/widgets/combobox.html
Normal 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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 we’ll 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 %}
|
||||
|
|
|
@ -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 can’t 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 %}
|
||||
|
|
|
@ -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" %}
|
||||
|
|
29
src/registrar/templates/domain_suborganization.html
Normal file
29
src/registrar/templates/domain_suborganization.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
49
src/registrar/templates/includes/senior_official.html
Normal file
49
src/registrar/templates/includes/senior_official.html
Normal 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 can’t 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
24
src/registrar/templates/portfolio_senior_official.html
Normal file
24
src/registrar/templates/portfolio_senior_official.html
Normal 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 %}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -3,6 +3,7 @@ from .domain import (
|
|||
DomainView,
|
||||
DomainSeniorOfficialView,
|
||||
DomainOrgNameAddressView,
|
||||
DomainSubOrganizationView,
|
||||
DomainDNSView,
|
||||
DomainNameserversView,
|
||||
DomainDNSSECView,
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue