merge main

This commit is contained in:
Rachid Mrad 2024-12-30 15:07:37 -05:00
commit 9cd759dac8
No known key found for this signature in database
43 changed files with 1344 additions and 215 deletions

View file

@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi" name = "pypi"
[packages] [packages]
django = "4.2.10" django = "4.2.17"
cfenv = "*" cfenv = "*"
django-cors-headers = "*" django-cors-headers = "*"
pycryptodomex = "*" pycryptodomex = "*"

9
src/Pipfile.lock generated
View file

@ -1789,11 +1789,12 @@
}, },
"waitress": { "waitress": {
"hashes": [ "hashes": [
"sha256:005da479b04134cdd9dd602d1ee7c49d79de0537610d653674cc6cbde222b8a1", "sha256:26cdbc593093a15119351690752c99adc13cbc6786d75f7b6341d1234a3730ac",
"sha256:2a06f242f4ba0cc563444ca3d1998959447477363a2d7e9b8b4d75d35cfd1669" "sha256:ef0c1f020d9f12a515c4ec65c07920a702613afcad1dbfdc3bcec256b6c072b3"
], ],
"markers": "python_full_version >= '3.8.0'", "index": "pypi",
"version": "==3.0.0" "markers": "python_full_version >= '3.9.0'",
"version": "==3.0.1"
}, },
"webob": { "webob": {
"hashes": [ "hashes": [

View file

@ -22,5 +22,8 @@
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"webpack": "^5.96.1", "webpack": "^5.96.1",
"webpack-stream": "^7.0.0" "webpack-stream": "^7.0.0"
},
"overrides": {
"semver": "^7.5.3"
} }
} }

View file

@ -1830,10 +1830,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
form = DomainRequestAdminForm form = DomainRequestAdminForm
change_form_template = "django/admin/domain_request_change_form.html" change_form_template = "django/admin/domain_request_change_form.html"
# ------ Filters ------
# Define custom filters
class StatusListFilter(MultipleChoiceListFilter): class StatusListFilter(MultipleChoiceListFilter):
"""Custom status filter which is a multiple choice filter""" """Custom status filter which is a multiple choice filter"""
title = "Status" title = "status"
parameter_name = "status__in" parameter_name = "status__in"
template = "django/admin/multiple_choice_list_filter.html" template = "django/admin/multiple_choice_list_filter.html"
@ -1877,7 +1879,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
If we have a portfolio, use the portfolio's federal type. If not, use the If we have a portfolio, use the portfolio's federal type. If not, use the
organization in the Domain Request object.""" organization in the Domain Request object."""
title = "federal Type" title = "federal type"
parameter_name = "converted_federal_types" parameter_name = "converted_federal_types"
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
@ -1965,13 +1967,58 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if self.value() == "0": if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
class PortfolioFilter(admin.SimpleListFilter):
"""Define a custom filter for portfolio"""
title = _("portfolio")
parameter_name = "portfolio__isnull"
def lookups(self, request, model_admin):
return (
("1", _("Yes")),
("0", _("No")),
)
def queryset(self, request, queryset):
if self.value() == "1":
return queryset.filter(Q(portfolio__isnull=False))
if self.value() == "0":
return queryset.filter(Q(portfolio__isnull=True))
# ------ Custom fields ------
def custom_election_board(self, obj):
return "Yes" if obj.is_election_board else "No"
custom_election_board.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore
@admin.display(description=_("Requested Domain"))
def custom_requested_domain(self, obj):
# Example: Show different icons based on `status`
url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}"
text = obj.requested_domain
if obj.portfolio:
return format_html('<a href="{}"><img src="/public/admin/img/icon-yes.svg"> {}</a>', url, text)
return format_html('<a href="{}">{}</a>', url, text)
custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore
# ------ Converted fields ------
# These fields map to @Property methods and
# require these custom definitions to work properly
@admin.display(description=_("Generic Org Type")) @admin.display(description=_("Generic Org Type"))
def converted_generic_org_type(self, obj): def converted_generic_org_type(self, obj):
return obj.converted_generic_org_type_display return obj.converted_generic_org_type_display
@admin.display(description=_("Organization Name")) @admin.display(description=_("Organization Name"))
def converted_organization_name(self, obj): def converted_organization_name(self, obj):
return obj.converted_organization_name # Example: Show different icons based on `status`
if obj.portfolio:
url = reverse("admin:registrar_portfolio_change", args=[obj.portfolio.id])
text = obj.converted_organization_name
return format_html('<a href="{}">{}</a>', url, text)
else:
return obj.converted_organization_name
@admin.display(description=_("Federal Agency")) @admin.display(description=_("Federal Agency"))
def converted_federal_agency(self, obj): def converted_federal_agency(self, obj):
@ -1989,34 +2036,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def converted_state_territory(self, obj): def converted_state_territory(self, obj):
return obj.converted_state_territory return obj.converted_state_territory
# Columns # ------ Portfolio fields ------
list_display = [
"requested_domain",
"first_submitted_date",
"last_submitted_date",
"last_status_update",
"status",
"custom_election_board",
"converted_generic_org_type",
"converted_organization_name",
"converted_federal_agency",
"converted_federal_type",
"converted_city",
"converted_state_territory",
"investigator",
]
orderable_fk_fields = [
("requested_domain", "name"),
("investigator", ["first_name", "last_name"]),
]
def custom_election_board(self, obj):
return "Yes" if obj.is_election_board else "No"
custom_election_board.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore
# Define methods to display fields from the related portfolio # Define methods to display fields from the related portfolio
def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]:
return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None
@ -2086,10 +2106,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def status_history(self, obj): def status_history(self, obj):
return "No changelog to display." return "No changelog to display."
status_history.short_description = "Status History" # type: ignore status_history.short_description = "Status history" # type: ignore
# Columns
list_display = [
"custom_requested_domain",
"first_submitted_date",
"last_submitted_date",
"last_status_update",
"status",
"custom_election_board",
"converted_generic_org_type",
"converted_organization_name",
"converted_federal_agency",
"converted_federal_type",
"converted_city",
"converted_state_territory",
"investigator",
]
orderable_fk_fields = [
("requested_domain", "name"),
("investigator", ["first_name", "last_name"]),
]
# Filters # Filters
list_filter = ( list_filter = (
PortfolioFilter,
StatusListFilter, StatusListFilter,
GenericOrgFilter, GenericOrgFilter,
FederalTypeFilter, FederalTypeFilter,
@ -2099,13 +2142,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
) )
# Search # Search
# NOTE: converted fields are included in the override for get_search_results
search_fields = [ search_fields = [
"requested_domain__name", "requested_domain__name",
"creator__email", "creator__email",
"creator__first_name", "creator__first_name",
"creator__last_name", "creator__last_name",
] ]
search_help_text = "Search by domain or creator." search_help_text = "Search by domain, creator, or organization name."
fieldsets = [ fieldsets = [
( (
@ -2271,9 +2315,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"cisa_representative_first_name", "cisa_representative_first_name",
"cisa_representative_last_name", "cisa_representative_last_name",
"cisa_representative_email", "cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
] ]
autocomplete_fields = [ autocomplete_fields = [
@ -2692,6 +2733,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
qs = qs.filter(portfolio=portfolio_id) qs = qs.filter(portfolio=portfolio_id)
return qs return qs
def get_search_results(self, request, queryset, search_term):
# Call the parent's method to apply default search logic
base_queryset, use_distinct = super().get_search_results(request, queryset, search_term)
# Add custom search logic for the annotated field
if search_term:
annotated_queryset = queryset.filter(
# converted_organization_name
Q(portfolio__organization_name__icontains=search_term)
| Q(portfolio__isnull=True, organization_name__icontains=search_term)
)
# Combine the two querysets using union
combined_queryset = base_queryset | annotated_queryset
else:
combined_queryset = base_queryset
return combined_queryset, use_distinct
class TransitionDomainAdmin(ListHeaderAdmin): class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class.""" """Custom transition domain admin class."""
@ -3746,9 +3806,9 @@ class PortfolioAdmin(ListHeaderAdmin):
"senior_official", "senior_official",
] ]
analyst_readonly_fields = [ # Even though this is empty, I will leave it as a stub for easy changes in the future
"organization_name", # rather than strip it out of our logic.
] analyst_readonly_fields = [] # type: ignore
def get_admin_users(self, obj): def get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio # Filter UserPortfolioPermission objects related to the portfolio

View file

@ -10,8 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js'; import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js'; import { initMemberDomainsTable } from './table-member-domains.js';
import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
initDomainValidators(); initDomainValidators();
@ -21,13 +20,6 @@ nameserversFormListener();
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
initializeUrbanizationToggle(); initializeUrbanizationToggle();
@ -44,5 +36,7 @@ initMembersTable();
initMemberDomainsTable(); initMemberDomainsTable();
initEditMemberDomainsTable(); initEditMemberDomainsTable();
initPortfolioMemberPageToggle(); // Init the portfolio new member page
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners(); initAddNewMemberPageListeners();

View file

@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js';
import { getCsrfToken } from './helpers.js'; import { getCsrfToken } from './helpers.js';
import { generateKebabHTML } from './table-base.js'; import { generateKebabHTML } from './table-base.js';
import { MembersTable } from './table-members.js'; import { MembersTable } from './table-members.js';
import { hookupRadioTogglerListener } from './radios.js';
// This is specifically for the Member Profile (Manage Member) Page member/invitation removal // This is specifically for the Member Profile (Manage Member) Page member/invitation removal
export function initPortfolioMemberPageToggle() { export function initPortfolioNewMemberPageToggle() {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const wrapperDeleteAction = document.getElementById("wrapper-delete-action") const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
if (wrapperDeleteAction) { if (wrapperDeleteAction) {
@ -169,4 +170,29 @@ export function initAddNewMemberPageListeners() {
} }
} }
} }
// Initalize the radio for the member pages
export function initPortfolioMemberPageRadio() {
document.addEventListener("DOMContentLoaded", () => {
let memberForm = document.getElementById("member_form");
let newMemberForm = document.getElementById("add_member_form")
if (memberForm) {
hookupRadioTogglerListener(
'role',
{
'organization_admin': 'member-admin-permissions',
'organization_member': 'member-basic-permissions'
}
);
}else if (newMemberForm){
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
}
});
}

View file

@ -38,21 +38,21 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme
**/ **/
export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
// Get the radio buttons // Get the radio buttons
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`);
// Extract the list of all element IDs from the valueToElementMap // Extract the list of all element IDs from the valueToElementMap
let allElementIds = Object.values(valueToElementMap); let allElementIds = Object.values(valueToElementMap);
function handleRadioButtonChange() { function handleRadioButtonChange() {
// Find the checked radio button // Find the checked radio button
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`);
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
// Hide all elements by default // Hide all elements by default
allElementIds.forEach(function (elementId) { allElementIds.forEach(function (elementId) {
let element = document.getElementById(elementId); let element = document.getElementById(elementId);
if (element) { if (element) {
hideElement(element); hideElement(element);
} }
}); });
@ -64,8 +64,8 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
} }
} }
} }
if (radioButtons.length) { if (radioButtons && radioButtons.length) {
// Add event listener to each radio button // Add event listener to each radio button
radioButtons.forEach(function (radioButton) { radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange); radioButton.addEventListener('change', handleRadioButtonChange);

View file

@ -31,6 +31,9 @@ export class DomainsTable extends BaseTable {
</td> </td>
` `
} }
const isExpiring = domain.state_display === "Expiring soon"
const iconType = isExpiring ? "error_outline" : "info_outline";
const iconColor = isExpiring ? "text-secondary-vivid" : "text-accent-cool"
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
${domain.name} ${domain.name}
@ -41,14 +44,14 @@ export class DomainsTable extends BaseTable {
<td data-label="Status"> <td data-label="Status">
${domain.state_display} ${domain.state_display}
<svg <svg
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help" class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 ${iconColor} no-click-outline-and-cursor-help"
data-position="top" data-position="top"
title="${domain.get_state_help_text}" title="${domain.get_state_help_text}"
focusable="true" focusable="true"
aria-label="${domain.get_state_help_text}" aria-label="${domain.get_state_help_text}"
role="tooltip" role="tooltip"
> >
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#${iconType}"></use>
</svg> </svg>
</td> </td>
${markupForSuborganizationRow} ${markupForSuborganizationRow}
@ -77,3 +80,30 @@ export function initDomainsTable() {
} }
}); });
} }
// For clicking the "Expiring" checkbox
document.addEventListener('DOMContentLoaded', () => {
const expiringLink = document.getElementById('link-expiring-domains');
if (expiringLink) {
// Grab the selection for the status filter by
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
expiringLink.addEventListener('click', (event) => {
event.preventDefault();
// Loop through all statuses
statusCheckboxes.forEach(checkbox => {
// To find the for checkbox for "Expiring soon"
if (checkbox.value === "expiring") {
// If the checkbox is not already checked, check it
if (!checkbox.checked) {
checkbox.checked = true;
// Do the checkbox action
let event = new Event('change');
checkbox.dispatchEvent(event)
}
}
});
});
}
});

View file

@ -149,6 +149,11 @@ footer {
color: color('primary'); color: color('primary');
} }
.usa-radio {
margin-top: 1rem;
font-size: 1.06rem;
}
abbr[title] { abbr[title] {
// workaround for underlining abbr element // workaround for underlining abbr element
border-bottom: none; border-bottom: none;

View file

@ -42,6 +42,9 @@ h2 {
.usa-form, .usa-form,
.usa-form fieldset { .usa-form fieldset {
font-size: 1rem; font-size: 1rem;
.usa-legend {
font-size: 1rem;
}
} }
.p--blockquote { .p--blockquote {

View file

@ -69,9 +69,19 @@ def portfolio_permissions(request):
"has_organization_requests_flag": False, "has_organization_requests_flag": False,
"has_organization_members_flag": False, "has_organization_members_flag": False,
"is_portfolio_admin": False, "is_portfolio_admin": False,
"has_domain_renewal_flag": False,
} }
try: try:
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
# These feature flags will display and doesn't depend on portfolio
portfolio_context.update(
{
"has_organization_feature_flag": True,
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
}
)
# Linting: line too long # Linting: line too long
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio) view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio) edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
@ -90,6 +100,7 @@ def portfolio_permissions(request):
"has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_requests_flag": request.user.has_organization_requests_flag(),
"has_organization_members_flag": request.user.has_organization_members_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(),
"is_portfolio_admin": request.user.is_portfolio_admin(portfolio), "is_portfolio_admin": request.user.is_portfolio_admin(portfolio),
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
} }
return portfolio_context return portfolio_context

View file

@ -151,6 +151,27 @@ class UserFixture:
"email": "skey@truss.works", "email": "skey@truss.works",
"title": "Designer", "title": "Designer",
}, },
{
"username": "f20b7a53-f40d-48f8-8c12-f42f35eede92",
"first_name": "Kimberly",
"last_name": "Aralar",
"email": "kimberly.aralar@gsa.gov",
"title": "Designer",
},
{
"username": "4aa78480-6272-42f9-ac29-a034ebdd9231",
"first_name": "Kaitlin",
"last_name": "Abbitt",
"email": "kaitlin.abbitt@cisa.dhs.gov",
"title": "Product Manager",
},
{
"username": "5e54fd98-6c11-4cb3-82b6-93ed8be50a61",
"first_name": "Gina",
"last_name": "Summers",
"email": "gina.summers@ecstech.com",
"title": "Scrum Master",
},
] ]
STAFF = [ STAFF = [
@ -257,6 +278,18 @@ class UserFixture:
"last_name": "Key-Analyst", "last_name": "Key-Analyst",
"email": "skey+1@truss.works", "email": "skey+1@truss.works",
}, },
{
"username": "cf2b32fe-280d-4bc0-96c2-99eec09ba4da",
"first_name": "Kimberly-Analyst",
"last_name": "Aralar-Analyst",
"email": "kimberly.aralar+1@gsa.gov",
},
{
"username": "80db923e-ac64-4128-9b6f-e54b2174a09b",
"first_name": "Kaitlin-Analyst",
"last_name": "Abbitt-Analyst",
"email": "kaitlin.abbitt@gwe.cisa.dhs.gov",
},
] ]
# Additional emails to add to the AllowedEmail whitelist. # Additional emails to add to the AllowedEmail whitelist.

View file

@ -530,7 +530,7 @@ class PurposeForm(RegistrarForm):
widget=forms.Textarea( widget=forms.Textarea(
attrs={ attrs={
"aria-label": "What is the purpose of your requested domain? Describe how youll use your .gov domain. \ "aria-label": "What is the purpose of your requested domain? Describe how youll use your .gov domain. \
Will it be used for a website, email, or something else? You can enter up to 2000 characters." Will it be used for a website, email, or something else?"
} }
), ),
validators=[ validators=[
@ -736,7 +736,13 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm):
required=True, required=True,
# label has to end in a space to get the label_suffix to show # label has to end in a space to get the label_suffix to show
label=("No other employees rationale"), label=("No other employees rationale"),
widget=forms.Textarea(), widget=forms.Textarea(
attrs={
"aria-label": "You dont need to provide names of other employees now, \
but it may slow down our assessment of your eligibility. Describe \
why there are no other employees who can help verify your request."
}
),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
1000, 1000,
@ -784,7 +790,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm):
anything_else = forms.CharField( anything_else = forms.CharField(
required=True, required=True,
label="Anything else?", label="Anything else?",
widget=forms.Textarea(), widget=forms.Textarea(
attrs={
"aria-label": "Is there anything else youd like us to know about your domain request? \
Provide details below. You can enter up to 2000 characters"
}
),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
2000, 2000,

View file

@ -4,6 +4,7 @@ import logging
from django import forms from django import forms
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.models import ( from registrar.models import (
PortfolioInvitation, PortfolioInvitation,
@ -271,3 +272,210 @@ class NewMemberForm(forms.ModelForm):
if admin_member_error in self.errors: if admin_member_error in self.errors:
del self.errors[admin_member_error] del self.errors[admin_member_error]
return cleaned_data return cleaned_data
class BasePortfolioMemberForm(forms.Form):
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
required_star = '<abbr class="usa-hint usa-hint--required" title="required">*</abbr>'
role = forms.ChoiceField(
choices=[
# Uses .value because the choice has a different label (on /admin)
(UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"),
(UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"),
],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Member access level is required",
},
)
domain_request_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin domain request permission is required",
},
)
member_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"),
(UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin member permission is required",
},
)
domain_request_permission_member = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
("no_access", "No access"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Basic member permission is required",
},
)
# Tracks what form elements are required for a given role choice.
# All of the fields included here have "required=False" by default as they are conditionally required.
# see def clean() for more details.
ROLE_REQUIRED_FIELDS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
"domain_request_permission_admin",
"member_permission_admin",
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
"domain_request_permission_member",
],
}
def __init__(self, *args, instance=None, **kwargs):
"""Initialize self.instance, self.initial, and descriptions under each radio button.
Uses map_instance_to_initial to set the initial dictionary."""
super().__init__(*args, **kwargs)
if instance:
self.instance = instance
self.initial = self.map_instance_to_initial(self.instance)
# Adds a <p> description beneath each role option
self.fields["role"].descriptions = {
"organization_admin": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_ADMIN
),
"organization_member": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
),
}
def save(self):
"""Saves self.instance by grabbing data from self.cleaned_data.
Uses map_cleaned_data_to_instance.
"""
self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance)
self.instance.save()
return self.instance
def clean(self):
"""Validates form data based on selected role and its required fields."""
cleaned_data = super().clean()
role = cleaned_data.get("role")
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
# Helpful error for if this breaks
if field_name not in self.fields:
raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.")
if not cleaned_data.get(field_name):
self.add_error(field_name, self.fields.get(field_name).error_messages.get("required"))
# Edgecase: Member uses a special form value for None called "no_access".
if cleaned_data.get("domain_request_permission_member") == "no_access":
cleaned_data["domain_request_permission_member"] = None
return cleaned_data
# Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work:
# map_instance_to_initial => called on init to set self.initial.
# Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission)
# into a dictionary representation for the form to use automatically.
# map_cleaned_data_to_instance => called on save() to save the instance to the db.
# Takes the self.cleaned_data dict, and converts this dict back to the object.
def map_instance_to_initial(self, instance):
"""
Maps self.instance to self.initial, handling roles and permissions.
Returns form data dictionary with appropriate permission levels based on user role:
{
"role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin,
"domain_request_permission_admin": permission level if admin,
"domain_request_permission_member": permission level if member
}
"""
# Function variables
form_data = {}
perms = UserPortfolioPermission.get_portfolio_permissions(
instance.roles, instance.additional_permissions, get_list=False
)
# Get the available options for roles, domains, and member.
roles = [
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
]
domain_perms = [
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
member_perms = [
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_MEMBERS,
]
# Build form data based on role (which options are available).
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
# and ADMIN takes precedence over MEMBER.
roles = instance.roles or []
selected_role = next((role for role in roles if role in roles), None)
form_data = {"role": selected_role}
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN
if is_admin:
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None)
selected_member_permission = next((perm for perm in member_perms if perm in perms), None)
form_data["domain_request_permission_admin"] = selected_domain_permission
form_data["member_permission_admin"] = selected_member_permission
else:
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
form_data["domain_request_permission_member"] = selected_domain_permission
return form_data
def map_cleaned_data_to_instance(self, cleaned_data, instance):
"""
Maps self.cleaned_data to self.instance, setting roles and permissions.
Args:
cleaned_data (dict): Cleaned data containing role and permission choices
instance: Instance to update
Returns:
instance: Updated instance
"""
role = cleaned_data.get("role")
# Handle roles
instance.roles = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
# Handle EDIT permissions (should be accompanied with a view permission)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
# Only set unique permissions not already defined in the base role
role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False)
instance.additional_permissions = list(additional_permissions - role_permissions)
return instance

View file

@ -2,7 +2,7 @@ from itertools import zip_longest
import logging import logging
import ipaddress import ipaddress
import re import re
from datetime import date from datetime import date, timedelta
from typing import Optional from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
@ -40,6 +40,7 @@ from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact from .public_contact import PublicContact
from .user_domain_role import UserDomainRole from .user_domain_role import UserDomainRole
from waffle.decorators import flag_is_active
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1152,14 +1153,29 @@ class Domain(TimeStampedModel, DomainHelper):
now = timezone.now().date() now = timezone.now().date()
return self.expiration_date < now return self.expiration_date < now
def state_display(self): def is_expiring(self):
"""
Check if the domain's expiration date is within 60 days.
Return True if domain expiration date exists and within 60 days
and otherwise False bc there's no expiration date meaning so not expiring
"""
if self.expiration_date is None:
return False
now = timezone.now().date()
threshold_date = now + timedelta(days=60)
return now < self.expiration_date <= threshold_date
def state_display(self, request=None):
"""Return the display status of the domain.""" """Return the display status of the domain."""
if self.is_expired() and self.state != self.State.UNKNOWN: if self.is_expired() and (self.state != self.State.UNKNOWN):
return "Expired" return "Expired"
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
return "Expiring soon"
elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED: elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED:
return "DNS needed" return "DNS needed"
else: return self.state.capitalize()
return self.state.capitalize()
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type): def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
"""Maps the Epp contact representation to a PublicContact object. """Maps the Epp contact representation to a PublicContact object.

View file

@ -14,6 +14,8 @@ from .domain import Domain
from .domain_request import DomainRequest from .domain_request import DomainRequest
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.utils import timezone
from datetime import timedelta
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -163,6 +165,20 @@ class User(AbstractUser):
active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count() active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count()
return active_requests_count return active_requests_count
def get_num_expiring_domains(self, request):
"""Return number of expiring domains"""
domain_ids = self.get_user_domain_ids(request)
now = timezone.now().date()
expiration_window = 60
threshold_date = now + timedelta(days=expiration_window)
num_of_expiring_domains = Domain.objects.filter(
id__in=domain_ids,
expiration_date__isnull=False,
expiration_date__lte=threshold_date,
expiration_date__gt=now,
).count()
return num_of_expiring_domains
def get_rejected_requests_count(self): def get_rejected_requests_count(self):
"""Return count of rejected requests""" """Return count of rejected requests"""
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count() return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count()
@ -259,6 +275,9 @@ class User(AbstractUser):
def is_portfolio_admin(self, portfolio): def is_portfolio_admin(self, portfolio):
return "Admin" in self.portfolio_role_summary(portfolio) return "Admin" in self.portfolio_role_summary(portfolio)
def has_domain_renewal_flag(self):
return flag_is_active_for_user(self, "domain_renewal")
def get_first_portfolio(self): def get_first_portfolio(self):
permission = self.portfolio_permissions.first() permission = self.portfolio_permissions.first()
if permission: if permission:

View file

@ -110,8 +110,13 @@ class UserPortfolioPermission(TimeStampedModel):
return self.get_portfolio_permissions(self.roles, self.additional_permissions) return self.get_portfolio_permissions(self.roles, self.additional_permissions)
@classmethod @classmethod
def get_portfolio_permissions(cls, roles, additional_permissions): def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True):
"""Class method to return a list of permissions based on roles and addtl permissions""" """Class method to return a list of permissions based on roles and addtl permissions.
Params:
roles => An array of roles
additional_permissions => An array of additional_permissions
get_list => If true, returns a list of perms. If false, returns a set of perms.
"""
# Use a set to avoid duplicate permissions # Use a set to avoid duplicate permissions
portfolio_permissions = set() portfolio_permissions = set()
if roles: if roles:
@ -119,7 +124,7 @@ class UserPortfolioPermission(TimeStampedModel):
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if additional_permissions: if additional_permissions:
portfolio_permissions.update(additional_permissions) portfolio_permissions.update(additional_permissions)
return list(portfolio_permissions) return list(portfolio_permissions) if get_list else portfolio_permissions
@classmethod @classmethod
def get_domain_request_permission_display(cls, roles, additional_permissions): def get_domain_request_permission_display(cls, roles, additional_permissions):

View file

@ -4,6 +4,9 @@ from django.apps import apps
from django.forms import ValidationError from django.forms import ValidationError
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import logging
logger = logging.getLogger(__name__)
class UserPortfolioRoleChoices(models.TextChoices): class UserPortfolioRoleChoices(models.TextChoices):
@ -16,7 +19,28 @@ class UserPortfolioRoleChoices(models.TextChoices):
@classmethod @classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role): def get_user_portfolio_role_label(cls, user_portfolio_role):
return cls(user_portfolio_role).label if user_portfolio_role else None try:
return cls(user_portfolio_role).label if user_portfolio_role else None
except ValueError:
logger.warning(f"Invalid portfolio role: {user_portfolio_role}")
return f"Unknown ({user_portfolio_role})"
@classmethod
def get_role_description(cls, user_portfolio_role):
"""Returns a detailed description for a given role."""
descriptions = {
cls.ORGANIZATION_ADMIN: (
"Grants this member access to the organization-wide information "
"on domains, domain requests, and members. Domain management can be assigned separately."
),
cls.ORGANIZATION_MEMBER: (
"Grants this member access to the organization. They can be given extra permissions to view all "
"organization domain requests and submit domain requests on behalf of the organization. Basic access "
"members cant view all members of an organization or manage them. "
"Domain management can be assigned separately."
),
}
return descriptions.get(user_portfolio_role)
class UserPortfolioPermissionChoices(models.TextChoices): class UserPortfolioPermissionChoices(models.TextChoices):

View file

@ -2,15 +2,25 @@
class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}"
{% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %}
> >
{% if span_for_text %} {% if legend_heading %}
<span>{{ field.label }}</span> <h2 class="{{ legend_classes }}">{{ legend_heading }} </h2>
{% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %}
<p>.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has <a href="https://www.cisa.gov/about/regions" target="_blank">10 regions</a> that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.</p>
{% endif %}
{% else %} {% else %}
{{ field.label }} {% if span_for_text %}
<span>{{ field.label }}</span>
{% else %}
{{ field.label }}
{% endif %}
{% endif %} {% endif %}
{% if widget.attrs.required %} {% if widget.attrs.required %}
<!--Don't add asterisk to one-field forms -->
{% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} {% if field.widget_type == 'radioselect' %}
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
<!--Don't add asterisk to one-field forms -->
{% elif field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %}
{% else %} {% else %}
<abbr class="usa-hint usa-hint--required" title="required">*</abbr> <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
{% endif %} {% endif %}

View file

@ -1,3 +1,5 @@
{% load static custom_filters %}
<div class="{{ uswds_input_class }}"> <div class="{{ uswds_input_class }}">
{% for group, options, index in widget.optgroups %} {% for group, options, index in widget.optgroups %}
{% if group %}<div><label>{{ group }}</label>{% endif %} {% if group %}<div><label>{{ group }}</label>{% endif %}
@ -13,7 +15,17 @@
<label <label
class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}" class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}"
for="{{ option.attrs.id }}" for="{{ option.attrs.id }}"
>{{ option.label }}</label> >
{{ option.label }}
{% comment %} Add a description on each, if available {% endcomment %}
{% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %}
<p class="margin-0 margin-top-1">{{ description }}</p>
{% endif %}
{% endwith %}
{% endif %}
</label>
{% endfor %} {% endfor %}
{% if group %}</div>{% endif %} {% if group %}</div>{% endif %}
{% endfor %} {% endfor %}

View file

@ -35,18 +35,27 @@
Status: Status:
</span> </span>
<span class="text-primary-darker"> <span class="text-primary-darker">
{# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #}
{% if domain.is_expired and domain.state != domain.State.UNKNOWN %} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %}
Expired Expired
{% elif has_domain_renewal_flag and domain.is_expiring %}
Expiring soon
{% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %}
DNS needed DNS needed
{% else %} {% else %}
{{ domain.state|title }} {{ domain.state|title }}
{% endif %} {% endif %}
</span> </span>
{% if domain.get_state_help_text %} {% if domain.get_state_help_text %}
<div class="padding-top-1 text-primary-darker"> <div class="padding-top-1 text-primary-darker">
{{ domain.get_state_help_text }} {% if has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
This domain will expire soon. <a href="/not-available-yet">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain.
{% else %}
{{ domain.get_state_help_text }}
{% endif %}
</div> </div>
{% endif %} {% endif %}
</p> </p>
@ -119,4 +128,4 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -9,15 +9,9 @@
{% block form_fields %} {% block form_fields %}
<fieldset class="usa-fieldset margin-top-2"> <fieldset class="usa-fieldset">
<legend>
<h2>Are you working with a CISA regional representative on your domain request?</h2>
<p>.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has <a href="https://www.cisa.gov/about/regions" target="_blank">10 regions</a> that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.</p>
</legend>
<!-- Toggle --> <!-- Toggle -->
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em> {% with add_class="usa-radio__input--tile" add_legend_class="margin-top-0" add_legend_heading="Are you working with a CISA regional representative on your domain request?" %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.has_cisa_representative %} {% input_with_errors forms.0.has_cisa_representative %}
{% endwith %} {% endwith %}
{# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
@ -31,13 +25,8 @@
</div> </div>
<fieldset class="usa-fieldset margin-top-2"> <fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Is there anything else youd like us to know about your domain request?</h2>
</legend>
<!-- Toggle --> <!-- Toggle -->
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em> {% with add_class="usa-radio__input--tile" add_legend_heading="Is there anything else youd like us to know about your domain request?" %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.2.has_anything_else_text %} {% input_with_errors forms.2.has_anything_else_text %}
{% endwith %} {% endwith %}
{# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
@ -45,7 +34,7 @@
<div class="margin-top-3" id="anything-else"> <div class="margin-top-3" id="anything-else">
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p> <p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %} {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_heading="Is there anything else youd like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %}
{% input_with_errors forms.3.anything_else %} {% input_with_errors forms.3.anything_else %}
{% endwith %} {% endwith %}
{# forms.3 is a form for inputting the e-mail of a cisa representative #} {# forms.3 is a form for inputting the e-mail of a cisa representative #}

View file

@ -9,7 +9,7 @@
<li><strong>We typically dont reach out to these employees</strong>, but if contact is necessary, our practice is to coordinate with you first.</li> <li><strong>We typically dont reach out to these employees</strong>, but if contact is necessary, our practice is to coordinate with you first.</li>
</ul> </ul>
</p> </p>
{% include "includes/required_fields.html" %}
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} {% block form_required_fields_help_text %}
@ -17,20 +17,14 @@
{% endblock %} {% endblock %}
{% block form_fields %} {% block form_fields %}
<fieldset class="usa-fieldset margin-top-2"> <div class="margin-top-2">
<legend> {% with add_class="usa-radio__input--tile" add_legend_heading="Are there other employees who can help verify your request?" %}
<h2>Are there other employees who can help verify your request?</h2>
</legend>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.has_other_contacts %} {% input_with_errors forms.0.has_other_contacts %}
{% endwith %} {% endwith %}
{# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #} {# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #}
</div>
</fieldset>
<div id="other-employees" class="other-contacts-form"> <div id="other-employees" class="other-contacts-form">
{% include "includes/required_fields.html" %}
{{ forms.1.management_form }} {{ forms.1.management_form }}
{# forms.1 is a formset and this iterates over its forms #} {# forms.1 is a formset and this iterates over its forms #}
{% for form in forms.1.forms %} {% for form in forms.1.forms %}

View file

@ -54,11 +54,14 @@
{% if portfolio %} {% if portfolio %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv"> <div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
<section aria-label="Domain Requests report component" class="margin-top-205"> <section aria-label="Domain Requests report component" class="margin-top-205">
<a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button"> <!----------------------------------------------------------------------
This link is commented out because we intend to add it back in later.
------------------------------------------------------------------------->
<!-- <a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use> <use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
</svg>Export as CSV </svg>Export as CSV
</a> </a> -->
</section> </section>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,10 +1,30 @@
{% load static %} {% load static %}
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %} {% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span> <span id="get_domains_json_url" class="display-none">{{url}}</span>
<!-- Org model banner (org manager can view, domain manager can edit) -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a>
{% else%}
Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domains.</a>
{% endif %}
</p>
</div>
</div>
</section>
{% endif %}
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains"> <section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
@ -53,7 +73,24 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if portfolio %}
<!-- Non org model banner -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a>
{% else%}
Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domains.</a>
{% endif %}
</p>
</div>
</div>
</section>
{% endif %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2"> <div class="usa-accordion usa-accordion--select margin-right-2">
@ -135,6 +172,19 @@
>Deleted</label >Deleted</label
> >
</div> </div>
{% if has_domain_renewal_flag and num_expiring_domains > 0 %}
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-expiring"
type="checkbox"
name="filter-status"
value="expiring"
/>
<label class="usa-checkbox__label" for="filter-status-expiring"
>Expiring soon</label>
</div>
{% endif %}
</fieldset> </fieldset>
</div> </div>
</div> </div>
@ -149,7 +199,6 @@
</svg> </svg>
</button> </button>
</div> </div>
{% endif %}
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper"> <div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your registered domains</caption> <caption class="sr-only">Your registered domains</caption>
@ -200,4 +249,4 @@
<ul class="usa-pagination__list"> <ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS --> <!-- Pagination links will be dynamically populated by JS -->
</ul> </ul>
</nav> </nav>

View file

@ -71,7 +71,11 @@ error messages, if necessary.
{% endif %} {% endif %}
{# this is the input field, itself #} {# this is the input field, itself #}
{% include widget.template_name %} {% with aria_label=aria_label %}
{% include widget.template_name %}
{% endwith %}
{% if append_gov %} {% if append_gov %}
<span class="padding-top-05 padding-left-2px">.gov </span> <span class="padding-top-05 padding-left-2px">.gov </span>

View file

@ -15,6 +15,6 @@
<div id="main-content"> <div id="main-content">
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %} {% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count num_expiring_domains=num_expiring_domains%}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,42 +1,132 @@
{% extends 'portfolio_base.html' %} {% extends 'portfolio_base.html' %}
{% load static field_helpers%} {% load static url_helpers %}
{% load field_helpers %}
{% block title %}Organization member {% endblock %} {% block title %}Organization member{% endblock %}
{% load static %} {% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<div class="grid-row grid-gap"> {% include "includes/form_errors.html" with form=form %}
<div class="tablet:grid-col-9" id="main-content">
{% block messages %} <!-- Navigation breadcrumbs -->
{% include "includes/form_messages.html" %} <nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
{% endblock %} <ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
{% if member %}
{% url 'member' pk=member.pk as back_url %}
{% elif invitation %}
{% url 'invitedmember' pk=invitation.pk as back_url %}
{% endif %}
<a href="{{ back_url }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
{% comment %} Manage members {% endcomment %}
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Member access and permissions</span>
</li>
</ol>
</nav>
<h1>Manage member</h1> <!-- Page header -->
<h1>Member access and permissions</h1>
<p>
{% if member %}
{{ member.email }}
{% elif invitation %}
{{ invitation.email }}
{% endif %}
</p>
<hr> {% include "includes/required_fields.html" with remove_margin_top=True %}
<form class="usa-form usa-form--large" method="post" novalidate> <form class="usa-form usa-form--large" method="post" id="member_form" novalidate>
{% csrf_token %} {% csrf_token %}
{% input_with_errors form.roles %} <fieldset class="usa-fieldset">
{% input_with_errors form.additional_permissions %} <legend>
<button {% if member and member.email or invitation and invitation.email %}
type="submit" <h2 class="margin-top-1">Member email</h2>
class="usa-button" {% else %}
>Submit</button> <h2 class="margin-top-1">Member</h2>
</form> {% endif %}
</legend>
<p class="margin-top-0">
{% comment %}
Show member email if possible, then invitation email.
If neither of these are true, show the name or as a last resort just "None".
{% endcomment %}
{% if member %}
{% if member.email %}
{{ member.email }}
{% else %}
{{ member.get_formatted_name }}
{% endif %}
{% elif invitation %}
{% if invitation.email %}
{{ invitation.email }}
{% else %}
None
{% endif %}
{% endif %}
</p>
<!-- Member email -->
</fieldset>
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<legend>
<h2 class="margin-top-0">Member Access</h2>
</legend>
</div> <em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</div>
{% endblock %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
{% endwith %}
</fieldset>
<!-- Admin access form -->
<div id="member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0 summary-item__title text-primary-dark">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{{ back_url }}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel editing member"
>
Cancel
</a>
<button type="submit" class="usa-button">Update Member</button>
</div>
</form>
{% endblock portfolio_content%}

View file

@ -5,12 +5,6 @@
{% block title %} Domains | {% endblock %} {% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content"> <div id="main-content">
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
<section class="section-outlined"> <section class="section-outlined">

View file

@ -282,3 +282,11 @@ def display_requesting_entity(domain_request):
) )
return display return display
@register.filter
def get_dict_value(dictionary, key):
"""Get a value from a dictionary. Returns a string on empty."""
if isinstance(dictionary, dict):
return dictionary.get(key, "")
return ""

View file

@ -57,6 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901
legend_classes = [] legend_classes = []
group_classes = [] group_classes = []
aria_labels = [] aria_labels = []
legend_headings = []
sublabel_text = [] sublabel_text = []
# this will be converted to an attribute string # this will be converted to an attribute string
@ -91,6 +92,8 @@ def input_with_errors(context, field=None): # noqa: C901
label_classes.append(value) label_classes.append(value)
elif key == "add_legend_class": elif key == "add_legend_class":
legend_classes.append(value) legend_classes.append(value)
elif key == "add_legend_heading":
legend_headings.append(value)
elif key == "add_group_class": elif key == "add_group_class":
group_classes.append(value) group_classes.append(value)
@ -120,9 +123,6 @@ def input_with_errors(context, field=None): # noqa: C901
else: else:
context["label_tag"] = "label" context["label_tag"] = "label"
if field.use_fieldset:
label_classes.append("usa-legend")
if field.widget_type == "checkbox": if field.widget_type == "checkbox":
label_classes.append("usa-checkbox__label") label_classes.append("usa-checkbox__label")
elif not field.use_fieldset: elif not field.use_fieldset:
@ -153,6 +153,9 @@ def input_with_errors(context, field=None): # noqa: C901
if legend_classes: if legend_classes:
context["legend_classes"] = " ".join(legend_classes) context["legend_classes"] = " ".join(legend_classes)
if legend_headings:
context["legend_heading"] = " ".join(legend_headings)
if group_classes: if group_classes:
context["group_classes"] = " ".join(group_classes) context["group_classes"] = " ".join(group_classes)

View file

@ -1731,9 +1731,6 @@ class TestDomainRequestAdmin(MockEppLib):
"cisa_representative_first_name", "cisa_representative_first_name",
"cisa_representative_last_name", "cisa_representative_last_name",
"cisa_representative_email", "cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)
@ -1967,6 +1964,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Grab the current list of table filters # Grab the current list of table filters
readonly_fields = self.admin.get_list_filter(request) readonly_fields = self.admin.get_list_filter(request)
expected_fields = ( expected_fields = (
DomainRequestAdmin.PortfolioFilter,
DomainRequestAdmin.StatusListFilter, DomainRequestAdmin.StatusListFilter,
DomainRequestAdmin.GenericOrgFilter, DomainRequestAdmin.GenericOrgFilter,
DomainRequestAdmin.FederalTypeFilter, DomainRequestAdmin.FederalTypeFilter,

View file

@ -7,7 +7,7 @@ This file tests the various ways in which the registrar interacts with the regis
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch, call
import datetime from datetime import datetime, date, timedelta
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from registrar.models import Domain, Host, HostIP from registrar.models import Domain, Host, HostIP
@ -2267,13 +2267,13 @@ class TestExpirationDate(MockEppLib):
"""assert that the setter for expiration date is not implemented and will raise error""" """assert that the setter for expiration date is not implemented and will raise error"""
with less_console_noise(): with less_console_noise():
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self.domain.registry_expiration_date = datetime.date.today() self.domain.registry_expiration_date = date.today()
def test_renew_domain(self): def test_renew_domain(self):
"""assert that the renew_domain sets new expiration date in cache and saves to registrar""" """assert that the renew_domain sets new expiration date in cache and saves to registrar"""
with less_console_noise(): with less_console_noise():
self.domain.renew_domain() self.domain.renew_domain()
test_date = datetime.date(2023, 5, 25) test_date = date(2023, 5, 25)
self.assertEquals(self.domain._cache["ex_date"], test_date) self.assertEquals(self.domain._cache["ex_date"], test_date)
self.assertEquals(self.domain.expiration_date, test_date) self.assertEquals(self.domain.expiration_date, test_date)
@ -2295,18 +2295,42 @@ class TestExpirationDate(MockEppLib):
with less_console_noise(): with less_console_noise():
# to do this, need to mock value returned from timezone.now # to do this, need to mock value returned from timezone.now
# set now to 2023-01-01 # set now to 2023-01-01
mocked_datetime = datetime.datetime(2023, 1, 1, 12, 0, 0) mocked_datetime = datetime(2023, 1, 1, 12, 0, 0)
# force fetch_cache which sets the expiration date to 2023-05-25 # force fetch_cache which sets the expiration date to 2023-05-25
self.domain.statuses self.domain.statuses
with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime): with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime):
self.assertFalse(self.domain.is_expired()) self.assertFalse(self.domain.is_expired())
def test_is_expiring_within_threshold(self):
"""assert that is_expiring returns true when expiration date is within 60 days"""
with less_console_noise():
mocked_datetime = datetime(2023, 1, 1, 12, 0, 0)
expiration_date = mocked_datetime.date() + timedelta(days=30)
# set domain's expiration date
self.domain.expiration_date = expiration_date
with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime):
self.assertTrue(self.domain.is_expiring())
def test_is_not_expiring_outside_threshold(self):
"""assert that is_expiring returns false when expiration date is outside 60 days"""
with less_console_noise():
mocked_datetime = datetime(2023, 1, 1, 12, 0, 0)
expiration_date = mocked_datetime.date() + timedelta(days=61)
# set domain's expiration date
self.domain.expiration_date = expiration_date
with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime):
self.assertFalse(self.domain.is_expiring())
def test_expiration_date_updated_on_info_domain_call(self): def test_expiration_date_updated_on_info_domain_call(self):
"""assert that expiration date in db is updated on info domain call""" """assert that expiration date in db is updated on info domain call"""
with less_console_noise(): with less_console_noise():
# force fetch_cache to be called # force fetch_cache to be called
self.domain.statuses self.domain.statuses
test_date = datetime.date(2023, 5, 25) test_date = date(2023, 5, 25)
self.assertEquals(self.domain.expiration_date, test_date) self.assertEquals(self.domain.expiration_date, test_date)
@ -2322,7 +2346,7 @@ class TestCreationDate(MockEppLib):
self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
# creation_date returned from mockDataInfoDomain with creation date: # creation_date returned from mockDataInfoDomain with creation date:
# cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35) # cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35)
self.creation_date = make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)) self.creation_date = make_aware(datetime(2023, 5, 25, 19, 45, 35))
def tearDown(self): def tearDown(self):
Domain.objects.all().delete() Domain.objects.all().delete()
@ -2331,7 +2355,7 @@ class TestCreationDate(MockEppLib):
def test_creation_date_setter_not_implemented(self): def test_creation_date_setter_not_implemented(self):
"""assert that the setter for creation date is not implemented and will raise error""" """assert that the setter for creation date is not implemented and will raise error"""
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self.domain.creation_date = datetime.date.today() self.domain.creation_date = date.today()
def test_creation_date_updated_on_info_domain_call(self): def test_creation_date_updated_on_info_domain_call(self):
"""assert that creation date in db is updated on info domain call""" """assert that creation date in db is updated on info domain call"""

View file

@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
] ]
@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"zdomain12.gov,Interstate,,,,,(blank)\n" "zdomain12.gov,Interstate,,,,,(blank)\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator

View file

@ -424,6 +424,112 @@ class TestDomainDetail(TestDomainOverview):
self.assertContains(detail_page, "invited@example.com") self.assertContains(detail_page, "invited@example.com")
class TestDomainDetailDomainRenewal(TestDomainOverview):
def setUp(self):
super().setUp()
self.user = get_user_model().objects.create(
first_name="User",
last_name="Test",
email="bogus@example.gov",
phone="8003111234",
title="test title",
username="usertest",
)
self.expiringdomain, _ = Domain.objects.get_or_create(
name="expiringdomain.gov",
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.expiringdomain, role=UserDomainRole.Roles.MANAGER
)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.expiringdomain)
self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
self.user.save()
def custom_is_expired(self):
return False
def custom_is_expiring(self):
return True
@override_flag("domain_renewal", active=True)
def test_expiring_domain_on_detail_page_as_domain_manager(self):
self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired
):
self.assertEquals(self.expiringdomain.state, Domain.State.UNKNOWN)
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.expiringdomain.id}),
)
self.assertContains(detail_page, "Expiring soon")
self.assertContains(detail_page, "Renew to maintain access")
self.assertNotContains(detail_page, "DNS needed")
self.assertNotContains(detail_page, "Expired")
@override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self):
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
non_dom_manage_user = get_user_model().objects.create(
first_name="Non Domain",
last_name="Manager",
email="verybogus@example.gov",
phone="8003111234",
title="test title again",
username="nondomain",
)
non_dom_manage_user.save()
UserPortfolioPermission.objects.get_or_create(
user=non_dom_manage_user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
],
)
expiringdomain2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov")
DomainInformation.objects.get_or_create(
creator=non_dom_manage_user, domain=expiringdomain2, portfolio=self.portfolio
)
non_dom_manage_user.refresh_from_db()
self.client.force_login(non_dom_manage_user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired
):
detail_page = self.client.get(
reverse("domain", kwargs={"pk": expiringdomain2.id}),
)
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
@override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self):
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user)
expiringdomain3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov")
UserDomainRole.objects.get_or_create(user=self.user, domain=expiringdomain3, role=UserDomainRole.Roles.MANAGER)
DomainInformation.objects.get_or_create(creator=self.user, domain=expiringdomain3, portfolio=portfolio)
self.user.refresh_from_db()
self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired
):
detail_page = self.client.get(
reverse("domain", kwargs={"pk": expiringdomain3.id}),
)
self.assertContains(detail_page, "Renew to maintain access")
class TestDomainManagers(TestDomainOverview): class TestDomainManagers(TestDomainOverview):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@ -2348,3 +2454,125 @@ class TestDomainChangeNotifications(TestDomainOverview):
# Check that an email was not sent # Check that an email was not sent
self.assertFalse(self.mock_client.send_email.called) self.assertFalse(self.mock_client.send_email.called)
class TestDomainRenewal(TestWithUser):
def setUp(self):
super().setUp()
today = datetime.now()
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
expiring_date_current = (today + timedelta(days=70)).strftime("%Y-%m-%d")
expired_date = (today - timedelta(days=30)).strftime("%Y-%m-%d")
self.domain_with_expiring_soon_date, _ = Domain.objects.get_or_create(
name="igorville.gov", expiration_date=expiring_date
)
self.domain_with_expired_date, _ = Domain.objects.get_or_create(
name="domainwithexpireddate.com", expiration_date=expired_date
)
self.domain_with_current_date, _ = Domain.objects.get_or_create(
name="domainwithfarexpireddate.com", expiration_date=expiring_date_current
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_with_current_date, role=UserDomainRole.Roles.MANAGER
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_with_expired_date, role=UserDomainRole.Roles.MANAGER
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_with_expiring_soon_date, role=UserDomainRole.Roles.MANAGER
)
def tearDown(self):
try:
UserDomainRole.objects.all().delete()
Domain.objects.all().delete()
except ValueError:
pass
super().tearDown()
# Remove test_without_domain_renewal_flag when domain renewal is released as a feature
@less_console_noise_decorator
@override_flag("domain_renewal", active=False)
def test_without_domain_renewal_flag(self):
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertNotContains(domains_page, "will expire soon")
self.assertNotContains(domains_page, "Expiring soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
def test_domain_renewal_flag_single_domain(self):
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "One domain will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
def test_with_domain_renewal_flag_mulitple_domains(self):
today = datetime.now()
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
self.domain_with_another_expiring, _ = Domain.objects.get_or_create(
name="domainwithanotherexpiringdate.com", expiration_date=expiring_date
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_with_another_expiring, role=UserDomainRole.Roles.MANAGER
)
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "Multiple domains will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
def test_with_domain_renewal_flag_no_expiring_domains(self):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertNotContains(domains_page, "Expiring soon")
self.assertNotContains(domains_page, "will expire soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_domain_renewal_flag_single_domain_w_org_feature_flag(self):
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "One domain will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_with_domain_renewal_flag_mulitple_domains_w_org_feature_flag(self):
today = datetime.now()
expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d")
self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create(
name="domainwithanotherexpiringdate_orgmodel.com", expiration_date=expiring_date
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_with_another_expiring_org_model, role=UserDomainRole.Roles.MANAGER
)
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertContains(domains_page, "Multiple domains will expire soon")
self.assertContains(domains_page, "Expiring soon")
@less_console_noise_decorator
@override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True)
def test_with_domain_renewal_flag_no_expiring_domains_w_org_feature_flag(self):
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
self.client.force_login(self.user)
domains_page = self.client.get("/")
self.assertNotContains(domains_page, "Expiring soon")
self.assertNotContains(domains_page, "will expire soon")

View file

@ -8,24 +8,34 @@ from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from waffle.testutils import override_flag from waffle.testutils import override_flag
from datetime import datetime, timedelta
class GetDomainsJsonTest(TestWithUser, WebTest): class GetDomainsJsonTest(TestWithUser, WebTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
today = datetime.now()
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
expiring_date_2 = (today + timedelta(days=31)).strftime("%Y-%m-%d")
# Create test domains # Create test domains
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown") self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed") self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready") self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
self.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready") self.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready")
self.domain5 = Domain.objects.create(name="example5.com", expiration_date=expiring_date, state="expiring soon")
self.domain6 = Domain.objects.create(
name="example6.com", expiration_date=expiring_date_2, state="expiring soon"
)
# Create UserDomainRoles # Create UserDomainRoles
UserDomainRole.objects.create(user=self.user, domain=self.domain1) UserDomainRole.objects.create(user=self.user, domain=self.domain1)
UserDomainRole.objects.create(user=self.user, domain=self.domain2) UserDomainRole.objects.create(user=self.user, domain=self.domain2)
UserDomainRole.objects.create(user=self.user, domain=self.domain3) UserDomainRole.objects.create(user=self.user, domain=self.domain3)
UserDomainRole.objects.create(user=self.user, domain=self.domain5)
UserDomainRole.objects.create(user=self.user, domain=self.domain6)
# Create Portfolio # Create Portfolio
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Example org") self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Example org")
@ -63,7 +73,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
# Check the number of domains # Check the number of domains
self.assertEqual(len(data["domains"]), 3) self.assertEqual(len(data["domains"]), 5)
# Expected domains # Expected domains
expected_domains = [self.domain1, self.domain2, self.domain3] expected_domains = [self.domain1, self.domain2, self.domain3]
@ -310,7 +320,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 1) self.assertEqual(data["total"], 1)
self.assertEqual(data["unfiltered_total"], 3) self.assertEqual(data["unfiltered_total"], 5)
# Check the number of domain requests # Check the number of domain requests
self.assertEqual(len(data["domains"]), 1) self.assertEqual(len(data["domains"]), 1)
@ -377,14 +387,15 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
@less_console_noise_decorator @less_console_noise_decorator
def test_state_filtering(self): def test_state_filtering(self):
"""Test that different states in request get expected responses.""" """Test that different states in request get expected responses."""
expected_values = [ expected_values = [
("unknown", 1), ("unknown", 1),
("ready", 0), ("ready", 0),
("expired", 2), ("expired", 2),
("ready,expired", 2), ("ready,expired", 2),
("unknown,expired", 3), ("unknown,expired", 3),
("expiring", 2),
] ]
for state, num_domains in expected_values: for state, num_domains in expected_values:
with self.subTest(state=state, num_domains=num_domains): with self.subTest(state=state, num_domains=num_domains):
response = self.app.get(reverse("get_domains_json"), {"status": state}) response = self.app.get(reverse("get_domains_json"), {"status": state})

View file

@ -2940,3 +2940,160 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# Validate Database has not changed # Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count() invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before) self.assertEqual(invite_count_after, invite_count_before)
class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios"""
def setUp(self):
self.user = create_user()
# Create Portfolio
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
# Add an invited member who has been invited to manage domains
self.invited_member_email = "invited@example.com"
self.invitation = PortfolioInvitation.objects.create(
email=self.invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
def tearDown(self):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_basic_to_admin(self):
"""Tests converting a basic member to admin with full permissions."""
self.client.force_login(self.user)
# Create a basic member to edit
basic_member = create_test_user()
basic_permission = UserPortfolioPermission.objects.create(
user=basic_member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
# Verify redirect and success message
self.assertEqual(response.status_code, 302)
# Verify database changes
basic_permission.refresh_from_db()
self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(basic_permission.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_validation(self):
"""Tests form validation for required fields based on role."""
self.client.force_login(self.user)
member = create_test_user()
permission = UserPortfolioPermission.objects.create(
user=member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# Test missing required admin permissions
response = self.client.post(
reverse("member-permissions", kwargs={"pk": permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
# Missing required admin fields
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context["form"].errors["domain_request_permission_admin"][0],
"Admin domain request permission is required",
)
self.assertEqual(
response.context["form"].errors["member_permission_admin"][0], "Admin member permission is required"
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_invited_member_permissions(self):
"""Tests editing permissions for an invited (but not yet joined) member."""
self.client.force_login(self.user)
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
self.assertEqual(response.status_code, 302)
# Verify invitation was updated
updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id)
self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(updated_invitation.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
"""Tests an admin removing their own admin role redirects to home."""
self.client.force_login(self.user)
# Get the user's admin permission
admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("home"))

View file

@ -744,30 +744,45 @@ class DomainExport(BaseExport):
): ):
security_contact_email = "(blank)" security_contact_email = "(blank)"
model["status"] = human_readable_status
model["first_ready_on"] = first_ready_on
model["expiration_date"] = expiration_date
model["domain_type"] = domain_type
model["security_contact_email"] = security_contact_email
# create a dictionary of fields which can be included in output. # create a dictionary of fields which can be included in output.
# "extra_fields" are precomputed fields (generated in the DB or parsed). # "extra_fields" are precomputed fields (generated in the DB or parsed).
FIELDS = cls.get_fields(model)
row = [FIELDS.get(column, "") for column in columns]
return row
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull and DomainDataFederal are
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
@classmethod
def get_fields(cls, model):
FIELDS = { FIELDS = {
"Domain name": model.get("domain__name"), "Domain name": model.get("domain__name"),
"Status": human_readable_status, "Status": model.get("status"),
"First ready on": first_ready_on, "First ready on": model.get("first_ready_on"),
"Expiration date": expiration_date, "Expiration date": model.get("expiration_date"),
"Domain type": domain_type, "Domain type": model.get("domain_type"),
"Agency": model.get("converted_federal_agency"), "Agency": model.get("converted_federal_agency"),
"Organization name": model.get("converted_organization_name"), "Organization name": model.get("converted_organization_name"),
"City": model.get("converted_city"), "City": model.get("converted_city"),
"State": model.get("converted_state_territory"), "State": model.get("converted_state_territory"),
"SO": model.get("converted_so_name"), "SO": model.get("converted_so_name"),
"SO email": model.get("converted_so_email"), "SO email": model.get("converted_so_email"),
"Security contact email": security_contact_email, "Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"), "Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"), "Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"), "Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"), "Invited domain managers": model.get("invited_users"),
} }
return FIELDS
row = [FIELDS.get(column, "") for column in columns]
return row
def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by):
"""Returns a list of Domain Requests that has been filtered by the given organization value.""" """Returns a list of Domain Requests that has been filtered by the given organization value."""
@ -1077,6 +1092,39 @@ class DomainDataFull(DomainExport):
Inherits from BaseExport -> DomainExport Inherits from BaseExport -> DomainExport
""" """
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod @classmethod
def get_columns(cls): def get_columns(cls):
""" """
@ -1106,9 +1154,9 @@ class DomainDataFull(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"converted_generic_org_type", "organization_type",
Coalesce("converted_federal_type", Value("ZZZZZ")), Coalesce("federal_type", Value("ZZZZZ")),
"converted_federal_agency", "federal_agency",
"domain__name", "domain__name",
] ]
@ -1164,6 +1212,39 @@ class DomainDataFederal(DomainExport):
Inherits from BaseExport -> DomainExport Inherits from BaseExport -> DomainExport
""" """
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod @classmethod
def get_columns(cls): def get_columns(cls):
""" """
@ -1193,9 +1274,9 @@ class DomainDataFederal(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"converted_generic_org_type", "organization_type",
Coalesce("converted_federal_type", Value("ZZZZZ")), Coalesce("federal_type", Value("ZZZZZ")),
"converted_federal_agency", "federal_agency",
"domain__name", "domain__name",
] ]

View file

@ -27,7 +27,7 @@ def get_domains_json(request):
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
domains = [serialize_domain(domain, request.user) for domain in page_obj.object_list] domains = [serialize_domain(domain, request) for domain in page_obj.object_list]
return JsonResponse( return JsonResponse(
{ {
@ -80,21 +80,27 @@ def apply_state_filter(queryset, request):
status_list.append("dns needed") status_list.append("dns needed")
# Split the status list into normal states and custom states # Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values] normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"] custom_states = [state for state in status_list if (state == "expired" or state == "expiring")]
# Construct Q objects for normal states that can be queried through ORM # Construct Q objects for normal states that can be queried through ORM
state_query = Q() state_query = Q()
if normal_states: if normal_states:
state_query |= Q(state__in=normal_states) state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM # Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states: if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"] expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expired"]
state_query |= Q(id__in=expired_domain_ids) state_query |= Q(id__in=expired_domain_ids)
if "expiring" in custom_states:
expiring_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expiring soon"]
state_query |= Q(id__in=expiring_domain_ids)
# Apply the combined query # Apply the combined query
queryset = queryset.filter(state_query) queryset = queryset.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with # If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed # state_display of 'Expired' must be removed
if "expired" not in custom_states: if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"] expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expired"]
queryset = queryset.exclude(id__in=expired_domain_ids)
if "expiring" not in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expiring soon"]
queryset = queryset.exclude(id__in=expired_domain_ids) queryset = queryset.exclude(id__in=expired_domain_ids)
return queryset return queryset
@ -105,7 +111,7 @@ def apply_sorting(queryset, request):
order = request.GET.get("order", "asc") order = request.GET.get("order", "asc")
if sort_by == "state_display": if sort_by == "state_display":
objects = list(queryset) objects = list(queryset)
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc")) objects.sort(key=lambda domain: domain.state_display(request), reverse=(order == "desc"))
return objects return objects
else: else:
if order == "desc": if order == "desc":
@ -113,7 +119,8 @@ def apply_sorting(queryset, request):
return queryset.order_by(sort_by) return queryset.order_by(sort_by)
def serialize_domain(domain, user): def serialize_domain(domain, request):
user = request.user
suborganization_name = None suborganization_name = None
try: try:
domain_info = domain.domain_info domain_info = domain.domain_info
@ -133,7 +140,7 @@ def serialize_domain(domain, user):
"name": domain.name, "name": domain.name,
"expiration_date": domain.expiration_date, "expiration_date": domain.expiration_date,
"state": domain.state, "state": domain.state,
"state_display": domain.state_display(), "state_display": domain.state_display(request),
"get_state_help_text": domain.get_state_help_text(), "get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}), "action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if view_only else "Manage"), "action_label": ("View" if view_only else "Manage"),

View file

@ -8,5 +8,6 @@ def index(request):
if request and request.user and request.user.is_authenticated: if request and request.user and request.user.is_authenticated:
# This controls the creation of a new domain request in the wizard # This controls the creation of a new domain request in the wizard
context["user_domain_count"] = request.user.get_user_domain_ids(request).count() context["user_domain_count"] = request.user.get_user_domain_ids(request).count()
context["num_expiring_domains"] = request.user.get_num_expiring_domains(request)
return render(request, "home.html", context) return render(request, "home.html", context)

View file

@ -7,7 +7,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.contrib import messages from django.contrib import messages
from registrar.forms import portfolio as portfolioForms from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User from registrar.models import Portfolio, User
from registrar.models.domain_invitation import DomainInvitation from registrar.models.domain_invitation import DomainInvitation
@ -44,6 +43,8 @@ class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
context = {} context = {}
if self.request and self.request.user and self.request.user.is_authenticated: if self.request and self.request.user and self.request.user.is_authenticated:
context["user_domain_count"] = self.request.user.get_user_domain_ids(request).count() context["user_domain_count"] = self.request.user.get_user_domain_ids(request).count()
context["num_expiring_domains"] = request.user.get_num_expiring_domains(request)
return render(request, "portfolio_domains.html", context) return render(request, "portfolio_domains.html", context)
@ -148,7 +149,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html" template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioMemberForm form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk): def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
@ -168,12 +169,17 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
def post(self, request, pk): def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user = portfolio_permission.user user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission) form = self.form_class(request.POST, instance=portfolio_permission)
if form.is_valid(): if form.is_valid():
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = (
request.user == user
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
)
form.save() form.save()
return redirect("member", pk=pk) messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
return render( return render(
request, request,
@ -344,7 +350,7 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html" template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioInvitedMemberForm form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk): def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
@ -364,6 +370,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
form = self.form_class(request.POST, instance=portfolio_invitation) form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("invitedmember", pk=pk) return redirect("invitedmember", pk=pk)
return render( return render(

View file

@ -13,7 +13,7 @@ defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1,
diff-match-patch==20230430; python_version >= '3.7' diff-match-patch==20230430; python_version >= '3.7'
dj-database-url==2.2.0 dj-database-url==2.2.0
dj-email-url==1.0.6 dj-email-url==1.0.6
django==4.2.10; python_version >= '3.8' django==4.2.17; python_version >= '3.8'
django-admin-multiple-choice-list-filter==0.1.1 django-admin-multiple-choice-list-filter==0.1.1
django-allow-cidr==0.7.1 django-allow-cidr==0.7.1
django-auditlog==3.0.0; python_version >= '3.8' django-auditlog==3.0.0; python_version >= '3.8'

View file

@ -70,6 +70,7 @@
10038 OUTOFSCOPE http://app:8080/org-name-address 10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/domains/edit
10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/suborganization/