Merge remote-tracking branch 'origin/main' into ms/2451-additional-domain-request-dates

This commit is contained in:
Matthew Spence 2024-08-21 12:25:59 -05:00
commit eac757a6a6
No known key found for this signature in database
23 changed files with 626 additions and 96 deletions

View file

@ -30,8 +30,9 @@ Welcome to the .gov team! We're excited to have you here. Please follow the step
- [ ] Review our [design tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.aprurp3z4gmv).
- [ ] Accept invitation to our [Figma workspace](https://www.figma.com/files/1287135731043703282/team/1299882813146449644).
- [ ] Follow the steps in [preparing for your sandbox](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.au66hq5e0l8s) section on the onboarding doc.
- [ ] Schedule coffee chats with Design Lead, other designers, scrum master, and product manager ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
- [ ] Schedule coffee chats with Design Leads and other teammates ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
- [ ] Look over [recommended reading](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.7ox9ee7v5q5n) and [relevant links](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.d9pac1gc751t).
- [ ] Fill out the [Emails](https://docs.google.com/spreadsheets/d/1a6wj8I7FzWGP1AyIhAwP7yL84mXzORao8D_Q3c1xs-g/edit?gid=1637270167#gid=1637270167) tab of the Team OOO spreadsheet. To create a scheduling link, follow these instructions: [Outlook](https://learn.microsoft.com/en-us/microsoft-365/bookings/create-new-meeting-type?view=o365-worldwide) or [Google Calendar](https://support.google.com/calendar/answer/10729749?hl=en)
- [ ] Fill out your own Personal Operating Manual (POM) on the [team norming board](https://miro.com/app/board/uXjVMxMu1SA=/). OPTIONAL: Present it on the next team coffee meeting.
- [ ] FOR FEDERAL EMPLOYEES: Check in with your manager on the CISA onboarding process and getting your PIV card.
- [ ] FOR CONTRACTORS: Check with your manager on your EOD clearance process.

View file

@ -9,8 +9,7 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_group import DomainGroup
from registrar.models.suborganization import Suborganization
from registrar.models.domain_information import DomainInformation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@ -23,6 +22,7 @@ from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@ -39,7 +39,7 @@ from import_export import resources
from import_export.admin import ImportExportModelAdmin
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
@ -2866,16 +2866,112 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
class PortfolioAdmin(ListHeaderAdmin):
change_form_template = "django/admin/portfolio_change_form.html"
fieldsets = [
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
# TODO - uncomment in #2521
# ("Portfolio members", {
# "classes": ("collapse", "closed"),
# "fields": ["administrators", "members"]}
# ),
("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
"Organization name and mailing address",
{
"fields": [
"federal_agency",
"state_territory",
"address_line1",
"address_line2",
"city",
"zipcode",
"urbanization",
]
},
),
("Suborganizations", {"fields": ["suborganizations"]}),
("Senior official", {"fields": ["senior_official"]}),
]
# This is the fieldset display when adding a new model
add_fieldsets = [
(None, {"fields": ["organization_name", "creator", "notes"]}),
("Type of organization", {"fields": ["organization_type"]}),
(
"Organization name and mailing address",
{
"fields": [
"federal_agency",
"state_territory",
"address_line1",
"address_line2",
"city",
"zipcode",
"urbanization",
]
},
),
("Senior official", {"fields": ["senior_official"]}),
]
list_display = ("organization_name", "federal_agency", "creator")
search_fields = ["organization_name"]
search_help_text = "Search by organization name."
readonly_fields = [
"creator",
# This is the created_at field
"created_on",
# Custom fields such as these must be defined as readonly.
"federal_type",
"domains",
"domain_requests",
"suborganizations",
"portfolio_type",
]
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
federal_type.short_description = "Federal type" # type: ignore
def created_on(self, obj: models.Portfolio):
"""Returns the created_at field, with a different short description"""
# Format: Dec 12, 2024
return obj.created_at.strftime("%b %d, %Y") if obj.created_at else "-"
created_on.short_description = "Created on" # type: ignore
def portfolio_type(self, obj: models.Portfolio):
"""Returns the portfolio type, or "-" if the result is empty"""
return obj.portfolio_type if obj.portfolio_type else "-"
portfolio_type.short_description = "Portfolio type" # type: ignore
def suborganizations(self, obj: models.Portfolio):
"""Returns a list of links for each related suborg"""
queryset = obj.get_suborganizations()
return self.get_field_links_as_list(queryset, "suborganization")
suborganizations.short_description = "Suborganizations" # type: ignore
def domains(self, obj: models.Portfolio):
"""Returns a list of links for each related domain"""
queryset = obj.get_domains()
return self.get_field_links_as_list(
queryset, "domaininformation", link_info_attribute="get_state_display_of_domain"
)
domains.short_description = "Domains" # type: ignore
def domain_requests(self, obj: models.Portfolio):
"""Returns a list of links for each related domain request"""
queryset = obj.get_domain_requests()
return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display")
domain_requests.short_description = "Domain requests" # type: ignore
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
@ -2883,17 +2979,91 @@ class PortfolioAdmin(ListHeaderAdmin):
"senior_official",
]
def get_field_links_as_list(
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
):
"""
Generate HTML links for items in a queryset, using a specified attribute for link text.
Args:
queryset: The queryset of items to generate links for.
model_name: The model name used to construct the admin change URL.
attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
separator: The separator to use between links in the resulting HTML.
If none, an unordered list is returned.
Returns:
A formatted HTML string with links to the admin change pages for each item.
"""
links = []
for item in queryset:
# This allows you to pass in attribute_name="get_full_name" for instance.
if attribute_name:
item_display_value = self.value_of_attribute(item, attribute_name)
else:
item_display_value = item
if item_display_value:
change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
link = f'<a href="{change_url}">{escape(item_display_value)}</a>'
if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
if seperator:
links.append(link)
else:
links.append(f"<li>{link}</li>")
# If no seperator is specified, just return an unordered list.
if seperator:
return format_html(seperator.join(links)) if links else "-"
else:
links = "".join(links)
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
def value_of_attribute(self, obj, attribute_name: str):
"""Returns the value of getattr if the attribute isn't callable.
If it is, execute the underlying function and return that result instead."""
value = getattr(obj, attribute_name)
if callable(value):
value = value()
return value
def get_fieldsets(self, request, obj=None):
"""Override of the default get_fieldsets definition to add an add_fieldsets view"""
# This is the add view if no obj exists
if not obj:
return self.add_fieldsets
return super().get_fieldsets(request, obj)
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 2 conditions that determine which fields are read-only:
admin user permissions and the creator's status, so
we'll use the baseline readonly_fields and extend it as needed.
"""
readonly_fields = list(self.readonly_fields)
# Check if the creator is restricted
if obj and obj.creator.status == models.User.RESTRICTED:
# For fields like CharField, IntegerField, etc., the widget used is
# straightforward and the readonly_fields list can control their behavior
readonly_fields.extend([field.name for field in self.model._meta.fields])
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups"""
obj = self.get_object(request, object_id)
# ---- Domain Groups
domain_groups = DomainGroup.objects.filter(portfolio=obj)
# ---- Suborganizations
suborganizations = Suborganization.objects.filter(portfolio=obj)
extra_context = {"domain_groups": domain_groups, "suborganizations": suborganizations}
extra_context = {"skip_additional_contact_info": True}
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
@ -2975,12 +3145,32 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
change_form_template = "django/admin/suborg_change_form.html"
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add suborg's related domains and requests to context"""
obj = self.get_object(request, object_id)
# ---- Domain Requests
domain_requests = DomainRequest.objects.filter(sub_organization=obj)
sort_by = request.GET.get("sort_by", "requested_domain__name")
domain_requests = domain_requests.order_by(sort_by)
# ---- Domains
domain_infos = DomainInformation.objects.filter(sub_organization=obj)
domain_ids = domain_infos.values_list("domain", flat=True)
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
extra_context = {"domain_requests": domain_requests, "domains": domains}
return super().change_view(request, object_id, form_url, extra_context)
admin.site.unregister(LogEntry) # Unregister the default registration

View file

@ -765,6 +765,11 @@ function initializeWidgetOnList(list, parentId) {
*/
(function dynamicPortfolioFields(){
// the federal agency change listener fires on page load, which we don't want.
var isInitialPageLoad = true
// This is the additional information that exists beneath the SO element.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
@ -776,9 +781,6 @@ function initializeWidgetOnList(list, parentId) {
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
if ($federalAgency && organizationType) {
// Execute this function once on load
handleFederalAgencyChange($federalAgency, organizationType);
// Attach the change event listener
$federalAgency.on("change", function() {
handleFederalAgencyChange($federalAgency, organizationType);
@ -800,6 +802,12 @@ function initializeWidgetOnList(list, parentId) {
});
function handleFederalAgencyChange(federalAgency, organizationType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
return;
}
// Set the org type to federal if an agency is selected
let selectedText = federalAgency.find("option:selected").text();
@ -825,6 +833,10 @@ function initializeWidgetOnList(list, parentId) {
return;
}
// Hide the contactList initially.
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
@ -843,6 +855,10 @@ function initializeWidgetOnList(list, parentId) {
return;
}
// Update the "contact details" blurb beneath senior official
updateContactInfo(data);
showElement(contactList.parentElement);
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
@ -873,4 +889,35 @@ function initializeWidgetOnList(list, parentId) {
hideElement(urbanizationField)
}
}
function updateContactInfo(data) {
if (!contactList) return;
const titleSpan = contactList.querySelector("#contact_info_title");
const emailSpan = contactList.querySelector("#contact_info_email");
const phoneSpan = contactList.querySelector("#contact_info_phone");
if (titleSpan) {
titleSpan.textContent = data.title || "None";
};
// Update the email field and the content for the clipboard
if (emailSpan) {
let copyButton = contactList.querySelector(".admin-icon-group");
emailSpan.textContent = data.email || "None";
if (data.email) {
const clipboardInput = contactList.querySelector(".admin-icon-group input");
if (clipboardInput) {
clipboardInput.value = data.email;
};
showElement(copyButton);
}else {
hideElement(copyButton);
}
}
if (phoneSpan) {
phoneSpan.textContent = data.phone || "None";
};
}
})();

View file

@ -847,3 +847,8 @@ div.dja__model-description{
}
}
}
ul.add-list-reset {
padding: 0 !important;
margin: 0 !important;
}

View file

@ -356,9 +356,18 @@ CSP_FORM_ACTION = allowed_sources
# strict CSP by allowing scripts to run from their domain
# and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
CSP_DEFAULT_SRC = ("'self'",)
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
CSP_SCRIPT_SRC_ELEM = [
"'self'",
"https://www.googletagmanager.com/",
"https://cdn.jsdelivr.net/npm/chart.js",
"https://www.ssa.gov",
"https://ajax.googleapis.com",
]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
CSP_IMG_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/icons/"]
# Cross-Origin Resource Sharing (CORS) configuration
# Sets clients that allow access control to manage.get.gov

View file

@ -22,6 +22,12 @@ class UserFixture:
"""
ADMINS = [
{
"username": "43a7fa8d-0550-4494-a6fe-81500324d590",
"first_name": "Jyoti",
"last_name": "Bock",
"email": "jyotibock@truss.works",
},
{
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi",
@ -125,6 +131,12 @@ class UserFixture:
]
STAFF = [
{
"username": "a5906815-dd80-4c64-aebe-2da6a4c9d7a4",
"first_name": "Jyoti-Analyst",
"last_name": "Bock-Analyst",
"email": "jyotibock+1@truss.works",
},
{
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst",

View file

@ -679,7 +679,7 @@ class CisaRepresentativeYesNoForm(BaseYesNoForm):
field_name = "has_cisa_representative"
class AdditionalDetailsForm(BaseDeletableRegistrarForm):
class AnythingElseForm(BaseDeletableRegistrarForm):
anything_else = forms.CharField(
required=True,
label="Anything else?",
@ -698,7 +698,7 @@ class AdditionalDetailsForm(BaseDeletableRegistrarForm):
)
class AdditionalDetailsYesNoForm(BaseYesNoForm):
class AnythingElseYesNoForm(BaseYesNoForm):
"""Yes/no toggle for the anything else question on additional details"""
# Note that these can be set as functions/init if you need more fine-grained control.

View file

@ -58,17 +58,17 @@ class Command(BaseCommand):
reports = {
"Domain report": {
"report_filename": f"domain-metadata-{self.current_date}.csv",
"report_function": csv_export.export_data_type_to_csv,
"report_function": csv_export.DomainDataType.export_data_to_csv,
},
"Domain request report": {
"report_filename": f"domain-request-metadata-{self.current_date}.csv",
"report_function": csv_export.DomainRequestExport.export_full_domain_request_report,
"report_function": csv_export.DomainRequestDataFull.export_data_to_csv,
},
}
# Set the password equal to our content in SECRET_ENCRYPT_METADATA.
# For local development, this will be "devpwd" unless otherwise set.
# Uncomment these lines if you want to use this:
# Uncomment these lines (and comment out the line after) if you want to use this:
# override = settings.SECRET_ENCRYPT_METADATA is None and not settings.IS_PRODUCTION
# password = "devpwd" if override else settings.SECRET_ENCRYPT_METADATA
password = settings.SECRET_ENCRYPT_METADATA

View file

@ -0,0 +1,50 @@
# Generated by Django 4.2.10 on 2024-08-15 15:32
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0117_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="portfolio",
options={"ordering": ["organization_name"]},
),
migrations.AlterField(
model_name="portfolio",
name="creator",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="created_portfolios",
to=settings.AUTH_USER_MODEL,
verbose_name="Portfolio creator",
),
),
migrations.AlterField(
model_name="portfolio",
name="organization_name",
field=models.CharField(blank=True, null=True, verbose_name="Portfolio organization"),
),
migrations.AlterField(
model_name="portfolio",
name="senior_official",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="registrar.seniorofficial"
),
),
migrations.AlterField(
model_name="suborganization",
name="portfolio",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="portfolio_suborganizations",
to="registrar.portfolio",
),
),
]

View file

@ -296,23 +296,29 @@ class DomainInformation(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save().
"""
# Check if the firstname or lastname of cisa representative has any data.
# Then set the has_cisa_representative flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
)
# This check is required to ensure that the form doesn't start out checked
# Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
# Check if anything_else has any data.
# Then set the has_anything_else_text flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != ""
# This check is required to ensure that the form doesn't start out checked.
# Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
@ -424,3 +430,10 @@ class DomainInformation(TimeStampedModel):
def _get_many_to_many_fields():
"""Returns a set of each field.name that has the many to many relation"""
return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore
def get_state_display_of_domain(self):
"""Returns the state display of the underlying domain record"""
if self.domain:
return self.domain.get_state_display()
else:
return None

View file

@ -665,23 +665,29 @@ class DomainRequest(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save().
"""
# Check if the firstname or lastname of cisa representative has any data.
# Then set the has_cisa_representative flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
)
# This check is required to ensure that the form doesn't start out checked
# Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
# Check if anything_else has any data.
# Then set the has_anything_else_text flag accordingly (so that it isn't
# "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != ""
# This check is required to ensure that the form doesn't start out checked.
# Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None

View file

@ -2,6 +2,7 @@ from django.db import models
from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency
from registrar.utility.constants import BranchChoices
from .utility.time_stamped_model import TimeStampedModel
@ -12,6 +13,10 @@ class Portfolio(TimeStampedModel):
manageable groups.
"""
# Addresses the UnorderedObjectListWarning
class Meta:
ordering = ["organization_name"]
# use the short names in Django admin
OrganizationChoices = DomainRequest.OrganizationChoices
StateTerritoryChoices = DomainRequest.StateTerritoryChoices
@ -21,11 +26,25 @@ class Portfolio(TimeStampedModel):
creator = models.ForeignKey(
"registrar.User",
on_delete=models.PROTECT,
help_text="Associated user",
verbose_name="Portfolio creator",
related_name="created_portfolios",
unique=False,
)
organization_name = models.CharField(
null=True,
blank=True,
verbose_name="Portfolio organization",
)
organization_type = models.CharField(
max_length=255,
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of organization",
)
notes = models.TextField(
null=True,
blank=True,
@ -42,25 +61,11 @@ class Portfolio(TimeStampedModel):
senior_official = models.ForeignKey(
"registrar.SeniorOfficial",
on_delete=models.PROTECT,
help_text="Associated senior official",
unique=False,
null=True,
blank=True,
)
organization_type = models.CharField(
max_length=255,
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of organization",
)
organization_name = models.CharField(
null=True,
blank=True,
)
address_line1 = models.CharField(
null=True,
blank=True,
@ -109,7 +114,7 @@ class Portfolio(TimeStampedModel):
)
def __str__(self) -> str:
return f"{self.organization_name}"
return str(self.organization_name)
def save(self, *args, **kwargs):
"""Save override for custom properties"""
@ -119,3 +124,35 @@ class Portfolio(TimeStampedModel):
self.urbanization = None
super().save(*args, **kwargs)
@property
def portfolio_type(self):
"""
Returns a combination of organization_type / federal_type, seperated by ' - '.
If no federal_type is found, we just return the org type.
"""
org_type_label = self.OrganizationChoices.get_org_label(self.organization_type)
agency_type_label = BranchChoices.get_branch_label(self.federal_type)
if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label:
return " - ".join([org_type_label, agency_type_label])
else:
return org_type_label
@property
def federal_type(self):
"""Returns the federal_type value on the underlying federal_agency field"""
return self.federal_agency.federal_type if self.federal_agency else None
# == Getters for domains == #
def get_domains(self):
"""Returns all DomainInformations associated with this portfolio"""
return self.information_portfolio.all()
def get_domain_requests(self):
"""Returns all DomainRequests associated with this portfolio"""
return self.DomainRequest_portfolio.all()
# == Getters for suborganization == #
def get_suborganizations(self):
"""Returns all suborganizations associated with this portfolio"""
return self.portfolio_suborganizations.all()

View file

@ -16,6 +16,7 @@ class Suborganization(TimeStampedModel):
portfolio = models.ForeignKey(
"registrar.Portfolio",
on_delete=models.PROTECT,
related_name="portfolio_suborganizations",
)
def __str__(self) -> str:

View file

@ -1,6 +1,7 @@
{% load i18n static %}
{% load custom_filters %}
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user|has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
{% if show_formatted_name %}
@ -9,10 +10,10 @@
{% else %}
None
{% endif %}
{% endif %}
</br>
{% endif %}
{% if user.has_contact_info %}
{% if user|has_contact_info %}
{# Title #}
{% if user.title %}
<span id="contact_info_title">{{ user.title }}</span>
@ -42,7 +43,7 @@
No additional contact information found.<br>
{% endif %}
{% if user_verification_type %}
{% if user_verification_type and not skip_additional_contact_info %}
<span id="contact_info_phone">{{ user_verification_type }}</span>
{% endif %}
</address>

View file

@ -184,7 +184,9 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<label aria-label="Creator contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
</div>
{% if not skip_additional_contact_info %}
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
{% endif%}
{% elif field.field.name == "submitter" %}
<div class="flex-container tablet:margin-top-2">
<label aria-label="Submitter contact details"></label>

View file

@ -8,34 +8,16 @@
{{ block.super }}
{% endblock content %}
{% block after_related_objects %}
<div class="module aligned padding-3">
<h2>Associated groups and suborganizations</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domain groups</h3>
<ul class="margin-0 padding-0">
{% for domain_group in domain_groups %}
<li>
<a href="{% url 'admin:registrar_domaingroup_change' domain_group.pk %}">
{{ domain_group.name }}
</a>
</li>
{% block field_sets %}
{% for fieldset in adminform %}
{% comment %}
This is a placeholder for now.
Disclaimer:
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
For instance, "portfolio_fieldset.html".
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
{% endfor %}
</ul>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Suborganizations</h3>
<ul class="margin-0 padding-0">
{% for suborg in suborganizations %}
<li>
<a href="{% url 'admin:registrar_suborganization_change' suborg.pk %}">
{{ suborg.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load i18n static %}
{% block after_related_objects %}
<div class="module aligned padding-3">
<h2>Associated requests and domains</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domain requests</h3>
<ul class="margin-0 padding-0">
{% for domain_request in domain_requests %}
<li>
<a href="{% url 'admin:registrar_domainrequest_change' domain_request.pk %}">
{{ domain_request.requested_domain }}
</a>
({{ domain_request.status }})
</li>
{% endfor %}
</ul>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domains</h3>
<ul class="margin-0 padding-0">
{% for domain in domains %}
<li>
<a href="{% url 'admin:registrar_domain_change' domain.pk %}">
{{ domain.name }}
</a>
({{ domain.state }})
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

View file

@ -159,3 +159,13 @@ def and_filter(value, arg):
Usage: {{ value|and:arg }}
"""
return bool(value and arg)
@register.filter(name="has_contact_info")
def has_contact_info(user):
"""Checks if the given object has the attributes: title, email, phone
and checks if at least one of those is not null."""
if not hasattr(user, "title") or not hasattr(user, "email") or not hasattr(user, "phone"):
return False
else:
return bool(user.title or user.email or user.phone)

View file

@ -906,6 +906,7 @@ def completed_domain_request( # noqa
federal_agency=None,
federal_type=None,
action_needed_reason=None,
portfolio=None,
):
"""A completed domain request."""
if not user:
@ -976,6 +977,9 @@ def completed_domain_request( # noqa
if action_needed_reason:
domain_request_kwargs["action_needed_reason"] = action_needed_reason
if portfolio:
domain_request_kwargs["portfolio"] = portfolio
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:

View file

@ -23,6 +23,7 @@ from registrar.admin import (
PublicContactAdmin,
TransitionDomainAdmin,
UserGroupAdmin,
PortfolioAdmin,
)
from registrar.models import (
Domain,
@ -38,6 +39,8 @@ from registrar.models import (
FederalAgency,
UserGroup,
TransitionDomain,
Portfolio,
Suborganization,
)
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
@ -2042,3 +2045,79 @@ class TestUserGroup(TestCase):
response, "Groups are a way to bundle admin permissions so they can be easily assigned to multiple users."
)
self.assertContains(response, "Show more")
class TestPortfolioAdmin(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.site = AdminSite()
cls.superuser = create_superuser()
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
cls.factory = RequestFactory()
def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080")
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
def tearDown(self):
Suborganization.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
Portfolio.objects.all().delete()
@less_console_noise_decorator
def test_created_on_display(self):
"""Tests the custom created on which is a reskin of the created_at field"""
created_on = self.admin.created_on(self.portfolio)
expected_date = self.portfolio.created_at.strftime("%b %d, %Y")
self.assertEqual(created_on, expected_date)
@less_console_noise_decorator
def test_suborganizations_display(self):
"""Tests the custom suborg field which displays all related suborgs"""
Suborganization.objects.create(name="Sub1", portfolio=self.portfolio)
Suborganization.objects.create(name="Sub2", portfolio=self.portfolio)
suborganizations = self.admin.suborganizations(self.portfolio)
self.assertIn("Sub1", suborganizations)
self.assertIn("Sub2", suborganizations)
self.assertIn('<ul class="add-list-reset">', suborganizations)
@less_console_noise_decorator
def test_domains_display(self):
"""Tests the custom domains field which displays all related domains"""
request_1 = completed_domain_request(
name="request1.gov", portfolio=self.portfolio, status=DomainRequest.DomainRequestStatus.IN_REVIEW
)
request_2 = completed_domain_request(
name="request2.gov", portfolio=self.portfolio, status=DomainRequest.DomainRequestStatus.IN_REVIEW
)
# Create some domain objects
request_1.approve()
request_2.approve()
domain_1 = DomainInformation.objects.get(domain_request=request_1).domain
domain_1.name = "domain1.gov"
domain_1.save()
domain_2 = DomainInformation.objects.get(domain_request=request_2).domain
domain_2.name = "domain2.gov"
domain_2.save()
domains = self.admin.domains(self.portfolio)
self.assertIn("domain1.gov", domains)
self.assertIn("domain2.gov", domains)
self.assertIn('<ul class="add-list-reset">', domains)
@less_console_noise_decorator
def test_domain_requests_display(self):
"""Tests the custom domains requests field which displays all related requests"""
completed_domain_request(name="request1.gov", portfolio=self.portfolio)
completed_domain_request(name="request2.gov", portfolio=self.portfolio)
domain_requests = self.admin.domain_requests(self.portfolio)
self.assertIn("request1.gov", domain_requests)
self.assertIn("request2.gov", domain_requests)
self.assertIn('<ul class="add-list-reset">', domain_requests)

View file

@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import (
RequirementsForm,
TribalGovernmentForm,
PurposeForm,
AdditionalDetailsForm,
AnythingElseForm,
AboutYourOrganizationForm,
)
from registrar.forms.domain import ContactForm
@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib):
def test_anything_else_form_about_your_organization_character_count_invalid(self):
"""Response must be less than 2000 characters."""
form = AdditionalDetailsForm(
form = AnythingElseForm(
data={
"anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami"
"shankle, drumstick doner chicken landjaeger turkey andouille."

View file

@ -1017,20 +1017,27 @@ class DomainRequestTests(TestWithUser, WebTest):
type_page = intro_result.follow()
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# fill out the organization type section then submit
type_form = type_page.forms[0]
type_form["generic_org_type-generic_org_type"] = "federal"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_result = type_form.submit()
# follow first redirect
# follow first redirect to the next section
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
federal_page = type_result.follow()
# Now on federal type page, click back to the organization type
# we need to fill out the federal section so it stays unlocked
fed_branch_form = federal_page.forms[0]
fed_branch_form["organization_federal-federal_type"] = "executive"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
fed_branch_form.submit()
# Now click back to the organization type
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
new_page = federal_page.click(str(self.TITLES["generic_org_type"]), index=0)
# Should be a link to the organization_federal page
# Should be a link to the organization_federal page since it is now unlocked
self.assertGreater(
len(new_page.html.find_all("a", href="/request/organization_federal/")),
0,
@ -2528,9 +2535,22 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow()
# Go back to SO page and test the dynamic text changed
# Navigate to the org page as that is the step right before senior_official
org_page = election_page.click(str(self.TITLES["organization_contact"]), index=0)
org_contact_form = org_page.forms[0]
org_contact_form["organization_contact-organization_name"] = "Testorg"
org_contact_form["organization_contact-address_line1"] = "address 1"
org_contact_form["organization_contact-address_line2"] = "address 2"
org_contact_form["organization_contact-city"] = "NYC"
org_contact_form["organization_contact-state_territory"] = "NY"
org_contact_form["organization_contact-zipcode"] = "10002"
org_contact_form["organization_contact-urbanization"] = "URB Royal Oaks"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = election_page.click(str(self.TITLES["senior_official"]), index=0)
org_contact_result = org_contact_form.submit()
# Navigate back to the so page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = org_contact_result.follow()
self.assertContains(so_page, "Domain requests from cities")
@less_console_noise_decorator
@ -2628,9 +2648,15 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_websites = election_page.click(str(self.TITLES["current_sites"]), index=0)
current_sites_form = current_websites.forms[0]
current_sites_form["current_sites-0-website"] = "www.city.com"
current_sites_result = current_sites_form.submit().follow()
# Go back to dotgov domain page to test the dynamic text changed
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
dotgov_page = election_page.click(str(self.TITLES["dotgov_domain"]), index=0)
dotgov_page = current_sites_result.click(str(self.TITLES["dotgov_domain"]), index=0)
self.assertContains(dotgov_page, "CityofEudoraKS.gov")
self.assertNotContains(dotgov_page, "medicare.gov")
@ -2984,6 +3010,9 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
"""Test when all fields in the domain request are filled."""
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user)
domain_request.anything_else = False
domain_request.has_anything_else_text = False
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps

View file

@ -217,7 +217,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
self.storage["domain_request_id"] = kwargs["id"]
self.storage["step_history"] = self.db_check_for_unlocking_steps()
# if accessing this class directly, redirect to either to an acknowledgement
# page or to the first step in the processes (if an edit rather than a new request);
@ -233,6 +232,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
else:
return self.goto(self.steps.first)
# refresh step_history to ensure we don't erroneously unlock unfinished
# steps just because we visited it
self.storage["step_history"] = self.db_check_for_unlocking_steps()
context = self.get_context_data()
self.steps.current = current_url
context["forms"] = self.get_forms()
@ -341,6 +343,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"""Helper for get_context_data
Queries the DB for a domain request and returns a list of unlocked steps."""
# The way this works is as follows:
# Each step is assigned a true/false value to determine if it is
# "unlocked" or not. This dictionary of values is looped through
# at the end of this function and any step with a "true" value is
# added to a simple array that is returned at the end of this function.
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
# and is used to determine how steps appear in the side nav.
# It is worth noting that any step assigned "false" here will be EXCLUDED
# from the list of "unlocked" steps.
history_dict = {
"generic_org_type": self.domain_request.generic_org_type is not None,
"tribal_government": self.domain_request.tribe_name is not None,
@ -368,8 +381,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
or self.domain_request.no_other_contacts_rationale is not None
),
"additional_details": (
(self.domain_request.anything_else is not None and self.domain_request.has_cisa_representative)
or self.domain_request.is_policy_acknowledged is not None
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
),
"requirements": self.domain_request.is_policy_acknowledged is not None,
"review": self.domain_request.is_policy_acknowledged is not None,
@ -626,8 +642,8 @@ class AdditionalDetails(DomainRequestWizard):
forms = [
forms.CisaRepresentativeYesNoForm,
forms.CisaRepresentativeForm,
forms.AdditionalDetailsYesNoForm,
forms.AdditionalDetailsForm,
forms.AnythingElseYesNoForm,
forms.AnythingElseForm,
]
def is_valid(self, forms: list) -> bool: