mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-04 17:01:56 +02:00
Merge branch 'main' into dk/2524-admin-domain-show-info
This commit is contained in:
commit
6584eb1547
139 changed files with 5838 additions and 1692 deletions
|
@ -1,7 +1,7 @@
|
|||
import os
|
||||
import tempfile
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf import settings # type: ignore
|
||||
|
||||
|
||||
class Cert:
|
||||
|
@ -12,7 +12,7 @@ class Cert:
|
|||
variable but Python's ssl library requires a file.
|
||||
"""
|
||||
|
||||
def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None:
|
||||
def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: # type: ignore
|
||||
self.filename = self._write(data)
|
||||
|
||||
def __del__(self):
|
||||
|
@ -31,4 +31,4 @@ class Key(Cert):
|
|||
"""Location of private key as written to disk."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(data=settings.SECRET_REGISTRY_KEY)
|
||||
super().__init__(data=settings.SECRET_REGISTRY_KEY) # type: ignore
|
||||
|
|
|
@ -7,9 +7,11 @@ from django import forms
|
|||
from django.db.models import Value, CharField, Q
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib import admin, messages
|
||||
|
@ -34,6 +36,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
|
|||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import escape
|
||||
from django.contrib.auth.forms import UserChangeForm, UsernameField
|
||||
from django.contrib.admin.views.main import IGNORED_PARAMS
|
||||
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
||||
from import_export import resources
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
@ -131,14 +134,6 @@ class MyUserAdminForm(UserChangeForm):
|
|||
widgets = {
|
||||
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
||||
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
),
|
||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_additional_permissions",
|
||||
is_stacked=False,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -170,6 +165,22 @@ class MyUserAdminForm(UserChangeForm):
|
|||
)
|
||||
|
||||
|
||||
class UserPortfolioPermissionsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.UserPortfolioPermission
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"roles": FilteredSelectMultipleArrayWidget(
|
||||
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
),
|
||||
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"additional_permissions",
|
||||
is_stacked=False,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PortfolioInvitationAdminForm(UserChangeForm):
|
||||
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||
|
||||
|
@ -223,7 +234,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
|||
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
||||
}
|
||||
labels = {
|
||||
"action_needed_reason_email": "Auto-generated email",
|
||||
"action_needed_reason_email": "Email",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -366,7 +377,9 @@ class DomainRequestAdminForm(forms.ModelForm):
|
|||
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
||||
"""
|
||||
This class overrides the behavior of column sorting in django admin tables in order
|
||||
to allow for multi field sorting on admin_order_field
|
||||
to allow for multi field sorting on admin_order_field. It also overrides behavior
|
||||
of getting the filter params to allow portfolio filters to be executed without
|
||||
displaying on the right side of the ChangeList view.
|
||||
|
||||
|
||||
Usage:
|
||||
|
@ -428,6 +441,24 @@ class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
|||
|
||||
return ordering
|
||||
|
||||
def get_filters_params(self, params=None):
|
||||
"""
|
||||
Add portfolio to ignored params to allow the portfolio filter while not
|
||||
listing it as a filter option on the right side of Change List on the
|
||||
portfolio list.
|
||||
"""
|
||||
params = params or self.params
|
||||
lookup_params = params.copy() # a dictionary of the query string
|
||||
# Remove all the parameters that are globally and systematically
|
||||
# ignored.
|
||||
# Remove portfolio so that it does not error as an invalid
|
||||
# filter parameter.
|
||||
ignored_params = list(IGNORED_PARAMS) + ["portfolio"]
|
||||
for ignored in ignored_params:
|
||||
if ignored in lookup_params:
|
||||
del lookup_params[ignored]
|
||||
return lookup_params
|
||||
|
||||
|
||||
class CustomLogEntryAdmin(LogEntryAdmin):
|
||||
"""Overwrite the generated LogEntry admin class"""
|
||||
|
@ -505,7 +536,6 @@ class AdminSortFields:
|
|||
sort_mapping = {
|
||||
# == Contact == #
|
||||
"other_contacts": (Contact, _name_sort),
|
||||
"submitter": (Contact, _name_sort),
|
||||
# == Senior Official == #
|
||||
"senior_official": (SeniorOfficial, _name_sort),
|
||||
# == User == #
|
||||
|
@ -644,6 +674,19 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
|
|||
)
|
||||
except models.User.DoesNotExist:
|
||||
pass
|
||||
elif parameter_name == "portfolio":
|
||||
# Retrieves the corresponding portfolio from Portfolio
|
||||
id_value = request.GET.get(param)
|
||||
try:
|
||||
portfolio = models.Portfolio.objects.get(id=id_value)
|
||||
filters.append(
|
||||
{
|
||||
"parameter_name": "portfolio",
|
||||
"parameter_value": portfolio.organization_name,
|
||||
}
|
||||
)
|
||||
except models.Portfolio.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
# For other parameter names, append a dictionary with the original
|
||||
# parameter_name and the corresponding parameter_value
|
||||
|
@ -710,19 +753,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
autocomplete_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
readonly_fields = ("verification_type",)
|
||||
|
||||
analyst_fieldsets = (
|
||||
|
@ -742,9 +778,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||
"fields": (
|
||||
"is_active",
|
||||
"groups",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
)
|
||||
},
|
||||
),
|
||||
|
@ -799,9 +832,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||
"Important dates",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
]
|
||||
|
||||
# TODO: delete after we merge organization feature
|
||||
|
@ -932,7 +962,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
|||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
|
||||
|
||||
extra_context = {"domain_requests": domain_requests, "domains": domains}
|
||||
portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
|
||||
portfolios = models.Portfolio.objects.filter(id__in=portfolio_ids)
|
||||
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
|
||||
|
@ -1210,6 +1242,26 @@ class UserDomainRoleResource(resources.ModelResource):
|
|||
model = models.UserDomainRole
|
||||
|
||||
|
||||
class UserPortfolioPermissionAdmin(ListHeaderAdmin):
|
||||
form = UserPortfolioPermissionsForm
|
||||
|
||||
class Meta:
|
||||
"""Contains meta information about this class"""
|
||||
|
||||
model = models.UserPortfolioPermission
|
||||
fields = "__all__"
|
||||
|
||||
_meta = Meta()
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"user",
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
autocomplete_fields = ["user", "portfolio"]
|
||||
|
||||
|
||||
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
"""Custom user domain role admin class."""
|
||||
|
||||
|
@ -1390,13 +1442,9 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"domain",
|
||||
"generic_org_type",
|
||||
"created_at",
|
||||
"submitter",
|
||||
]
|
||||
|
||||
orderable_fk_fields = [
|
||||
("domain", "name"),
|
||||
("submitter", ["first_name", "last_name"]),
|
||||
]
|
||||
orderable_fk_fields = [("domain", "name")]
|
||||
|
||||
# Filters
|
||||
list_filter = ["generic_org_type"]
|
||||
|
@ -1408,7 +1456,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
search_help_text = "Search by domain."
|
||||
|
||||
fieldsets = [
|
||||
(None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
|
||||
(None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}),
|
||||
(".gov domain", {"fields": ["domain"]}),
|
||||
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
|
||||
("Background info", {"fields": ["anything_else"]}),
|
||||
|
@ -1472,7 +1520,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"more_organization_information",
|
||||
"domain",
|
||||
"domain_request",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
|
@ -1487,7 +1534,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"domain_request",
|
||||
"senior_official",
|
||||
"domain",
|
||||
"submitter",
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
]
|
||||
|
@ -1649,7 +1695,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Columns
|
||||
list_display = [
|
||||
"requested_domain",
|
||||
"submission_date",
|
||||
"first_submitted_date",
|
||||
"last_submitted_date",
|
||||
"last_status_update",
|
||||
"status",
|
||||
"generic_org_type",
|
||||
"federal_type",
|
||||
|
@ -1658,13 +1706,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"custom_election_board",
|
||||
"city",
|
||||
"state_territory",
|
||||
"submitter",
|
||||
"investigator",
|
||||
]
|
||||
|
||||
orderable_fk_fields = [
|
||||
("requested_domain", "name"),
|
||||
("submitter", ["first_name", "last_name"]),
|
||||
("investigator", ["first_name", "last_name"]),
|
||||
]
|
||||
|
||||
|
@ -1694,11 +1740,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Search
|
||||
search_fields = [
|
||||
"requested_domain__name",
|
||||
"submitter__email",
|
||||
"submitter__first_name",
|
||||
"submitter__last_name",
|
||||
"creator__email",
|
||||
"creator__first_name",
|
||||
"creator__last_name",
|
||||
]
|
||||
search_help_text = "Search by domain or submitter."
|
||||
search_help_text = "Search by domain or creator."
|
||||
|
||||
fieldsets = [
|
||||
(
|
||||
|
@ -1714,7 +1760,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"action_needed_reason_email",
|
||||
"investigator",
|
||||
"creator",
|
||||
"submitter",
|
||||
"approved_domain",
|
||||
"notes",
|
||||
]
|
||||
|
@ -1802,7 +1847,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"approved_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
|
@ -1813,7 +1857,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
autocomplete_fields = [
|
||||
"approved_domain",
|
||||
"requested_domain",
|
||||
"submitter",
|
||||
"creator",
|
||||
"senior_official",
|
||||
"investigator",
|
||||
|
@ -1852,7 +1895,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Table ordering
|
||||
# NOTE: This impacts the select2 dropdowns (combobox)
|
||||
# Currentl, there's only one for requests on DomainInfo
|
||||
ordering = ["-submission_date", "requested_domain__name"]
|
||||
ordering = ["-last_submitted_date", "requested_domain__name"]
|
||||
|
||||
change_form_template = "django/admin/domain_request_change_form.html"
|
||||
|
||||
|
@ -1914,6 +1957,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
else:
|
||||
obj.action_needed_reason_email = default_email
|
||||
|
||||
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||
self._check_for_valid_email(request, obj)
|
||||
|
||||
# == Handle status == #
|
||||
if obj.status == original_obj.status:
|
||||
# If the status hasn't changed, let the base function take care of it
|
||||
|
@ -1926,6 +1972,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if should_save:
|
||||
return super().save_model(request, obj, form, change)
|
||||
|
||||
def _check_for_valid_email(self, request, obj):
|
||||
"""Certain emails are whitelisted in non-production environments,
|
||||
so we should display that information using this function.
|
||||
|
||||
"""
|
||||
|
||||
if hasattr(obj, "creator"):
|
||||
recipient = obj.creator
|
||||
else:
|
||||
recipient = None
|
||||
|
||||
# Displays a warning in admin when an email cannot be sent
|
||||
if recipient and recipient.email:
|
||||
email = recipient.email
|
||||
allowed = models.AllowedEmail.is_allowed_email(email)
|
||||
error_message = f"Could not send email. The email '{email}' does not exist within the whitelist."
|
||||
if not allowed:
|
||||
messages.warning(request, error_message)
|
||||
|
||||
def _handle_status_change(self, request, obj, original_obj):
|
||||
"""
|
||||
Checks for various conditions when a status change is triggered.
|
||||
|
@ -2150,10 +2215,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
|
||||
return None
|
||||
|
||||
if flag_is_active(None, "profile_feature"): # type: ignore
|
||||
recipient = domain_request.creator
|
||||
else:
|
||||
recipient = domain_request.submitter
|
||||
recipient = domain_request.creator
|
||||
|
||||
# Return the context of the rendered views
|
||||
context = {"domain_request": domain_request, "recipient": recipient}
|
||||
|
@ -2236,6 +2298,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
use_sort = db_field.name != "senior_official"
|
||||
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Custom get_queryset to filter by portfolio if portfolio is in the
|
||||
request params."""
|
||||
qs = super().get_queryset(request)
|
||||
# Check if a 'portfolio' parameter is passed in the request
|
||||
portfolio_id = request.GET.get("portfolio")
|
||||
if portfolio_id:
|
||||
# Further filter the queryset by the portfolio
|
||||
qs = qs.filter(portfolio=portfolio_id)
|
||||
return qs
|
||||
|
||||
|
||||
class TransitionDomainAdmin(ListHeaderAdmin):
|
||||
"""Custom transition domain admin class."""
|
||||
|
@ -2724,6 +2797,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return True
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Custom get_queryset to filter by portfolio if portfolio is in the
|
||||
request params."""
|
||||
qs = super().get_queryset(request)
|
||||
# Check if a 'portfolio' parameter is passed in the request
|
||||
portfolio_id = request.GET.get("portfolio")
|
||||
if portfolio_id:
|
||||
# Further filter the queryset by the portfolio
|
||||
qs = qs.filter(domain_info__portfolio=portfolio_id)
|
||||
return qs
|
||||
|
||||
|
||||
class DraftDomainResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -2903,12 +2987,8 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
fieldsets = [
|
||||
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
|
||||
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
|
||||
# TODO - uncomment in #2521
|
||||
# ("Portfolio members", {
|
||||
# "classes": ("collapse", "closed"),
|
||||
# "fields": ["administrators", "members"]}
|
||||
# ),
|
||||
("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}),
|
||||
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
|
||||
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
|
||||
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
|
||||
(
|
||||
"Organization name and mailing address",
|
||||
|
@ -2955,14 +3035,118 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
readonly_fields = [
|
||||
# This is the created_at field
|
||||
"created_on",
|
||||
# Custom fields such as these must be defined as readonly.
|
||||
# Django admin doesn't allow methods to be directly listed in fieldsets. We can
|
||||
# display the custom methods display_admins amd display_members in the admin form if
|
||||
# they are readonly.
|
||||
"federal_type",
|
||||
"domains",
|
||||
"domain_requests",
|
||||
"suborganizations",
|
||||
"portfolio_type",
|
||||
"display_admins",
|
||||
"display_members",
|
||||
"creator",
|
||||
]
|
||||
|
||||
def get_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio
|
||||
admin_permissions = UserPortfolioPermission.objects.filter(
|
||||
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# Get the user objects associated with these permissions
|
||||
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
|
||||
|
||||
return admin_users
|
||||
|
||||
def get_non_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
|
||||
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
|
||||
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# Get the user objects associated with these permissions
|
||||
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
|
||||
|
||||
return non_admin_users
|
||||
|
||||
def display_admins(self, obj):
|
||||
"""Get joined users who are Admin, unpack and return an HTML block.
|
||||
|
||||
'DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||
Alternatively, we could return querysets in context but that would limit where this
|
||||
data would display in a custom change form without extensive template customization.
|
||||
|
||||
Will be used in the field_readonly block"""
|
||||
admins = self.get_admin_users(obj)
|
||||
if not admins:
|
||||
return format_html("<p>No admins found.</p>")
|
||||
|
||||
admin_details = ""
|
||||
for portfolio_admin in admins:
|
||||
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
|
||||
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
|
||||
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
|
||||
admin_details += f"{escape(portfolio_admin.title)}<br>"
|
||||
admin_details += f"{escape(portfolio_admin.email)}"
|
||||
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
|
||||
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
|
||||
admin_details += (
|
||||
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
|
||||
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
|
||||
)
|
||||
admin_details += "<svg class='usa-icon'>"
|
||||
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
|
||||
admin_details += "</svg>"
|
||||
admin_details += "Copy"
|
||||
admin_details += "</button>"
|
||||
admin_details += "</div><br>"
|
||||
admin_details += f"{escape(portfolio_admin.phone)}"
|
||||
admin_details += "</address>"
|
||||
return format_html(admin_details)
|
||||
|
||||
display_admins.short_description = "Administrators" # type: ignore
|
||||
|
||||
def display_members(self, obj):
|
||||
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
|
||||
|
||||
DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||
Alternatively, we could return querysets in context but that would limit where this
|
||||
data would display in a custom change form without extensive template customization.
|
||||
|
||||
Will be used in the after_help_text block."""
|
||||
members = self.get_non_admin_users(obj)
|
||||
if not members:
|
||||
return ""
|
||||
|
||||
member_details = (
|
||||
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
|
||||
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
|
||||
)
|
||||
for member in members:
|
||||
full_name = member.get_formatted_name()
|
||||
member_details += "<tr>"
|
||||
member_details += f"<td>{escape(full_name)}</td>"
|
||||
member_details += f"<td>{escape(member.title)}</td>"
|
||||
member_details += f"<td>{escape(member.email)}</td>"
|
||||
member_details += f"<td>{escape(member.phone)}</td>"
|
||||
member_details += "<td>"
|
||||
for role in member.portfolio_role_summary(obj):
|
||||
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
|
||||
member_details += "</td></tr>"
|
||||
member_details += "</tbody></table>"
|
||||
return format_html(member_details)
|
||||
|
||||
display_members.short_description = "Members" # type: ignore
|
||||
|
||||
def display_members_summary(self, obj):
|
||||
"""Will be passed as context and used in the field_readonly block."""
|
||||
members = self.get_non_admin_users(obj)
|
||||
if not members:
|
||||
return {}
|
||||
|
||||
return self.get_field_links_as_list(members, "user", separator=", ")
|
||||
|
||||
def federal_type(self, obj: models.Portfolio):
|
||||
"""Returns the federal_type field"""
|
||||
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
|
||||
|
@ -2990,18 +3174,27 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
suborganizations.short_description = "Suborganizations" # type: ignore
|
||||
|
||||
def domains(self, obj: models.Portfolio):
|
||||
"""Returns a list of links for each related domain"""
|
||||
queryset = obj.get_domains()
|
||||
return self.get_field_links_as_list(
|
||||
queryset, "domaininformation", link_info_attribute="get_state_display_of_domain"
|
||||
)
|
||||
"""Returns the count of domains with a link to view them in the admin."""
|
||||
domain_count = obj.get_domains().count() # Count the related domains
|
||||
if domain_count > 0:
|
||||
# Construct the URL to the admin page, filtered by portfolio
|
||||
url = reverse("admin:registrar_domain_changelist") + f"?portfolio={obj.id}"
|
||||
label = "domain" if domain_count == 1 else "domains"
|
||||
# Create a clickable link with the domain count
|
||||
return format_html('<a href="{}">{} {}</a>', url, domain_count, label)
|
||||
return "No domains"
|
||||
|
||||
domains.short_description = "Domains" # type: ignore
|
||||
|
||||
def domain_requests(self, obj: models.Portfolio):
|
||||
"""Returns a list of links for each related domain request"""
|
||||
queryset = obj.get_domain_requests()
|
||||
return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display")
|
||||
"""Returns the count of domain requests with a link to view them in the admin."""
|
||||
domain_request_count = obj.get_domain_requests().count() # Count the related domain requests
|
||||
if domain_request_count > 0:
|
||||
# Construct the URL to the admin page, filtered by portfolio
|
||||
url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}"
|
||||
# Create a clickable link with the domain request count
|
||||
return format_html('<a href="{}">{} domain requests</a>', url, domain_request_count)
|
||||
return "No domain requests"
|
||||
|
||||
domain_requests.short_description = "Domain requests" # type: ignore
|
||||
|
||||
|
@ -3013,7 +3206,7 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
]
|
||||
|
||||
def get_field_links_as_list(
|
||||
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
|
||||
self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
|
||||
):
|
||||
"""
|
||||
Generate HTML links for items in a queryset, using a specified attribute for link text.
|
||||
|
@ -3045,14 +3238,14 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
if link_info_attribute:
|
||||
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
|
||||
|
||||
if seperator:
|
||||
if separator:
|
||||
links.append(link)
|
||||
else:
|
||||
links.append(f"<li>{link}</li>")
|
||||
|
||||
# If no seperator is specified, just return an unordered list.
|
||||
if seperator:
|
||||
return format_html(seperator.join(links)) if links else "-"
|
||||
# If no separator is specified, just return an unordered list.
|
||||
if separator:
|
||||
return format_html(separator.join(links)) if links else "-"
|
||||
else:
|
||||
links = "".join(links)
|
||||
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
|
||||
|
@ -3095,8 +3288,12 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
return readonly_fields
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Add related suborganizations and domain groups"""
|
||||
extra_context = {"skip_additional_contact_info": True}
|
||||
"""Add related suborganizations and domain groups.
|
||||
Add the summary for the portfolio members field (list of members that link to change_forms)."""
|
||||
obj = self.get_object(request, object_id)
|
||||
extra_context = extra_context or {}
|
||||
extra_context["skip_additional_contact_info"] = True
|
||||
extra_context["display_members_summary"] = self.display_members_summary(obj)
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
@ -3205,6 +3402,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
|
||||
class AllowedEmailAdmin(ListHeaderAdmin):
|
||||
class Meta:
|
||||
model = models.AllowedEmail
|
||||
|
||||
list_display = ["email"]
|
||||
search_fields = ["email"]
|
||||
search_help_text = "Search by email."
|
||||
ordering = ["email"]
|
||||
|
||||
|
||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||
|
||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||
|
@ -3232,6 +3439,8 @@ admin.site.register(models.Portfolio, PortfolioAdmin)
|
|||
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
|
||||
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
|
||||
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)
|
||||
|
||||
# Register our custom waffle implementations
|
||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||
|
|
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox
|
||||
(function($) {
|
||||
$(document).ready(function() {
|
||||
if ($) {
|
||||
$("#selected_user").select2({
|
||||
width: 'resolve',
|
||||
placeholder: 'Select a user',
|
||||
allowClear: true
|
||||
});
|
||||
} else {
|
||||
console.error('jQuery is not available');
|
||||
}
|
||||
});
|
||||
})(window.jQuery);
|
|
@ -172,40 +172,39 @@ function addOrRemoveSessionBoolean(name, add){
|
|||
** To perform data operations on this - we need to use jQuery rather than vanilla js.
|
||||
*/
|
||||
(function (){
|
||||
let selector = django.jQuery("#id_investigator")
|
||||
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
||||
if (!selector || !assignSelfButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentUserId = assignSelfButton.getAttribute("data-user-id");
|
||||
let currentUserName = assignSelfButton.getAttribute("data-user-name");
|
||||
if (!currentUserId || !currentUserName){
|
||||
console.error("Could not assign current user: no values found.")
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook a click listener to the "Assign to me" button.
|
||||
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
|
||||
assignSelfButton.addEventListener("click", function() {
|
||||
if (selector.find(`option[value='${currentUserId}']`).length) {
|
||||
// Select the value that is associated with the current user.
|
||||
selector.val(currentUserId).trigger("change");
|
||||
} else {
|
||||
// Create a DOM Option that matches the desired user. Then append it and select it.
|
||||
let userOption = new Option(currentUserName, currentUserId, true, true);
|
||||
selector.append(userOption).trigger("change");
|
||||
if (document.getElementById("id_investigator") && django && django.jQuery) {
|
||||
let selector = django.jQuery("#id_investigator")
|
||||
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
||||
if (!selector || !assignSelfButton) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to any change events, and hide the parent container if investigator has a value.
|
||||
selector.on('change', function() {
|
||||
// The parent container has display type flex.
|
||||
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
||||
});
|
||||
|
||||
|
||||
let currentUserId = assignSelfButton.getAttribute("data-user-id");
|
||||
let currentUserName = assignSelfButton.getAttribute("data-user-name");
|
||||
if (!currentUserId || !currentUserName){
|
||||
console.error("Could not assign current user: no values found.")
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook a click listener to the "Assign to me" button.
|
||||
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
|
||||
assignSelfButton.addEventListener("click", function() {
|
||||
if (selector.find(`option[value='${currentUserId}']`).length) {
|
||||
// Select the value that is associated with the current user.
|
||||
selector.val(currentUserId).trigger("change");
|
||||
} else {
|
||||
// Create a DOM Option that matches the desired user. Then append it and select it.
|
||||
let userOption = new Option(currentUserName, currentUserId, true, true);
|
||||
selector.append(userOption).trigger("change");
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to any change events, and hide the parent container if investigator has a value.
|
||||
selector.on('change', function() {
|
||||
// The parent container has display type flex.
|
||||
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
||||
|
@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){
|
|||
function copyToClipboardAndChangeIcon(button) {
|
||||
// Assuming the input is the previous sibling of the button
|
||||
let input = button.previousElementSibling;
|
||||
let userId = input.getAttribute("user-id")
|
||||
// Copy input value to clipboard
|
||||
if (input) {
|
||||
navigator.clipboard.writeText(input.value).then(function() {
|
||||
|
@ -353,7 +351,7 @@ function initializeWidgetOnList(list, parentId) {
|
|||
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||
// This is the "action needed reason" field
|
||||
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
||||
// This is the "auto-generated email" field
|
||||
// This is the "Email" field
|
||||
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
|
||||
|
||||
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
|
||||
|
@ -509,22 +507,37 @@ function initializeWidgetOnList(list, parentId) {
|
|||
(function () {
|
||||
// Since this is an iife, these vars will be removed from memory afterwards
|
||||
var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
|
||||
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
|
||||
var readonlyView = document.querySelector("#action-needed-reason-email-readonly");
|
||||
|
||||
// Placeholder text (for certain "action needed" reasons that do not involve e=mails)
|
||||
var placeholderText = document.querySelector("#action-needed-reason-email-placeholder-text")
|
||||
|
||||
// E-mail divs and textarea components
|
||||
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email")
|
||||
var actionNeededEmailReadonly = document.querySelector("#action-needed-reason-email-readonly")
|
||||
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
|
||||
|
||||
// Edit e-mail modal (and its confirmation button)
|
||||
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
|
||||
|
||||
// Headers and footers (which change depending on if the e-mail was sent or not)
|
||||
var actionNeededEmailHeader = document.querySelector("#action-needed-email-header")
|
||||
var actionNeededEmailHeaderOnSave = document.querySelector("#action-needed-email-header-email-sent")
|
||||
var actionNeededEmailFooter = document.querySelector("#action-needed-email-footer")
|
||||
|
||||
let emailWasSent = document.getElementById("action-needed-email-sent");
|
||||
let lastSentEmailText = document.getElementById("action-needed-email-last-sent-text");
|
||||
|
||||
// Get the list of e-mails associated with each action-needed dropdown value
|
||||
let emailData = document.getElementById('action-needed-emails-data');
|
||||
if (!emailData) {
|
||||
return;
|
||||
}
|
||||
|
||||
let actionNeededEmailData = emailData.textContent;
|
||||
if(!actionNeededEmailData) {
|
||||
return;
|
||||
}
|
||||
|
||||
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
|
||||
|
||||
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
|
||||
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
|
||||
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
|
||||
|
@ -540,58 +553,117 @@ function initializeWidgetOnList(list, parentId) {
|
|||
// An email was sent out - store that information in a session variable
|
||||
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
|
||||
}
|
||||
|
||||
|
||||
// Show an editable email field or a readonly one
|
||||
updateActionNeededEmailDisplay(reason)
|
||||
});
|
||||
|
||||
// editEmailButton.addEventListener("click", function() {
|
||||
// if (!checkEmailAlreadySent()) {
|
||||
// showEmail(canEdit=true)
|
||||
// }
|
||||
// });
|
||||
|
||||
confirmEditEmailButton.addEventListener("click", function() {
|
||||
// Show editable view
|
||||
showEmail(canEdit=true)
|
||||
});
|
||||
|
||||
|
||||
// Add a change listener to the action needed reason dropdown
|
||||
actionNeededReasonDropdown.addEventListener("change", function() {
|
||||
let reason = actionNeededReasonDropdown.value;
|
||||
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
|
||||
|
||||
if (reason && emailBody) {
|
||||
// Replace the email content
|
||||
actionNeededEmail.value = emailBody;
|
||||
|
||||
// Reset the session object on change since change refreshes the email content.
|
||||
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
|
||||
let emailSent = sessionStorage.getItem(emailSentSessionVariableName)
|
||||
if (emailSent !== null){
|
||||
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false)
|
||||
}
|
||||
// Replace the email content
|
||||
actionNeededEmail.value = emailBody;
|
||||
actionNeededEmailReadonlyTextarea.value = emailBody;
|
||||
hideEmailAlreadySentView();
|
||||
}
|
||||
}
|
||||
|
||||
// Show an editable email field or a readonly one
|
||||
// Show either a preview of the email or some text describing no email will be sent
|
||||
updateActionNeededEmailDisplay(reason)
|
||||
});
|
||||
}
|
||||
|
||||
// Shows an editable email field or a readonly one.
|
||||
function checkEmailAlreadySent()
|
||||
{
|
||||
lastEmailSent = lastSentEmailText.value.replace(/\s+/g, '')
|
||||
currentEmailInTextArea = actionNeededEmail.value.replace(/\s+/g, '')
|
||||
return lastEmailSent === currentEmailInTextArea
|
||||
}
|
||||
|
||||
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
|
||||
function showEmailAlreadySentView()
|
||||
{
|
||||
hideElement(actionNeededEmailHeader)
|
||||
showElement(actionNeededEmailHeaderOnSave)
|
||||
actionNeededEmailFooter.innerHTML = "This email has been sent to the creator of this request";
|
||||
}
|
||||
|
||||
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
|
||||
function hideEmailAlreadySentView()
|
||||
{
|
||||
showElement(actionNeededEmailHeader)
|
||||
hideElement(actionNeededEmailHeaderOnSave)
|
||||
actionNeededEmailFooter.innerHTML = "This email will be sent to the creator of this request after saving";
|
||||
}
|
||||
|
||||
// Shows either a preview of the email or some text describing no email will be sent.
|
||||
// If the email doesn't exist or if we're of reason "other", display that no email was sent.
|
||||
// Likewise, if we've sent this email before, we should just display the content.
|
||||
function updateActionNeededEmailDisplay(reason) {
|
||||
let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null;
|
||||
let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
|
||||
let showMoreButton = document.querySelector("#action_needed_reason_email__show_details");
|
||||
if ((reason && reason != "other") && !emailHasBeenSentBefore) {
|
||||
showElement(actionNeededEmail.parentElement)
|
||||
hideElement(readonlyView)
|
||||
hideElement(showMoreButton)
|
||||
} else {
|
||||
if (!reason || reason === "other") {
|
||||
collapseableDiv.innerHTML = reason ? "No email will be sent." : "-";
|
||||
hideElement(showMoreButton)
|
||||
if (collapseableDiv.classList.contains("collapsed")) {
|
||||
showMoreButton.click()
|
||||
}
|
||||
}else {
|
||||
showElement(showMoreButton)
|
||||
hideElement(actionNeededEmail.parentElement)
|
||||
|
||||
if (reason) {
|
||||
if (reason === "other") {
|
||||
// Hide email preview and show this text instead
|
||||
showPlaceholderText("No email will be sent");
|
||||
}
|
||||
hideElement(actionNeededEmail.parentElement)
|
||||
showElement(readonlyView)
|
||||
else {
|
||||
// Always show readonly view of email to start
|
||||
showEmail(canEdit=false)
|
||||
if(checkEmailAlreadySent())
|
||||
{
|
||||
showEmailAlreadySentView();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hide email preview and show this text instead
|
||||
showPlaceholderText("Select an action needed reason to see email");
|
||||
}
|
||||
}
|
||||
|
||||
// Shows either a readonly view (canEdit=false) or editable view (canEdit=true) of the action needed email
|
||||
function showEmail(canEdit)
|
||||
{
|
||||
if(!canEdit)
|
||||
{
|
||||
showElement(actionNeededEmailReadonly)
|
||||
hideElement(actionNeededEmail.parentElement)
|
||||
}
|
||||
else
|
||||
{
|
||||
hideElement(actionNeededEmailReadonly)
|
||||
showElement(actionNeededEmail.parentElement)
|
||||
}
|
||||
showElement(actionNeededEmailFooter) // this is the same for both views, so it was separated out
|
||||
hideElement(placeholderText)
|
||||
}
|
||||
|
||||
// Hides preview of action needed email and instead displays the given text (innerHTML)
|
||||
function showPlaceholderText(innerHTML)
|
||||
{
|
||||
hideElement(actionNeededEmail.parentElement)
|
||||
hideElement(actionNeededEmailReadonly)
|
||||
hideElement(actionNeededEmailFooter)
|
||||
|
||||
placeholderText.innerHTML = innerHTML;
|
||||
showElement(placeholderText)
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
|
@ -676,7 +748,10 @@ function initializeWidgetOnList(list, parentId) {
|
|||
|
||||
//------ Requested Domains
|
||||
const requestedDomainElement = document.getElementById('id_requested_domain');
|
||||
const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text;
|
||||
// We have to account for different superuser and analyst markups
|
||||
const requestedDomain = requestedDomainElement.options
|
||||
? requestedDomainElement.options[requestedDomainElement.selectedIndex].text
|
||||
: requestedDomainElement.text;
|
||||
|
||||
//------ Submitter
|
||||
// Function to extract text by ID and handle missing elements
|
||||
|
@ -690,7 +765,10 @@ function initializeWidgetOnList(list, parentId) {
|
|||
// Extract the submitter name, title, email, and phone number
|
||||
const submitterDiv = document.querySelector('.form-row.field-submitter');
|
||||
const submitterNameElement = document.getElementById('id_submitter');
|
||||
const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text;
|
||||
// We have to account for different superuser and analyst markups
|
||||
const submitterName = submitterNameElement
|
||||
? submitterNameElement.options[submitterNameElement.selectedIndex].text
|
||||
: submitterDiv.querySelector('a').text;
|
||||
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
|
||||
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
|
||||
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
|
||||
|
@ -833,10 +911,28 @@ function initializeWidgetOnList(list, parentId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Determine if any changes are necessary to the display of portfolio type or federal type
|
||||
// based on changes to the Federal Agency
|
||||
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
|
||||
fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`)
|
||||
.then(response => {
|
||||
const statusCode = response.status;
|
||||
return response.json().then(data => ({ statusCode, data }));
|
||||
})
|
||||
.then(({ statusCode, data }) => {
|
||||
if (data.error) {
|
||||
console.error("Error in AJAX call: " + data.error);
|
||||
return;
|
||||
}
|
||||
updateReadOnly(data.federal_type, '.field-federal_type');
|
||||
updateReadOnly(data.portfolio_type, '.field-portfolio_type');
|
||||
})
|
||||
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
|
||||
|
||||
// Hide the contactList initially.
|
||||
// If we can update the contact information, it'll be shown again.
|
||||
hideElement(contactList.parentElement);
|
||||
|
||||
|
||||
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
|
||||
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
|
||||
.then(response => {
|
||||
|
@ -879,6 +975,7 @@ function initializeWidgetOnList(list, parentId) {
|
|||
}
|
||||
})
|
||||
.catch(error => console.error("Error fetching senior official: ", error));
|
||||
|
||||
}
|
||||
|
||||
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
|
||||
|
@ -890,6 +987,26 @@ function initializeWidgetOnList(list, parentId) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility that selects a div from the DOM using selectorString,
|
||||
* and updates a div within that div which has class of 'readonly'
|
||||
* so that the text of the div is updated to updateText
|
||||
* @param {*} updateText
|
||||
* @param {*} selectorString
|
||||
*/
|
||||
function updateReadOnly(updateText, selectorString) {
|
||||
// find the div by selectorString
|
||||
const selectedDiv = document.querySelector(selectorString);
|
||||
if (selectedDiv) {
|
||||
// find the nested div with class 'readonly' inside the selectorString div
|
||||
const readonlyDiv = selectedDiv.querySelector('.readonly');
|
||||
if (readonlyDiv) {
|
||||
// Update the text content of the readonly div
|
||||
readonlyDiv.textContent = updateText !== null ? updateText : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateContactInfo(data) {
|
||||
if (!contactList) return;
|
||||
|
||||
|
|
|
@ -1168,7 +1168,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
|
||||
const statusIndicator = document.querySelector('.domain__filter-indicator');
|
||||
const statusToggle = document.querySelector('.usa-button--filter');
|
||||
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
|
||||
const portfolioElement = document.getElementById('portfolio-js-value');
|
||||
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
||||
|
||||
|
@ -1220,16 +1219,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
|
||||
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
||||
const actionUrl = domain.action_url;
|
||||
const suborganization = domain.suborganization ? domain.suborganization : '';
|
||||
const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
|
||||
let markupForSuborganizationRow = '';
|
||||
|
||||
if (!noPortfolioFlag) {
|
||||
if (portfolioValue) {
|
||||
markupForSuborganizationRow = `
|
||||
<td>
|
||||
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
|
||||
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
|
||||
</td>
|
||||
`
|
||||
}
|
||||
|
@ -1427,9 +1426,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// NOTE: We may need to evolve this as we add more filters.
|
||||
document.addEventListener('focusin', function(event) {
|
||||
const accordion = document.querySelector('.usa-accordion--select');
|
||||
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||
|
||||
if (accordionIsOpen && !accordion.contains(event.target)) {
|
||||
if (accordionThatIsOpen && !accordion.contains(event.target)) {
|
||||
closeFilters();
|
||||
}
|
||||
});
|
||||
|
@ -1438,9 +1437,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// NOTE: We may need to evolve this as we add more filters.
|
||||
document.addEventListener('click', function(event) {
|
||||
const accordion = document.querySelector('.usa-accordion--select');
|
||||
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||
|
||||
if (accordionIsOpen && !accordion.contains(event.target)) {
|
||||
if (accordionThatIsOpen && !accordion.contains(event.target)) {
|
||||
closeFilters();
|
||||
}
|
||||
});
|
||||
|
@ -1485,6 +1484,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
|
||||
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
|
||||
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
|
||||
const portfolioElement = document.getElementById('portfolio-js-value');
|
||||
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
||||
|
||||
/**
|
||||
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
|
||||
|
@ -1533,7 +1534,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} scroll - control for the scrollToElement functionality
|
||||
* @param {*} searchTerm - the search term
|
||||
*/
|
||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
|
||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
|
||||
// fetch json of page of domain requests, given params
|
||||
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
||||
if (!baseUrl) {
|
||||
|
@ -1545,7 +1546,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
fetch(`${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
||||
// fetch json of page of requests, given params
|
||||
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`
|
||||
if (portfolio)
|
||||
url += `&portfolio=${portfolio}`
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
|
@ -1599,12 +1605,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
|
||||
const actionUrl = request.action_url;
|
||||
const actionLabel = request.action_label;
|
||||
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||
|
||||
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
|
||||
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
|
||||
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
|
||||
let modalTrigger = '';
|
||||
|
||||
// If the request is deletable, create modal body and insert it
|
||||
let markupCreatorRow = '';
|
||||
|
||||
if (portfolioValue) {
|
||||
markupCreatorRow = `
|
||||
<td>
|
||||
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
|
||||
</td>
|
||||
`
|
||||
}
|
||||
|
||||
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||
if (request.is_deletable) {
|
||||
let modalHeading = '';
|
||||
let modalDescription = '';
|
||||
|
@ -1627,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
role="button"
|
||||
id="button-toggle-delete-domain-alert-${request.id}"
|
||||
href="#toggle-delete-domain-alert-${request.id}"
|
||||
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger line-height-sans-5"
|
||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||
data-open-modal
|
||||
>
|
||||
|
@ -1692,16 +1709,66 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
`
|
||||
|
||||
domainRequestsSectionWrapper.appendChild(modal);
|
||||
|
||||
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
|
||||
if (portfolioValue) {
|
||||
modalTrigger = `
|
||||
<a
|
||||
role="button"
|
||||
id="button-toggle-delete-domain-alert-${request.id}"
|
||||
href="#toggle-delete-domain-alert-${request.id}"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 visible-mobile-flex line-height-sans-5"
|
||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||
data-open-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||
</a>
|
||||
|
||||
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions-${request.id}"
|
||||
>
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="more-actions-${request.id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||
<h2>More options</h2>
|
||||
<a
|
||||
role="button"
|
||||
id="button-toggle-delete-domain-alert-${request.id}"
|
||||
href="#toggle-delete-domain-alert-${request.id}"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5"
|
||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||
data-open-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="Domain name">
|
||||
${domainName}
|
||||
</th>
|
||||
<td data-sort-value="${new Date(request.submission_date).getTime()}" data-label="Date submitted">
|
||||
<td data-sort-value="${new Date(request.last_submitted_date).getTime()}" data-label="Date submitted">
|
||||
${submissionDate}
|
||||
</td>
|
||||
${markupCreatorRow}
|
||||
<td data-label="Status">
|
||||
${request.status}
|
||||
</td>
|
||||
|
@ -1817,6 +1884,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
function closeMoreActionMenu(accordionThatIsOpen) {
|
||||
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
|
||||
accordionThatIsOpen.click();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('focusin', function(event) {
|
||||
closeOpenAccordions(event);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
closeOpenAccordions(event);
|
||||
});
|
||||
|
||||
function closeOpenAccordions(event) {
|
||||
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
|
||||
openAccordions.forEach((openAccordionButton) => {
|
||||
// Find the corresponding accordion
|
||||
const accordion = openAccordionButton.closest('.usa-accordion--more-actions');
|
||||
if (accordion && !accordion.contains(event.target)) {
|
||||
// Close the accordion if the click is outside
|
||||
closeMoreActionMenu(openAccordionButton);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadDomainRequests(1);
|
||||
}
|
||||
|
@ -1910,7 +2003,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
let editableFormGroup = button.parentElement.parentElement.parentElement;
|
||||
if (editableFormGroup){
|
||||
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
|
||||
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
|
||||
let inputField = document.getElementById(`id_${fieldName}`);
|
||||
if (!inputField || !readonlyField) {
|
||||
return;
|
||||
|
@ -1936,8 +2029,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Keep the path before '#' and replace the part after '#' with 'invalid'
|
||||
const newHref = parts[0] + '#error';
|
||||
svg.setAttribute('xlink:href', newHref);
|
||||
fullNameField.classList.add("input-with-edit-button__error")
|
||||
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
|
||||
fullNameField.classList.add("toggleable_input__error")
|
||||
label = fullNameField.querySelector(".toggleable_input__readonly-field")
|
||||
label.innerHTML = "Unknown";
|
||||
}
|
||||
}
|
||||
|
@ -2043,11 +2136,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Due to the nature of how uswds works, this is slightly hacky.
|
||||
|
||||
// Use a MutationObserver to watch for changes in the dropdown list
|
||||
const dropdownList = document.querySelector(`#${input.id}--list`);
|
||||
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === "childList") {
|
||||
addBlankOption(clearInputButton, dropdownList, initialValue);
|
||||
addBlankOption(clearInputButton, dropdownList, initialValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -2111,7 +2204,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (!initialValue){
|
||||
blankOption.classList.add("usa-combo-box__list-option--selected")
|
||||
}
|
||||
blankOption.textContent = "---------";
|
||||
blankOption.textContent = "⎯";
|
||||
|
||||
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
|
||||
blankOption.addEventListener("click", (e) => {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
.usa-accordion--select {
|
||||
.usa-accordion--select,
|
||||
.usa-accordion--more-actions {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
position: relative;
|
||||
|
@ -14,7 +15,6 @@
|
|||
// Note, width is determined by a custom width class on one of the children
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 33.88px;
|
||||
left: 0;
|
||||
border-radius: 4px;
|
||||
border: solid 1px color('base-lighter');
|
||||
|
@ -31,3 +31,17 @@
|
|||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.usa-accordion--select .usa-accordion__content {
|
||||
top: 33.88px;
|
||||
}
|
||||
|
||||
.usa-accordion--more-actions .usa-accordion__content {
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
tr:last-child .usa-accordion--more-actions .usa-accordion__content {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
right: 30px;
|
||||
}
|
||||
|
|
|
@ -66,6 +66,9 @@ html[data-theme="light"] {
|
|||
// --object-tools-fg: var(--button-fg);
|
||||
// --object-tools-bg: var(--close-button-bg);
|
||||
// --object-tools-hover-bg: var(--close-button-hover-bg);
|
||||
|
||||
--summary-box-bg: #f1f1f1;
|
||||
--summary-box-border: #d1d2d2;
|
||||
}
|
||||
|
||||
// Fold dark theme settings into our main CSS
|
||||
|
@ -104,6 +107,9 @@ html[data-theme="light"] {
|
|||
|
||||
--close-button-bg: #333333;
|
||||
--close-button-hover-bg: #666666;
|
||||
|
||||
--summary-box-bg: #121212;
|
||||
--summary-box-border: #666666;
|
||||
}
|
||||
|
||||
// Dark mode django (bug due to scss cascade) and USWDS tables
|
||||
|
@ -120,7 +126,7 @@ html[data-theme="light"] {
|
|||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form,
|
||||
.analytics {
|
||||
.custom-admin-template, dt {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
|
@ -149,7 +155,7 @@ html[data-theme="dark"] {
|
|||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form,
|
||||
.analytics {
|
||||
.custom-admin-template, dt {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
|
@ -160,7 +166,7 @@ html[data-theme="dark"] {
|
|||
// Remove when dark mode successfully applies to Django delete page.
|
||||
.delete-confirmation .content a:not(.button) {
|
||||
color: color('primary');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -364,14 +370,60 @@ input.admin-confirm-button {
|
|||
list-style-type: none;
|
||||
line-height: normal;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 8px;
|
||||
line-height: normal;
|
||||
}
|
||||
a.button:active, a.button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
// This block resolves some of the issues we're seeing on buttons due to css
|
||||
// conflicts between DJ and USWDS
|
||||
a.button,
|
||||
.usa-button--dja {
|
||||
display: inline-block;
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
line-height: 16.1px;
|
||||
font-kerning: auto;
|
||||
font-family: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
.button svg,
|
||||
.button span,
|
||||
.usa-button--dja svg,
|
||||
.usa-button--dja span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.usa-button--dja span {
|
||||
font-size: 14px;
|
||||
}
|
||||
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
a.button:active, a.button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
.usa-modal {
|
||||
font-family: inherit;
|
||||
}
|
||||
input[type=submit].button--dja-toolbar {
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
background: var(--body-bg);
|
||||
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
|
||||
cursor: pointer;
|
||||
color: var(--body-fg);
|
||||
}
|
||||
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
|
||||
border-color: var(--body-quiet-color);
|
||||
}
|
||||
// Targets the DJA buttom with a nested icon
|
||||
button .usa-icon,
|
||||
.button .usa-icon,
|
||||
.button--clipboard .usa-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.module--custom {
|
||||
|
@ -465,13 +517,6 @@ address.dja-address-contact-list {
|
|||
color: var(--link-fg);
|
||||
}
|
||||
|
||||
// Targets the DJA buttom with a nested icon
|
||||
button .usa-icon,
|
||||
.button .usa-icon,
|
||||
.button--clipboard .usa-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.errors span.select2-selection {
|
||||
border: 1px solid var(--error-fg) !important;
|
||||
}
|
||||
|
@ -540,7 +585,7 @@ button .usa-icon,
|
|||
#submitRowToggle {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.requested-domain-sticky {
|
||||
.submit-row-sticky {
|
||||
max-width: 325px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
@ -732,7 +777,7 @@ div.dja__model-description{
|
|||
|
||||
li {
|
||||
list-style-type: disc;
|
||||
font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif;
|
||||
font-family: family('sans');
|
||||
}
|
||||
|
||||
a, a:link, a:visited {
|
||||
|
@ -848,7 +893,40 @@ div.dja__model-description{
|
|||
}
|
||||
}
|
||||
|
||||
.vertical-separator {
|
||||
min-height: 20px;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: #d1d2d2;
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
.usa-summary-box_admin {
|
||||
color: var(--body-fg);
|
||||
border-color: var(--summary-box-border);
|
||||
background-color: var(--summary-box-bg);
|
||||
min-width: fit-content;
|
||||
padding: .5rem;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.text-faded {
|
||||
color: #{$dhs-gray-60};
|
||||
}
|
||||
ul.add-list-reset {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
// Fix the combobox when deployed outside admin (eg user transfer)
|
||||
.submit-row .select2,
|
||||
.submit-row .select2 span {
|
||||
margin-top: 0;
|
||||
}
|
||||
.transfer-user-selector .select2-selection__placeholder {
|
||||
color: #3d4551!important;
|
||||
}
|
||||
|
||||
.dl-dja dt {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
|
@ -33,16 +33,19 @@ body {
|
|||
}
|
||||
|
||||
#wrapper.dashboard--portfolio {
|
||||
background-color: color('gray-1');
|
||||
padding-top: units(4)!important;
|
||||
}
|
||||
|
||||
#wrapper.dashboard--grey-1 {
|
||||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
.section--outlined {
|
||||
|
||||
.section-outlined {
|
||||
background-color: color('white');
|
||||
border: 1px solid color('base-lighter');
|
||||
border-radius: 4px;
|
||||
padding: 0 units(2) units(3);
|
||||
padding: 0 units(4) units(3) units(2);
|
||||
margin-top: units(3);
|
||||
|
||||
&.margin-top-0 {
|
||||
|
@ -72,9 +75,13 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.section--outlined__header--no-portfolio {
|
||||
.section--outlined__search,
|
||||
.section--outlined__utility-button {
|
||||
.section-outlined--border-base-light {
|
||||
border: 1px solid color('base-light');
|
||||
}
|
||||
|
||||
.section-outlined__header--no-portfolio {
|
||||
.section-outlined__search,
|
||||
.section-outlined__utility-button {
|
||||
margin-top: units(2);
|
||||
}
|
||||
|
||||
|
@ -82,11 +89,11 @@ body {
|
|||
display: flex;
|
||||
column-gap: units(3);
|
||||
|
||||
.section--outlined__search,
|
||||
.section--outlined__utility-button {
|
||||
.section-outlined__search,
|
||||
.section-outlined__utility-button {
|
||||
margin-top: 0;
|
||||
}
|
||||
.section--outlined__search {
|
||||
.section-outlined__search {
|
||||
flex-grow: 4;
|
||||
// Align right
|
||||
max-width: 383px;
|
||||
|
@ -152,6 +159,23 @@ abbr[title] {
|
|||
}
|
||||
}
|
||||
|
||||
.hidden-mobile-flex {
|
||||
display: none!important;
|
||||
}
|
||||
.visible-mobile-flex {
|
||||
display: flex!important;
|
||||
}
|
||||
|
||||
@include at-media(tablet) {
|
||||
.hidden-mobile-flex {
|
||||
display: flex!important;
|
||||
}
|
||||
.visible-mobile-flex {
|
||||
display: none!important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.flex-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
@ -192,3 +216,12 @@ abbr[title] {
|
|||
max-width: 50ch;
|
||||
}
|
||||
}
|
||||
|
||||
// Boost this USWDS utility class for the accordions in the portfolio requests table
|
||||
.left-auto {
|
||||
left: auto!important;
|
||||
}
|
||||
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
|
@ -124,10 +124,6 @@ a.withdraw:active {
|
|||
background-color: color('error-darker');
|
||||
}
|
||||
|
||||
.usa-button--unstyled .usa-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
a.usa-button--unstyled:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
|
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
|
|||
}
|
||||
}
|
||||
|
||||
.input-with-edit-button {
|
||||
.toggleable_input {
|
||||
svg.usa-icon {
|
||||
width: 1.5em !important;
|
||||
height: 1.5em !important;
|
||||
color: #{$dhs-green};
|
||||
position: absolute;
|
||||
}
|
||||
&.input-with-edit-button__error {
|
||||
&.toggleable_input__error {
|
||||
svg.usa-icon {
|
||||
color: #{$dhs-red};
|
||||
}
|
||||
|
@ -205,12 +201,31 @@ a.usa-button--unstyled:visited {
|
|||
}
|
||||
}
|
||||
|
||||
.dotgov-table a,
|
||||
.usa-link--icon,
|
||||
.usa-button--with-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: color('primary');
|
||||
column-gap: units(.5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dotgov-table a
|
||||
a .usa-icon,
|
||||
.usa-button--with-icon .usa-icon {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
.usa-icon.usa-icon--big {
|
||||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
.margin-right-neg-4px {
|
||||
margin-right: -4px;
|
||||
}
|
||||
button.text-secondary,
|
||||
button.text-secondary:hover,
|
||||
.dotgov-table a.text-secondary {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
|
|
@ -89,16 +89,24 @@
|
|||
.usa-nav__primary {
|
||||
.usa-nav-link,
|
||||
.usa-nav-link:hover,
|
||||
.usa-nav-link:active {
|
||||
.usa-nav-link:active,
|
||||
button {
|
||||
color: color('primary');
|
||||
font-weight: font-weight('normal');
|
||||
font-size: 16px;
|
||||
}
|
||||
.usa-current,
|
||||
.usa-current:hover,
|
||||
.usa-current:active {
|
||||
.usa-current:active,
|
||||
button.usa-current {
|
||||
font-weight: font-weight('bold');
|
||||
}
|
||||
button[aria-expanded="true"] {
|
||||
color: color('white');
|
||||
}
|
||||
button:not(.usa-current):hover::after {
|
||||
display: none!important;
|
||||
}
|
||||
}
|
||||
.usa-nav__secondary {
|
||||
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
.dotgov-table a,
|
||||
.usa-link--icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: color('primary');
|
||||
|
||||
&:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
.usa-icon {
|
||||
// align icon with x height
|
||||
margin-top: units(0.5);
|
||||
margin-right: units(0.5);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
td,
|
||||
th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.dotgov-table--stacked {
|
||||
td, th {
|
||||
padding: units(1) units(2) units(2px) 0;
|
||||
|
@ -12,7 +17,7 @@
|
|||
|
||||
tr {
|
||||
border-bottom: none;
|
||||
border-top: 2px solid color('base-light');
|
||||
border-top: 2px solid color('base-lighter');
|
||||
margin-top: units(2);
|
||||
|
||||
&:first-child {
|
||||
|
@ -39,10 +44,6 @@
|
|||
.dotgov-table {
|
||||
width: 100%;
|
||||
|
||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
@ -56,7 +57,7 @@
|
|||
}
|
||||
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-light');
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
|
||||
thead th {
|
||||
|
@ -72,11 +73,17 @@
|
|||
|
||||
td, th,
|
||||
.usa-tabel th{
|
||||
padding: units(2) units(2) units(2) 0;
|
||||
padding: units(2) units(4) units(2) 0;
|
||||
}
|
||||
|
||||
thead tr:first-child th:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include at-media(tablet-lg) {
|
||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
--- Custom Styles ---------------------------------*/
|
||||
@forward "base";
|
||||
@forward "typography";
|
||||
@forward "links";
|
||||
@forward "lists";
|
||||
@forward "accordions";
|
||||
@forward "buttons";
|
||||
|
|
|
@ -23,6 +23,9 @@ from cfenv import AppEnv # type: ignore
|
|||
from pathlib import Path
|
||||
from typing import Final
|
||||
from botocore.config import Config
|
||||
import json
|
||||
import logging
|
||||
from django.utils.log import ServerFormatter
|
||||
|
||||
# # # ###
|
||||
# Setup code goes here #
|
||||
|
@ -57,7 +60,7 @@ env_db_url = env.dj_db_url("DATABASE_URL")
|
|||
env_debug = env.bool("DJANGO_DEBUG", default=False)
|
||||
env_is_production = env.bool("IS_PRODUCTION", default=False)
|
||||
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
|
||||
env_base_url = env.str("DJANGO_BASE_URL")
|
||||
env_base_url: str = env.str("DJANGO_BASE_URL")
|
||||
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
||||
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
||||
|
||||
|
@ -192,7 +195,7 @@ MIDDLEWARE = [
|
|||
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||
]
|
||||
|
||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
||||
# application object used by Django's built-in servers (e.g. `runserver`)
|
||||
WSGI_APPLICATION = "registrar.config.wsgi.application"
|
||||
|
||||
# endregion
|
||||
|
@ -357,13 +360,18 @@ CSP_FORM_ACTION = allowed_sources
|
|||
# and inline with a nonce, as well as allowing connections back to their domain.
|
||||
# Note: If needed, we can embed chart.js instead of using the CDN
|
||||
CSP_DEFAULT_SRC = ("'self'",)
|
||||
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
|
||||
CSP_STYLE_SRC = [
|
||||
"'self'",
|
||||
"https://www.ssa.gov/accessibility/andi/andi.css",
|
||||
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
|
||||
]
|
||||
CSP_SCRIPT_SRC_ELEM = [
|
||||
"'self'",
|
||||
"https://www.googletagmanager.com/",
|
||||
"https://cdn.jsdelivr.net/npm/chart.js",
|
||||
"https://www.ssa.gov",
|
||||
"https://ajax.googleapis.com",
|
||||
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
|
||||
]
|
||||
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
|
||||
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
|
||||
|
@ -410,7 +418,7 @@ LANGUAGE_COOKIE_SECURE = True
|
|||
# and to interpret datetimes entered in forms
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
# enable Django’s translation system
|
||||
# enable Django's translation system
|
||||
USE_I18N = True
|
||||
|
||||
# enable localized formatting of numbers and dates
|
||||
|
@ -445,6 +453,40 @@ PHONENUMBER_DEFAULT_REGION = "US"
|
|||
# logger.error("Can't do this important task. Something is very wrong.")
|
||||
# logger.critical("Going to crash now.")
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Formats logs into JSON for better parsing"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(datefmt="%d/%b/%Y %H:%M:%S")
|
||||
|
||||
def format(self, record):
|
||||
log_record = {
|
||||
"timestamp": self.formatTime(record, self.datefmt),
|
||||
"level": record.levelname,
|
||||
"name": record.name,
|
||||
"lineno": record.lineno,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
return json.dumps(log_record)
|
||||
|
||||
|
||||
class JsonServerFormatter(ServerFormatter):
|
||||
"""Formats server logs into JSON for better parsing"""
|
||||
|
||||
def format(self, record):
|
||||
formatted_record = super().format(record)
|
||||
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
||||
# default to json formatted logs
|
||||
server_formatter, console_formatter = "json.server", "json"
|
||||
|
||||
# don't use json format locally, it makes logs hard to read in console
|
||||
if "localhost" in env_base_url:
|
||||
server_formatter, console_formatter = "django.server", "verbose"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
# Don't import Django's existing loggers
|
||||
|
@ -464,6 +506,12 @@ LOGGING = {
|
|||
"format": "[{server_time}] {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"json.server": {
|
||||
"()": JsonServerFormatter,
|
||||
},
|
||||
"json": {
|
||||
"()": JsonFormatter,
|
||||
},
|
||||
},
|
||||
# define where log messages will be sent;
|
||||
# each logger can have one or more handlers
|
||||
|
@ -471,12 +519,12 @@ LOGGING = {
|
|||
"console": {
|
||||
"level": env_log_level,
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"formatter": console_formatter,
|
||||
},
|
||||
"django.server": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "django.server",
|
||||
"formatter": server_formatter,
|
||||
},
|
||||
# No file logger is configured,
|
||||
# because containerized apps
|
||||
|
|
|
@ -24,7 +24,11 @@ from registrar.views.report_views import (
|
|||
|
||||
from registrar.views.domain_request import Step
|
||||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
from registrar.views.utility.api_views import get_senior_official_from_federal_agency_json
|
||||
from registrar.views.transfer_user import TransferUserView
|
||||
from registrar.views.utility.api_views import (
|
||||
get_senior_official_from_federal_agency_json,
|
||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||
)
|
||||
from registrar.views.domains_json import get_domains_json
|
||||
from registrar.views.utility import always_404
|
||||
from api.views import available, get_current_federal, get_current_full
|
||||
|
@ -49,7 +53,6 @@ for step, view in [
|
|||
(Step.CURRENT_SITES, views.CurrentSites),
|
||||
(Step.DOTGOV_DOMAIN, views.DotgovDomain),
|
||||
(Step.PURPOSE, views.Purpose),
|
||||
(Step.YOUR_CONTACT, views.YourContact),
|
||||
(Step.OTHER_CONTACTS, views.OtherContacts),
|
||||
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
||||
(Step.REQUIREMENTS, views.Requirements),
|
||||
|
@ -75,6 +78,11 @@ urlpatterns = [
|
|||
views.PortfolioDomainRequestsView.as_view(),
|
||||
name="domain-requests",
|
||||
),
|
||||
path(
|
||||
"no-organization-requests/",
|
||||
views.PortfolioNoDomainRequestsView.as_view(),
|
||||
name="no-portfolio-requests",
|
||||
),
|
||||
path(
|
||||
"organization/",
|
||||
views.PortfolioOrganizationView.as_view(),
|
||||
|
@ -134,11 +142,17 @@ urlpatterns = [
|
|||
AnalyticsView.as_view(),
|
||||
name="analytics",
|
||||
),
|
||||
path("admin/registrar/user/<int:user_id>/transfer/", TransferUserView.as_view(), name="transfer_user"),
|
||||
path(
|
||||
"admin/api/get-senior-official-from-federal-agency-json/",
|
||||
get_senior_official_from_federal_agency_json,
|
||||
name="get-senior-official-from-federal-agency-json",
|
||||
),
|
||||
path(
|
||||
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
|
||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||
name="get-federal-and-portfolio-types-from-federal-agency-json",
|
||||
),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"reports/export_data_type_user/",
|
||||
|
@ -198,11 +212,6 @@ urlpatterns = [
|
|||
views.DomainDsDataView.as_view(),
|
||||
name="domain-dns-dnssec-dsdata",
|
||||
),
|
||||
path(
|
||||
"domain/<int:pk>/your-contact-information",
|
||||
views.DomainYourContactInformationView.as_view(),
|
||||
name="domain-your-contact-information",
|
||||
),
|
||||
path(
|
||||
"domain/<int:pk>/org-name-address",
|
||||
views.DomainOrgNameAddressView.as_view(),
|
||||
|
|
|
@ -60,28 +60,42 @@ def add_has_profile_feature_flag_to_context(request):
|
|||
|
||||
def portfolio_permissions(request):
|
||||
"""Make portfolio permissions for the request user available in global context"""
|
||||
portfolio_context = {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_any_domains_portfolio_permission": False,
|
||||
"has_any_requests_portfolio_permission": False,
|
||||
"has_edit_request_portfolio_permission": False,
|
||||
"has_view_suborganization_portfolio_permission": False,
|
||||
"has_edit_suborganization_portfolio_permission": False,
|
||||
"has_view_members_portfolio_permission": False,
|
||||
"has_edit_members_portfolio_permission": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
"has_organization_requests_flag": False,
|
||||
"has_organization_members_flag": False,
|
||||
}
|
||||
try:
|
||||
if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
|
||||
portfolio = request.session.get("portfolio")
|
||||
# Linting: line too long
|
||||
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
|
||||
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
|
||||
if portfolio:
|
||||
return {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
|
||||
"has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
|
||||
"has_view_suborganization_portfolio_permission": view_suborg,
|
||||
"has_edit_suborganization_portfolio_permission": edit_suborg,
|
||||
"has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
|
||||
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
|
||||
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
|
||||
"has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
|
||||
"portfolio": portfolio,
|
||||
"has_organization_feature_flag": True,
|
||||
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
|
||||
"has_organization_members_flag": request.user.has_organization_members_flag(),
|
||||
}
|
||||
return {
|
||||
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
|
||||
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
|
||||
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
|
||||
"portfolio": request.user.portfolio,
|
||||
"has_organization_feature_flag": True,
|
||||
}
|
||||
return portfolio_context
|
||||
|
||||
except AttributeError:
|
||||
# Handles cases where request.user might not exist
|
||||
return {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
return portfolio_context
|
||||
|
|
|
@ -37,7 +37,6 @@ class DomainRequestFixture:
|
|||
# "anything_else": None,
|
||||
# "is_policy_acknowledged": None,
|
||||
# "senior_official": None,
|
||||
# "submitter": None,
|
||||
# "other_contacts": [],
|
||||
# "current_websites": [],
|
||||
# "alternative_domains": [],
|
||||
|
@ -95,7 +94,7 @@ class DomainRequestFixture:
|
|||
|
||||
# TODO for a future ticket: Allow for more than just "federal" here
|
||||
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
|
||||
da.submission_date = fake.date()
|
||||
da.last_submitted_date = fake.date()
|
||||
da.federal_type = (
|
||||
app["federal_type"]
|
||||
if "federal_type" in app
|
||||
|
@ -123,12 +122,6 @@ class DomainRequestFixture:
|
|||
else:
|
||||
da.senior_official = Contact.objects.create(**cls.fake_contact())
|
||||
|
||||
if not da.submitter:
|
||||
if "submitter" in app and app["submitter"] is not None:
|
||||
da.submitter, _ = Contact.objects.get_or_create(**app["submitter"])
|
||||
else:
|
||||
da.submitter = Contact.objects.create(**cls.fake_contact())
|
||||
|
||||
if not da.requested_domain:
|
||||
if "requested_domain" in app and app["requested_domain"] is not None:
|
||||
da.requested_domain, _ = DraftDomain.objects.get_or_create(name=app["requested_domain"])
|
||||
|
|
|
@ -6,6 +6,7 @@ from registrar.models import (
|
|||
User,
|
||||
UserGroup,
|
||||
)
|
||||
from registrar.models.allowed_email import AllowedEmail
|
||||
|
||||
|
||||
fake = Faker()
|
||||
|
@ -32,6 +33,7 @@ class UserFixture:
|
|||
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
||||
"first_name": "Aditi",
|
||||
"last_name": "Green",
|
||||
"email": "aditidevelops+01@gmail.com",
|
||||
},
|
||||
{
|
||||
"username": "be17c826-e200-4999-9389-2ded48c43691",
|
||||
|
@ -42,16 +44,19 @@ class UserFixture:
|
|||
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
|
||||
"first_name": "Rachid",
|
||||
"last_name": "Mrad",
|
||||
"email": "rachid.mrad@associates.cisa.dhs.gov",
|
||||
},
|
||||
{
|
||||
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
|
||||
"first_name": "Alysia",
|
||||
"last_name": "Broddrick",
|
||||
"email": "abroddrick@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
|
||||
"first_name": "Katherine",
|
||||
"last_name": "Osos",
|
||||
"email": "kosos@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "70488e0a-e937-4894-a28c-16f5949effd4",
|
||||
|
@ -63,6 +68,7 @@ class UserFixture:
|
|||
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
|
||||
"first_name": "Cameron",
|
||||
"last_name": "Dixon",
|
||||
"email": "cameron.dixon@cisa.dhs.gov",
|
||||
},
|
||||
{
|
||||
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
|
||||
|
@ -83,16 +89,19 @@ class UserFixture:
|
|||
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
|
||||
"first_name": "Rebecca",
|
||||
"last_name": "Hsieh",
|
||||
"email": "rebecca.hsieh@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
||||
"first_name": "David",
|
||||
"last_name": "Kennedy",
|
||||
"email": "david.kennedy@ecstech.com",
|
||||
},
|
||||
{
|
||||
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
|
||||
"first_name": "Nicolle",
|
||||
"last_name": "LeClair",
|
||||
"email": "nicolle.leclair@ecstech.com",
|
||||
},
|
||||
{
|
||||
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
|
||||
|
@ -141,6 +150,7 @@ class UserFixture:
|
|||
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
|
||||
"first_name": "Aditi-Analyst",
|
||||
"last_name": "Green-Analyst",
|
||||
"email": "aditidevelops+02@gmail.com",
|
||||
},
|
||||
{
|
||||
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
|
||||
|
@ -162,7 +172,7 @@ class UserFixture:
|
|||
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
|
||||
"first_name": "Katherine-Analyst",
|
||||
"last_name": "Osos-Analyst",
|
||||
"email": "kosos@truss.works",
|
||||
"email": "kosos+1@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
|
||||
|
@ -183,6 +193,7 @@ class UserFixture:
|
|||
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
||||
"first_name": "David-Analyst",
|
||||
"last_name": "Kennedy-Analyst",
|
||||
"email": "david.kennedy@associates.cisa.dhs.gov",
|
||||
},
|
||||
{
|
||||
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
|
||||
|
@ -194,7 +205,7 @@ class UserFixture:
|
|||
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
|
||||
"first_name": "Nicolle-Analyst",
|
||||
"last_name": "LeClair-Analyst",
|
||||
"email": "nicolle.leclair@ecstech.com",
|
||||
"email": "nicolle.leclair@gmail.com",
|
||||
},
|
||||
{
|
||||
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
|
||||
|
@ -240,6 +251,9 @@ class UserFixture:
|
|||
},
|
||||
]
|
||||
|
||||
# Additional emails to add to the AllowedEmail whitelist.
|
||||
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"]
|
||||
|
||||
def load_users(cls, users, group_name, are_superusers=False):
|
||||
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
||||
for user_data in users:
|
||||
|
@ -264,6 +278,32 @@ class UserFixture:
|
|||
logger.warning(e)
|
||||
logger.info(f"All users in group {group_name} loaded.")
|
||||
|
||||
def load_allowed_emails(cls, users, additional_emails):
|
||||
"""Populates a whitelist of allowed emails (as defined in this list)"""
|
||||
logger.info(f"Going to load allowed emails for {len(users)} users")
|
||||
if additional_emails:
|
||||
logger.info(f"Going to load {len(additional_emails)} additional allowed emails")
|
||||
|
||||
# Load user emails
|
||||
allowed_emails = []
|
||||
for user_data in users:
|
||||
user_email = user_data.get("email")
|
||||
if user_email and user_email not in allowed_emails:
|
||||
allowed_emails.append(AllowedEmail(email=user_email))
|
||||
else:
|
||||
first_name = user_data.get("first_name")
|
||||
last_name = user_data.get("last_name")
|
||||
logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.")
|
||||
|
||||
# Load additional emails
|
||||
allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails])
|
||||
|
||||
if allowed_emails:
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
logger.info(f"Loaded {len(allowed_emails)} allowed emails")
|
||||
else:
|
||||
logger.info("No allowed emails to load")
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||
|
@ -275,3 +315,7 @@ class UserFixture:
|
|||
with transaction.atomic():
|
||||
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
||||
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
||||
|
||||
# Combine ADMINS and STAFF lists
|
||||
all_users = cls.ADMINS + cls.STAFF
|
||||
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
|
||||
|
|
|
@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
|
|||
# This action should be blocked by the UI, as the text fields are readonly.
|
||||
# If they get past this point, we forbid it this way.
|
||||
# This could be malicious, so lets reserve information for the backend only.
|
||||
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
|
||||
raise ValueError("Senior official cannot be modified for federal or tribal domains.")
|
||||
elif db_so.has_more_than_one_join("information_senior_official"):
|
||||
# Handle the case where the domain information object is available and the SO Contact
|
||||
# has more than one joined object.
|
||||
|
|
|
@ -386,64 +386,6 @@ class PurposeForm(RegistrarForm):
|
|||
)
|
||||
|
||||
|
||||
class YourContactForm(RegistrarForm):
|
||||
JOIN = "submitter"
|
||||
|
||||
def to_database(self, obj):
|
||||
if not self.is_valid():
|
||||
return
|
||||
contact = getattr(obj, "submitter", None)
|
||||
if contact is not None and not contact.has_more_than_one_join("submitted_domain_requests"):
|
||||
# if contact exists in the database and is not joined to other entities
|
||||
super().to_database(contact)
|
||||
else:
|
||||
# no contact exists OR contact exists which is joined also to other entities;
|
||||
# in either case, create a new contact and update it
|
||||
contact = Contact()
|
||||
super().to_database(contact)
|
||||
obj.submitter = contact
|
||||
obj.save()
|
||||
|
||||
@classmethod
|
||||
def from_database(cls, obj):
|
||||
contact = getattr(obj, "submitter", None)
|
||||
return super().from_database(contact)
|
||||
|
||||
first_name = forms.CharField(
|
||||
label="First name / given name",
|
||||
error_messages={"required": "Enter your first name / given name."},
|
||||
)
|
||||
middle_name = forms.CharField(
|
||||
required=False,
|
||||
label="Middle name (optional)",
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label="Last name / family name",
|
||||
error_messages={"required": "Enter your last name / family name."},
|
||||
)
|
||||
title = forms.CharField(
|
||||
label="Title or role in your organization",
|
||||
error_messages={
|
||||
"required": ("Enter your title or role in your organization (e.g., Chief Information Officer).")
|
||||
},
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
label="Phone",
|
||||
error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."},
|
||||
)
|
||||
|
||||
|
||||
class OtherContactsYesNoForm(BaseYesNoForm):
|
||||
"""The yes/no field for the OtherContacts form."""
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class Command(BaseCommand):
|
|||
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect="""
|
||||
prompt_message="""
|
||||
This script will delete all rows from the following tables:
|
||||
* Contact
|
||||
* Domain
|
||||
|
|
255
src/registrar/management/commands/create_federal_portfolio.py
Normal file
255
src/registrar/management/commands/create_federal_portfolio.py
Normal file
|
@ -0,0 +1,255 @@
|
|||
"""Loads files from /tmp into our sandboxes"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates a federal portfolio given a FederalAgency name"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add three arguments:
|
||||
1. agency_name => the value of FederalAgency.agency
|
||||
2. --parse_requests => if true, adds the given portfolio to each related DomainRequest
|
||||
3. --parse_domains => if true, adds the given portfolio to each related DomainInformation
|
||||
"""
|
||||
parser.add_argument(
|
||||
"agency_name",
|
||||
help="The name of the FederalAgency to add",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parse_requests",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Adds portfolio to DomainRequests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parse_domains",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Adds portfolio to DomainInformation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--both",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Adds portfolio to both requests and domains",
|
||||
)
|
||||
|
||||
def handle(self, agency_name, **options):
|
||||
parse_requests = options.get("parse_requests")
|
||||
parse_domains = options.get("parse_domains")
|
||||
both = options.get("both")
|
||||
|
||||
if not both:
|
||||
if not parse_requests and not parse_domains:
|
||||
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
||||
else:
|
||||
if parse_requests or parse_domains:
|
||||
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||
|
||||
federal_agency = FederalAgency.objects.filter(agency__iexact=agency_name).first()
|
||||
if not federal_agency:
|
||||
raise ValueError(
|
||||
f"Cannot find the federal agency '{agency_name}' in our database. "
|
||||
"The value you enter for `agency_name` must be "
|
||||
"prepopulated in the FederalAgency table before proceeding."
|
||||
)
|
||||
|
||||
portfolio = self.create_or_modify_portfolio(federal_agency)
|
||||
self.create_suborganizations(portfolio, federal_agency)
|
||||
|
||||
if parse_requests or both:
|
||||
self.handle_portfolio_requests(portfolio, federal_agency)
|
||||
|
||||
if parse_domains or both:
|
||||
self.handle_portfolio_domains(portfolio, federal_agency)
|
||||
|
||||
def create_or_modify_portfolio(self, federal_agency):
|
||||
"""Creates or modifies a portfolio record based on a federal agency."""
|
||||
portfolio_args = {
|
||||
"federal_agency": federal_agency,
|
||||
"organization_name": federal_agency.agency,
|
||||
"organization_type": DomainRequest.OrganizationChoices.FEDERAL,
|
||||
"creator": User.get_default_user(),
|
||||
"notes": "Auto-generated record",
|
||||
}
|
||||
|
||||
if federal_agency.so_federal_agency.exists():
|
||||
portfolio_args["senior_official"] = federal_agency.so_federal_agency.first()
|
||||
|
||||
portfolio, created = Portfolio.objects.get_or_create(
|
||||
organization_name=portfolio_args.get("organization_name"), defaults=portfolio_args
|
||||
)
|
||||
|
||||
if created:
|
||||
message = f"Created portfolio '{portfolio}'"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||
|
||||
if portfolio_args.get("senior_official"):
|
||||
message = f"Added senior official '{portfolio_args['senior_official']}'"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||
else:
|
||||
message = (
|
||||
f"No senior official added to portfolio '{portfolio}'. "
|
||||
"None was returned for the reverse relation `FederalAgency.so_federal_agency.first()`"
|
||||
)
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||
else:
|
||||
proceed = TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=False,
|
||||
prompt_message=f"""
|
||||
The given portfolio '{federal_agency.agency}' already exists in our DB.
|
||||
If you cancel, the rest of the script will still execute but this record will not update.
|
||||
""",
|
||||
prompt_title="Do you wish to modify this record?",
|
||||
)
|
||||
if proceed:
|
||||
|
||||
# Don't override the creator and notes fields
|
||||
if portfolio.creator:
|
||||
portfolio_args.pop("creator")
|
||||
|
||||
if portfolio.notes:
|
||||
portfolio_args.pop("notes")
|
||||
|
||||
# Update everything else
|
||||
for key, value in portfolio_args.items():
|
||||
setattr(portfolio, key, value)
|
||||
|
||||
portfolio.save()
|
||||
message = f"Modified portfolio '{portfolio}'"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
|
||||
if portfolio_args.get("senior_official"):
|
||||
message = f"Added/modified senior official '{portfolio_args['senior_official']}'"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
|
||||
return portfolio
|
||||
|
||||
def create_suborganizations(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||
"""Create Suborganizations tied to the given portfolio based on DomainInformation objects"""
|
||||
valid_agencies = DomainInformation.objects.filter(
|
||||
federal_agency=federal_agency, organization_name__isnull=False
|
||||
)
|
||||
org_names = set(valid_agencies.values_list("organization_name", flat=True))
|
||||
|
||||
if not org_names:
|
||||
message = (
|
||||
"Could not add any suborganizations."
|
||||
f"\nNo suborganizations were found for '{federal_agency}' when filtering on this name, "
|
||||
"and excluding null organization_name records."
|
||||
)
|
||||
TerminalHelper.colorful_logger(logger.warning, TerminalColors.FAIL, message)
|
||||
return
|
||||
|
||||
# Check if we need to update any existing suborgs first. This step is optional.
|
||||
existing_suborgs = Suborganization.objects.filter(name__in=org_names)
|
||||
if existing_suborgs.exists():
|
||||
self._update_existing_suborganizations(portfolio, existing_suborgs)
|
||||
|
||||
# Create new suborgs, as long as they don't exist in the db already
|
||||
new_suborgs = []
|
||||
for name in org_names - set(existing_suborgs.values_list("name", flat=True)):
|
||||
# Stored in variables due to linter wanting type information here.
|
||||
portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else ""
|
||||
if name is not None and name.lower() == portfolio_name.lower():
|
||||
# You can use this to populate location information, when this occurs.
|
||||
# However, this isn't needed for now so we can skip it.
|
||||
message = (
|
||||
f"Skipping suborganization create on record '{name}'. "
|
||||
"The federal agency name is the same as the portfolio name."
|
||||
)
|
||||
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message)
|
||||
else:
|
||||
new_suborgs.append(Suborganization(name=name, portfolio=portfolio)) # type: ignore
|
||||
|
||||
if new_suborgs:
|
||||
Suborganization.objects.bulk_create(new_suborgs)
|
||||
TerminalHelper.colorful_logger(
|
||||
logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations"
|
||||
)
|
||||
else:
|
||||
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, "No suborganizations added")
|
||||
|
||||
def _update_existing_suborganizations(self, portfolio, orgs_to_update):
|
||||
"""
|
||||
Update existing suborganizations with new portfolio.
|
||||
Prompts for user confirmation before proceeding.
|
||||
"""
|
||||
proceed = TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=False,
|
||||
prompt_message=f"""Some suborganizations already exist in our DB.
|
||||
If you cancel, the rest of the script will still execute but these records will not update.
|
||||
|
||||
==Proposed Changes==
|
||||
The following suborgs will be updated: {[org.name for org in orgs_to_update]}
|
||||
""",
|
||||
prompt_title="Do you wish to modify existing suborganizations?",
|
||||
)
|
||||
if proceed:
|
||||
for org in orgs_to_update:
|
||||
org.portfolio = portfolio
|
||||
|
||||
Suborganization.objects.bulk_update(orgs_to_update, ["portfolio"])
|
||||
message = f"Updated {len(orgs_to_update)} suborganizations."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||
|
||||
def handle_portfolio_requests(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||
"""
|
||||
Associate portfolio with domain requests for a federal agency.
|
||||
Updates all relevant domain request records.
|
||||
"""
|
||||
invalid_states = [
|
||||
DomainRequest.DomainRequestStatus.STARTED,
|
||||
DomainRequest.DomainRequestStatus.INELIGIBLE,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
]
|
||||
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states)
|
||||
if not domain_requests.exists():
|
||||
message = f"""
|
||||
Portfolios not added to domain requests: no valid records found.
|
||||
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||
Excluded statuses: STARTED, INELIGIBLE, REJECTED.
|
||||
"""
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||
return None
|
||||
|
||||
# Get all suborg information and store it in a dict to avoid doing a db call
|
||||
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||
for domain_request in domain_requests:
|
||||
domain_request.portfolio = portfolio
|
||||
if domain_request.organization_name in suborgs:
|
||||
domain_request.sub_organization = suborgs.get(domain_request.organization_name)
|
||||
|
||||
DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"])
|
||||
message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests."
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||
|
||||
def handle_portfolio_domains(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||
"""
|
||||
Associate portfolio with domains for a federal agency.
|
||||
Updates all relevant domain information records.
|
||||
"""
|
||||
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency)
|
||||
if not domain_infos.exists():
|
||||
message = f"""
|
||||
Portfolios not added to domains: no valid records found.
|
||||
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||
"""
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||
return None
|
||||
|
||||
# Get all suborg information and store it in a dict to avoid doing a db call
|
||||
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||
for domain_info in domain_infos:
|
||||
domain_info.portfolio = portfolio
|
||||
if domain_info.organization_name in suborgs:
|
||||
domain_info.sub_organization = suborgs.get(domain_info.organization_name)
|
||||
|
||||
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
|
||||
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains"
|
||||
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
|
@ -130,7 +130,7 @@ class Command(BaseCommand):
|
|||
"""Asks if the user wants to proceed with this action"""
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Extension Amount==
|
||||
Period: {extension_amount} year(s)
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ class Command(BaseCommand):
|
|||
# Will sys.exit() when prompt is "n"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Master data file==
|
||||
domain_additional_filename: {org_args.domain_additional_filename}
|
||||
|
||||
|
@ -84,7 +84,7 @@ class Command(BaseCommand):
|
|||
# Will sys.exit() when prompt is "n"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Master data file==
|
||||
domain_additional_filename: {org_args.domain_additional_filename}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ class Command(BaseCommand):
|
|||
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
CSV: {federal_cio_csv_path}
|
||||
|
||||
|
|
|
@ -651,7 +651,7 @@ class Command(BaseCommand):
|
|||
title = "Do you wish to load additional data for TransitionDomains?"
|
||||
proceed = TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
|
||||
==Master data file==
|
||||
domain_additional_filename: {domain_additional_filename}
|
||||
|
|
|
@ -91,7 +91,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainInformation objects to change: {len(human_readable_domain_names)}
|
||||
The following DomainInformation objects will be modified: {human_readable_domain_names}
|
||||
|
@ -148,7 +148,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==File location==
|
||||
current-full.csv filepath: {file_path}
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||
from registrar.models import DomainRequest
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand, PopulateScriptTemplate):
|
||||
help = "Loops through each domain request object and populates the last_status_update and first_submitted_date"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Loops through each DomainRequest object and populates
|
||||
its last_status_update and first_submitted_date values"""
|
||||
self.mass_update_records(DomainRequest, None, ["last_status_update", "first_submitted_date"])
|
||||
|
||||
def update_record(self, record: DomainRequest):
|
||||
"""Defines how we update the first_submitted_date and last_status_update fields"""
|
||||
|
||||
# Retrieve and order audit log entries by timestamp in descending order
|
||||
audit_log_entries = LogEntry.objects.filter(object_pk=record.pk).order_by("-timestamp")
|
||||
# Loop through logs in descending order to find most recent status change
|
||||
for log_entry in audit_log_entries:
|
||||
if "status" in log_entry.changes_dict:
|
||||
record.last_status_update = log_entry.timestamp.date()
|
||||
break
|
||||
|
||||
# Loop through logs in ascending order to find first submission
|
||||
for log_entry in audit_log_entries.reverse():
|
||||
status = log_entry.changes_dict.get("status")
|
||||
if status and status[1] == "submitted":
|
||||
record.first_submitted_date = log_entry.timestamp.date()
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"""{TerminalColors.OKCYAN}Updating {record} =>
|
||||
first submitted date: {record.first_submitted_date},
|
||||
last status update: {record.last_status_update}{TerminalColors.ENDC}
|
||||
"""
|
||||
)
|
||||
|
||||
def should_skip_record(self, record) -> bool:
|
||||
# make sure the record had some kind of history
|
||||
return not LogEntry.objects.filter(object_pk=record.pk).exists()
|
|
@ -31,7 +31,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of Domain objects to change: {len(domains)}
|
||||
""",
|
||||
|
|
|
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainRequest objects to change: {len(domain_requests)}
|
||||
|
||||
|
@ -72,7 +72,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainInformation objects to change: {len(domain_infos)}
|
||||
|
||||
|
|
|
@ -423,7 +423,7 @@ class Command(BaseCommand):
|
|||
valid_fed_type = fed_type in fed_choices
|
||||
valid_fed_agency = fed_agency in agency_choices
|
||||
|
||||
default_creator, _ = User.objects.get_or_create(username="System")
|
||||
default_creator = User.get_default_user()
|
||||
|
||||
new_domain_info_data = {
|
||||
"domain": domain,
|
||||
|
|
38
src/registrar/management/commands/update_first_ready.py
Normal file
38
src/registrar/management/commands/update_first_ready.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||
from registrar.models import Domain, TransitionDomain
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand, PopulateScriptTemplate):
|
||||
help = "Loops through each domain object and populates the last_status_update and first_submitted_date"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Loops through each valid Domain object and updates it's first_ready value if it is out of sync"""
|
||||
filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]}
|
||||
self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True)
|
||||
|
||||
def update_record(self, record: Domain):
|
||||
"""Defines how we update the first_ready field"""
|
||||
# update the first_ready value based on the creation date.
|
||||
record.first_ready = record.created_at.date()
|
||||
|
||||
logger.info(
|
||||
f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}"
|
||||
)
|
||||
|
||||
# check if a transition domain object for this domain name exists,
|
||||
# or if so whether its first_ready value matches its created_at date
|
||||
def custom_filter(self, records):
|
||||
to_include_pks = []
|
||||
for record in records:
|
||||
if (
|
||||
TransitionDomain.objects.filter(domain_name=record.name).exists()
|
||||
and record.first_ready != record.created_at.date()
|
||||
): # noqa
|
||||
to_include_pks.append(record.pk)
|
||||
|
||||
return records.filter(pk__in=to_include_pks)
|
|
@ -2,9 +2,12 @@ import logging
|
|||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Model
|
||||
from django.db.models.manager import BaseManager
|
||||
from typing import List
|
||||
from registrar.utility.enums import LogCode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def update_record(self, record):
|
||||
"""Defines how we update each field. Must be defined before using mass_update_records."""
|
||||
"""Defines how we update each field.
|
||||
|
||||
raises:
|
||||
NotImplementedError: If not defined before calling mass_update_records.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
|
||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False):
|
||||
"""Loops through each valid "object_class" object - specified by filter_conditions - and
|
||||
updates fields defined by fields_to_update using update_record.
|
||||
|
||||
You must define update_record before you can use this function.
|
||||
Parameters:
|
||||
object_class: The Django model class that you want to perform the bulk update on.
|
||||
This should be the actual class, not a string of the class name.
|
||||
|
||||
filter_conditions: dictionary of valid Django Queryset filter conditions
|
||||
(e.g. {'verification_type__isnull'=True}).
|
||||
|
||||
fields_to_update: List of strings specifying which fields to update.
|
||||
(e.g. ["first_ready_date", "last_submitted_date"])
|
||||
|
||||
debug: Whether to log script run summary in debug mode.
|
||||
Default: True.
|
||||
|
||||
verbose: Whether to print a detailed run summary *before* run confirmation.
|
||||
Default: False.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If you do not define update_record before using this function.
|
||||
TypeError: If custom_filter is not Callable.
|
||||
"""
|
||||
|
||||
records = object_class.objects.filter(**filter_conditions)
|
||||
records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
|
||||
|
||||
# apply custom filter
|
||||
records = self.custom_filter(records)
|
||||
|
||||
readable_class_name = self.get_class_name(object_class)
|
||||
|
||||
# for use in the execution prompt.
|
||||
proposed_changes = f"""==Proposed Changes==
|
||||
Number of {readable_class_name} objects to change: {len(records)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
"""
|
||||
|
||||
if verbose:
|
||||
proposed_changes = f"""{proposed_changes}
|
||||
These records will be updated: {list(records.all())}
|
||||
"""
|
||||
|
||||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
==Proposed Changes==
|
||||
Number of {readable_class_name} objects to change: {len(records)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
""",
|
||||
prompt_message=proposed_changes,
|
||||
prompt_title=self.prompt_title,
|
||||
)
|
||||
logger.info("Updating...")
|
||||
|
@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC):
|
|||
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
|
||||
|
||||
def should_skip_record(self, record) -> bool: # noqa
|
||||
"""Defines the condition in which we should skip updating a record. Override as needed."""
|
||||
"""Defines the condition in which we should skip updating a record. Override as needed.
|
||||
The difference between this and custom_filter is that records matching these conditions
|
||||
*will* be included in the run but will be skipped (and logged as such)."""
|
||||
# By default - don't skip
|
||||
return False
|
||||
|
||||
def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]:
|
||||
"""Override to define filters that can't be represented by django queryset field lookups.
|
||||
Applied to individual records *after* filter_conditions. True means"""
|
||||
return records
|
||||
|
||||
|
||||
class TerminalHelper:
|
||||
@staticmethod
|
||||
|
@ -220,6 +263,9 @@ class TerminalHelper:
|
|||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is True for "yes" or False for "no".
|
||||
|
||||
Raises:
|
||||
ValueError: When "default" is not "yes", "no", or None.
|
||||
"""
|
||||
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
|
||||
if default is None:
|
||||
|
@ -244,6 +290,7 @@ class TerminalHelper:
|
|||
@staticmethod
|
||||
def query_yes_no_exit(question: str, default="yes"):
|
||||
"""Ask a yes/no question via raw_input() and return their answer.
|
||||
Allows for answer "e" to exit.
|
||||
|
||||
"question" is a string that is presented to the user.
|
||||
"default" is the presumed answer if the user just hits <Enter>.
|
||||
|
@ -251,6 +298,9 @@ class TerminalHelper:
|
|||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is True for "yes" or False for "no".
|
||||
|
||||
Raises:
|
||||
ValueError: When "default" is not "yes", "no", or None.
|
||||
"""
|
||||
valid = {
|
||||
"yes": True,
|
||||
|
@ -317,9 +367,8 @@ class TerminalHelper:
|
|||
case _:
|
||||
logger.info(print_statement)
|
||||
|
||||
# TODO - "info_to_inspect" should be refactored to "prompt_message"
|
||||
@staticmethod
|
||||
def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool:
|
||||
def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool:
|
||||
"""Create to reduce code complexity.
|
||||
Prompts the user to inspect the given string
|
||||
and asks if they wish to proceed.
|
||||
|
@ -340,7 +389,7 @@ class TerminalHelper:
|
|||
=====================================================
|
||||
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
|
||||
|
||||
{info_to_inspect}
|
||||
{prompt_message}
|
||||
{TerminalColors.FAIL}
|
||||
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
|
||||
{TerminalColors.ENDC}"""
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
# Generated by Django 4.2.10 on 2024-08-19 20:24
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="portfolio",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="portfolio_additional_permissions",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="portfolio_roles",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserPortfolioPermission",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"roles",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("organization_admin", "Admin"),
|
||||
("organization_admin_read_only", "Admin read only"),
|
||||
("organization_member", "Member"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more roles.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"additional_permissions",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_member", "View members"),
|
||||
("edit_member", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"portfolio",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="portfolio_users",
|
||||
to="registrar.portfolio",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="portfolio_permissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("user", "portfolio")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,47 @@
|
|||
# Generated by Django 4.2.10 on 2024-08-16 15:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0119_remove_user_portfolio_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="domainrequest",
|
||||
old_name="submission_date",
|
||||
new_name="last_submitted_date",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domainrequest",
|
||||
name="last_submitted_date",
|
||||
field=models.DateField(
|
||||
blank=True, default=None, help_text="Date last submitted", null=True, verbose_name="last submitted on"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="first_submitted_date",
|
||||
field=models.DateField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Date initially submitted",
|
||||
null=True,
|
||||
verbose_name="first submitted on",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="last_status_update",
|
||||
field=models.DateField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Date of the last status update",
|
||||
null=True,
|
||||
verbose_name="last updated on",
|
||||
),
|
||||
),
|
||||
]
|
25
src/registrar/migrations/0121_allowedemail.py
Normal file
25
src/registrar/migrations/0121_allowedemail.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.2.10 on 2024-08-29 18:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0120_add_domainrequest_submission_dates"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AllowedEmail",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("email", models.EmailField(max_length=320, unique=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||
# It is dependent on 0079 (which populates federal agencies)
|
||||
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||
# in the user_group model then:
|
||||
# [NOT RECOMMENDED]
|
||||
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||
# step 3: fake run the latest migration in the migrations list
|
||||
# [RECOMMENDED]
|
||||
# Alternatively:
|
||||
# step 1: duplicate the migration that loads data
|
||||
# step 2: docker-compose exec app ./manage.py migrate
|
||||
|
||||
from django.db import migrations
|
||||
from registrar.models import UserGroup
|
||||
from typing import Any
|
||||
|
||||
|
||||
# For linting: RunPython expects a function reference,
|
||||
# so let's give it one
|
||||
def create_groups(apps, schema_editor) -> Any:
|
||||
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||
UserGroup.create_full_access_group(apps, schema_editor)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0121_allowedemail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_groups,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
atomic=True,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,66 @@
|
|||
# Generated by Django 4.2.10 on 2024-09-04 21:29
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0122_create_groups_v16"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="portfolioinvitation",
|
||||
name="portfolio_additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userportfoliopermission",
|
||||
name="additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,64 @@
|
|||
# Generated by Django 4.2.10 on 2024-09-09 14:48
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="portfolioinvitation",
|
||||
name="portfolio_additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userportfoliopermission",
|
||||
name="additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 4.2.10 on 2024-08-29 23:17
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="domaininformation",
|
||||
name="submitter",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Person listed under "your contact information" in the request form',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="submitted_domain_requests_information",
|
||||
to="registrar.contact",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domainrequest",
|
||||
name="submitter",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Person listed under "your contact information" in the request form; will receive email updates',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="submitted_domain_requests",
|
||||
to="registrar.contact",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,27 @@
|
|||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Deletes Contact objects associated with a submitter which we are deprecating
|
||||
def cascade_delete_submitter_contacts(apps, schema_editor) -> Any:
|
||||
contacts_model = apps.get_model("registrar", "Contact")
|
||||
submitter_contacts = contacts_model.objects.filter(
|
||||
Q(submitted_domain_requests__isnull=False) | Q(submitted_domain_requests_information__isnull=False)
|
||||
).filter(
|
||||
information_senior_official__isnull=True,
|
||||
senior_official__isnull=True,
|
||||
contact_domain_requests_information__isnull=True,
|
||||
contact_domain_requests__isnull=True,
|
||||
)
|
||||
submitter_contacts.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0125_alter_domaininformation_submitter_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(cascade_delete_submitter_contacts, reverse_code=migrations.RunPython.noop, atomic=True),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 4.2.10 on 2024-08-29 24:13
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0126_delete_cascade_submitter_contacts"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="domaininformation",
|
||||
name="submitter",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="domainrequest",
|
||||
name="submitter",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domainrequest",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
help_text="Person who submitted the domain request. Will receive email updates.",
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="domain_requests_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -21,6 +21,8 @@ from .portfolio import Portfolio
|
|||
from .domain_group import DomainGroup
|
||||
from .suborganization import Suborganization
|
||||
from .senior_official import SeniorOfficial
|
||||
from .user_portfolio_permission import UserPortfolioPermission
|
||||
from .allowed_email import AllowedEmail
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
@ -46,6 +48,8 @@ __all__ = [
|
|||
"DomainGroup",
|
||||
"Suborganization",
|
||||
"SeniorOfficial",
|
||||
"UserPortfolioPermission",
|
||||
"AllowedEmail",
|
||||
]
|
||||
|
||||
auditlog.register(Contact)
|
||||
|
@ -70,3 +74,5 @@ auditlog.register(Portfolio)
|
|||
auditlog.register(DomainGroup)
|
||||
auditlog.register(Suborganization)
|
||||
auditlog.register(SeniorOfficial)
|
||||
auditlog.register(UserPortfolioPermission)
|
||||
auditlog.register(AllowedEmail)
|
||||
|
|
52
src/registrar/models/allowed_email.py
Normal file
52
src/registrar/models/allowed_email.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
import re
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
class AllowedEmail(TimeStampedModel):
|
||||
"""
|
||||
AllowedEmail is a whitelist for email addresses that we can send to
|
||||
in non-production environments.
|
||||
"""
|
||||
|
||||
email = models.EmailField(
|
||||
unique=True,
|
||||
null=False,
|
||||
blank=False,
|
||||
max_length=320,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_allowed_email(cls, email):
|
||||
"""Given an email, check if this email exists within our AllowEmail whitelist"""
|
||||
|
||||
if not email:
|
||||
return False
|
||||
|
||||
# Split the email into a local part and a domain part
|
||||
local, domain = email.split("@")
|
||||
|
||||
# If the email exists within the whitelist, then do nothing else.
|
||||
email_exists = cls.objects.filter(email__iexact=email).exists()
|
||||
if email_exists:
|
||||
return True
|
||||
|
||||
# Check if there's a '+' in the local part
|
||||
if "+" in local:
|
||||
base_local = local.split("+")[0]
|
||||
base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists()
|
||||
|
||||
# Given an example email, such as "joe.smoe+1@igorville.com"
|
||||
# The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$"
|
||||
pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$"
|
||||
return base_email_exists and re.match(pattern, email)
|
||||
else:
|
||||
# Edge case, the +1 record exists but the base does not,
|
||||
# and the record we are checking is the base record.
|
||||
pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$"
|
||||
plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists()
|
||||
return plus_email_exists
|
||||
|
||||
def __str__(self):
|
||||
return str(self.email)
|
|
@ -48,8 +48,7 @@ class DomainInformation(TimeStampedModel):
|
|||
null=True,
|
||||
)
|
||||
|
||||
# This is the domain request user who created this domain request. The contact
|
||||
# information that they gave is in the `submitter` field
|
||||
# This is the domain request user who created this domain request.
|
||||
creator = models.ForeignKey(
|
||||
"registrar.User",
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -197,17 +196,6 @@ class DomainInformation(TimeStampedModel):
|
|||
related_name="domain_info",
|
||||
)
|
||||
|
||||
# This is the contact information provided by the domain requestor. The
|
||||
# user who created the domain request is in the `creator` field.
|
||||
submitter = models.ForeignKey(
|
||||
"registrar.Contact",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="submitted_domain_requests_information",
|
||||
on_delete=models.PROTECT,
|
||||
help_text='Person listed under "your contact information" in the request form',
|
||||
)
|
||||
|
||||
purpose = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
|
|
|
@ -6,7 +6,6 @@ from django.conf import settings
|
|||
from django.db import models
|
||||
from django_fsm import FSMField, transition # type: ignore
|
||||
from django.utils import timezone
|
||||
from waffle import flag_is_active
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||
|
@ -339,13 +338,12 @@ class DomainRequest(TimeStampedModel):
|
|||
help_text="The suborganization that this domain request is included under",
|
||||
)
|
||||
|
||||
# This is the domain request user who created this domain request. The contact
|
||||
# information that they gave is in the `submitter` field
|
||||
# This is the domain request user who created this domain request.
|
||||
creator = models.ForeignKey(
|
||||
"registrar.User",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="domain_requests_created",
|
||||
help_text="Person who submitted the domain request; will not receive email updates",
|
||||
help_text="Person who submitted the domain request. Will receive email updates.",
|
||||
)
|
||||
|
||||
investigator = models.ForeignKey(
|
||||
|
@ -483,17 +481,6 @@ class DomainRequest(TimeStampedModel):
|
|||
help_text="Other domain names the creator provided for consideration",
|
||||
)
|
||||
|
||||
# This is the contact information provided by the domain requestor. The
|
||||
# user who created the domain request is in the `creator` field.
|
||||
submitter = models.ForeignKey(
|
||||
"registrar.Contact",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="submitted_domain_requests",
|
||||
on_delete=models.PROTECT,
|
||||
help_text='Person listed under "your contact information" in the request form; will receive email updates',
|
||||
)
|
||||
|
||||
purpose = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
|
@ -563,20 +550,43 @@ class DomainRequest(TimeStampedModel):
|
|||
help_text="Acknowledged .gov acceptable use policy",
|
||||
)
|
||||
|
||||
# submission date records when domain request is submitted
|
||||
submission_date = models.DateField(
|
||||
# Records when the domain request was first submitted
|
||||
first_submitted_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name="submitted at",
|
||||
help_text="Date submitted",
|
||||
verbose_name="first submitted on",
|
||||
help_text="Date initially submitted",
|
||||
)
|
||||
|
||||
# Records when domain request was last submitted
|
||||
last_submitted_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name="last submitted on",
|
||||
help_text="Date last submitted",
|
||||
)
|
||||
|
||||
# Records when domain request status was last updated by an admin or analyst
|
||||
last_status_update = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name="last updated on",
|
||||
help_text="Date of the last status update",
|
||||
)
|
||||
notes = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_statuses_that_send_emails(cls):
|
||||
"""Returns a list of statuses that send an email to the user"""
|
||||
excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW]
|
||||
return [status for status in cls.DomainRequestStatus if status not in excluded_statuses]
|
||||
|
||||
def sync_organization_type(self):
|
||||
"""
|
||||
Updates the organization_type (without saving) to match
|
||||
|
@ -621,6 +631,9 @@ class DomainRequest(TimeStampedModel):
|
|||
self.sync_organization_type()
|
||||
self.sync_yes_no_form_fields()
|
||||
|
||||
if self._cached_status != self.status:
|
||||
self.last_status_update = timezone.now().date()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Handle the action needed email.
|
||||
|
@ -715,9 +728,6 @@ class DomainRequest(TimeStampedModel):
|
|||
contact information. If there is not creator information, then do
|
||||
nothing.
|
||||
|
||||
If the waffle flag "profile_feature" is active, then this email will be sent to the
|
||||
domain request creator rather than the submitter
|
||||
|
||||
Optional args:
|
||||
bcc_address: str -> the address to bcc to
|
||||
|
||||
|
@ -732,7 +742,7 @@ class DomainRequest(TimeStampedModel):
|
|||
custom_email_content: str -> Renders an email with the content of this string as its body text.
|
||||
"""
|
||||
|
||||
recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter
|
||||
recipient = self.creator
|
||||
if recipient is None or recipient.email is None:
|
||||
logger.warning(
|
||||
f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}."
|
||||
|
@ -803,8 +813,12 @@ class DomainRequest(TimeStampedModel):
|
|||
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
|
||||
raise ValueError("Requested domain is not a valid domain name.")
|
||||
|
||||
# Update submission_date to today
|
||||
self.submission_date = timezone.now().date()
|
||||
# if the domain has not been submitted before this must be the first time
|
||||
if not self.first_submitted_date:
|
||||
self.first_submitted_date = timezone.now().date()
|
||||
|
||||
# Update last_submitted_date to today
|
||||
self.last_submitted_date = timezone.now().date()
|
||||
self.save()
|
||||
|
||||
# Limit email notifications to transitions from Started and Withdrawn
|
||||
|
@ -1152,6 +1166,10 @@ class DomainRequest(TimeStampedModel):
|
|||
# Special District -> "Election office" and "About your organization" page can't be empty
|
||||
return self.is_election_board is not None and self.about_your_organization is not None
|
||||
|
||||
# Do we still want to test this after creator is autogenerated? Currently it went back to being selectable
|
||||
def _is_creator_complete(self):
|
||||
return self.creator is not None
|
||||
|
||||
def _is_organization_name_and_address_complete(self):
|
||||
return not (
|
||||
self.organization_name is None
|
||||
|
@ -1170,9 +1188,6 @@ class DomainRequest(TimeStampedModel):
|
|||
def _is_purpose_complete(self):
|
||||
return self.purpose is not None
|
||||
|
||||
def _is_submitter_complete(self):
|
||||
return self.submitter is not None
|
||||
|
||||
def _has_other_contacts_and_filled(self):
|
||||
# Other Contacts Radio button is Yes and if all required fields are filled
|
||||
return (
|
||||
|
@ -1221,14 +1236,12 @@ class DomainRequest(TimeStampedModel):
|
|||
return self.is_policy_acknowledged is not None
|
||||
|
||||
def _is_general_form_complete(self, request):
|
||||
has_profile_feature_flag = flag_is_active(request, "profile_feature")
|
||||
return (
|
||||
self._is_organization_name_and_address_complete()
|
||||
self._is_creator_complete()
|
||||
and self._is_organization_name_and_address_complete()
|
||||
and self._is_senior_official_complete()
|
||||
and self._is_requested_domain_complete()
|
||||
and self._is_purpose_complete()
|
||||
# NOTE: This flag leaves submitter as empty (request wont submit) hence set to True
|
||||
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
|
||||
and self._is_other_contacts_complete()
|
||||
and self._is_additional_details_complete()
|
||||
and self._is_policy_acknowledgement_complete()
|
||||
|
|
|
@ -131,9 +131,13 @@ class Portfolio(TimeStampedModel):
|
|||
Returns a combination of organization_type / federal_type, seperated by ' - '.
|
||||
If no federal_type is found, we just return the org type.
|
||||
"""
|
||||
org_type_label = self.OrganizationChoices.get_org_label(self.organization_type)
|
||||
agency_type_label = BranchChoices.get_branch_label(self.federal_type)
|
||||
if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label:
|
||||
return self.get_portfolio_type(self.organization_type, self.federal_type)
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_type(cls, organization_type, federal_type):
|
||||
org_type_label = cls.OrganizationChoices.get_org_label(organization_type)
|
||||
agency_type_label = BranchChoices.get_branch_label(federal_type)
|
||||
if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label:
|
||||
return " - ".join([org_type_label, agency_type_label])
|
||||
else:
|
||||
return org_type_label
|
||||
|
@ -141,7 +145,11 @@ class Portfolio(TimeStampedModel):
|
|||
@property
|
||||
def federal_type(self):
|
||||
"""Returns the federal_type value on the underlying federal_agency field"""
|
||||
return self.federal_agency.federal_type if self.federal_agency else None
|
||||
return self.get_federal_type(self.federal_agency)
|
||||
|
||||
@classmethod
|
||||
def get_federal_type(cls, federal_agency):
|
||||
return federal_agency.federal_type if federal_agency else None
|
||||
|
||||
# == Getters for domains == #
|
||||
def get_domains(self):
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
"""People are invited by email to administer domains."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from django_fsm import FSMField, transition
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
|
@ -87,9 +85,11 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
|
||||
|
||||
# and create a role for that user on this portfolio
|
||||
user.portfolio = self.portfolio
|
||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=user
|
||||
)
|
||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
||||
user.portfolio_roles = self.portfolio_roles
|
||||
user_portfolio_permission.roles = self.portfolio_roles
|
||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
||||
user.portfolio_additional_permissions = self.portfolio_additional_permissions
|
||||
user.save()
|
||||
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
||||
user_portfolio_permission.save()
|
||||
|
|
|
@ -3,11 +3,10 @@ import logging
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.forms import ValidationError
|
||||
from django.http import HttpRequest
|
||||
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.models import DomainInformation, UserDomainRole
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
|
||||
|
||||
from .domain_invitation import DomainInvitation
|
||||
from .portfolio_invitation import PortfolioInvitation
|
||||
|
@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain
|
|||
from .verified_by_staff import VerifiedByStaff
|
||||
from .domain import Domain
|
||||
from .domain_request import DomainRequest
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
||||
|
@ -66,32 +64,6 @@ class User(AbstractUser):
|
|||
# after they login.
|
||||
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
||||
|
||||
PORTFOLIO_ROLE_PERMISSIONS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBER,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
||||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
],
|
||||
}
|
||||
|
||||
# #### Constants for choice fields ####
|
||||
RESTRICTED = "restricted"
|
||||
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
||||
|
@ -112,34 +84,6 @@ class User(AbstractUser):
|
|||
related_name="users",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user",
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
portfolio_roles = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select one or more roles.",
|
||||
)
|
||||
|
||||
portfolio_additional_permissions = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
)
|
||||
|
||||
phone = PhoneNumberField(
|
||||
null=True,
|
||||
blank=True,
|
||||
|
@ -187,6 +131,12 @@ class User(AbstractUser):
|
|||
else:
|
||||
return self.username
|
||||
|
||||
@classmethod
|
||||
def get_default_user(cls):
|
||||
"""Returns the default "system" user"""
|
||||
default_creator, _ = User.objects.get_or_create(username="System")
|
||||
return default_creator
|
||||
|
||||
def restrict_user(self):
|
||||
self.status = self.RESTRICTED
|
||||
self.save()
|
||||
|
@ -230,68 +180,134 @@ class User(AbstractUser):
|
|||
def has_contact_info(self):
|
||||
return bool(self.title or self.email or self.phone)
|
||||
|
||||
def clean(self):
|
||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||
super().clean()
|
||||
|
||||
if self.portfolio is None and self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
|
||||
|
||||
if self.portfolio is not None and not self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
||||
|
||||
def _get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
"""
|
||||
portfolio_permissions = set() # Use a set to avoid duplicate permissions
|
||||
|
||||
if self.portfolio_roles:
|
||||
for role in self.portfolio_roles:
|
||||
if role in self.PORTFOLIO_ROLE_PERMISSIONS:
|
||||
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
|
||||
if self.portfolio_additional_permissions:
|
||||
portfolio_permissions.update(self.portfolio_additional_permissions)
|
||||
return list(portfolio_permissions) # Convert back to list if necessary
|
||||
|
||||
def _has_portfolio_permission(self, portfolio_permission):
|
||||
def _has_portfolio_permission(self, portfolio, portfolio_permission):
|
||||
"""The views should only call this function when testing for perms and not rely on roles."""
|
||||
|
||||
if not self.portfolio:
|
||||
if not portfolio:
|
||||
return False
|
||||
|
||||
portfolio_permissions = self._get_portfolio_permissions()
|
||||
user_portfolio_perms = self.portfolio_permissions.filter(portfolio=portfolio, user=self).first()
|
||||
if not user_portfolio_perms:
|
||||
return False
|
||||
|
||||
return portfolio_permission in portfolio_permissions
|
||||
return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
|
||||
|
||||
# the methods below are checks for individual portfolio permissions. They are defined here
|
||||
# to make them easier to call elsewhere throughout the application
|
||||
def has_base_portfolio_permission(self):
|
||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||
def has_base_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||
|
||||
def has_edit_org_portfolio_permission(self):
|
||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||
def has_edit_org_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||
|
||||
def has_domains_portfolio_permission(self):
|
||||
def has_any_domains_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
||||
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
||||
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||
|
||||
def has_domain_requests_portfolio_permission(self):
|
||||
return self._has_portfolio_permission(
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
||||
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
||||
def has_organization_requests_flag(self):
|
||||
request = HttpRequest()
|
||||
request.user = self
|
||||
return flag_is_active(request, "organization_requests")
|
||||
|
||||
def has_view_all_domains_permission(self):
|
||||
def has_organization_members_flag(self):
|
||||
request = HttpRequest()
|
||||
request.user = self
|
||||
return flag_is_active(request, "organization_members")
|
||||
|
||||
def has_view_members_portfolio_permission(self, portfolio):
|
||||
# BEGIN
|
||||
# Note code below is to add organization_request feature
|
||||
if not self.has_organization_members_flag():
|
||||
return False
|
||||
# END
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS)
|
||||
|
||||
def has_edit_members_portfolio_permission(self, portfolio):
|
||||
# BEGIN
|
||||
# Note code below is to add organization_request feature
|
||||
if not self.has_organization_members_flag():
|
||||
return False
|
||||
# END
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS)
|
||||
|
||||
def has_view_all_domains_portfolio_permission(self, portfolio):
|
||||
"""Determines if the current user can view all available domains in a given portfolio"""
|
||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
||||
|
||||
def has_any_requests_portfolio_permission(self, portfolio):
|
||||
# BEGIN
|
||||
# Note code below is to add organization_request feature
|
||||
if not self.has_organization_requests_flag():
|
||||
return False
|
||||
# END
|
||||
return self._has_portfolio_permission(
|
||||
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
||||
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||
|
||||
def has_view_all_requests_portfolio_permission(self, portfolio):
|
||||
"""Determines if the current user can view all available domain requests in a given portfolio"""
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||
|
||||
def has_edit_request_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||
|
||||
# Field specific permission checks
|
||||
def has_view_suborganization(self):
|
||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
||||
def has_view_suborganization_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
||||
|
||||
def has_edit_suborganization(self):
|
||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
||||
def has_edit_suborganization_portfolio_permission(self, portfolio):
|
||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
||||
|
||||
def get_first_portfolio(self):
|
||||
permission = self.portfolio_permissions.first()
|
||||
if permission:
|
||||
return permission.portfolio
|
||||
return None
|
||||
|
||||
def portfolio_role_summary(self, portfolio):
|
||||
"""Returns a list of roles based on the user's permissions."""
|
||||
roles = []
|
||||
|
||||
# Define the conditions and their corresponding roles
|
||||
conditions_roles = [
|
||||
(self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]),
|
||||
(
|
||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||
and self.has_any_requests_portfolio_permission(portfolio)
|
||||
and self.has_edit_request_portfolio_permission(portfolio),
|
||||
["View-only admin", "Domain requestor"],
|
||||
),
|
||||
(
|
||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||
and self.has_any_requests_portfolio_permission(portfolio),
|
||||
["View-only admin"],
|
||||
),
|
||||
(
|
||||
self.has_base_portfolio_permission(portfolio)
|
||||
and self.has_edit_request_portfolio_permission(portfolio)
|
||||
and self.has_any_domains_portfolio_permission(portfolio),
|
||||
["Domain requestor", "Domain manager"],
|
||||
),
|
||||
(
|
||||
self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
||||
["Domain requestor"],
|
||||
),
|
||||
(
|
||||
self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
||||
["Domain manager"],
|
||||
),
|
||||
(self.has_base_portfolio_permission(portfolio), ["Member"]),
|
||||
]
|
||||
|
||||
# Evaluate conditions and add roles
|
||||
for condition, role_list in conditions_roles:
|
||||
if condition:
|
||||
roles.extend(role_list)
|
||||
break
|
||||
|
||||
return roles
|
||||
|
||||
def get_portfolios(self):
|
||||
return self.portfolio_permissions.all()
|
||||
|
||||
@classmethod
|
||||
def needs_identity_verification(cls, email, uuid):
|
||||
|
@ -406,7 +422,14 @@ class User(AbstractUser):
|
|||
for invitation in PortfolioInvitation.objects.filter(
|
||||
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
|
||||
):
|
||||
if self.portfolio is None:
|
||||
# need to create a bogus request and assign user to it, in order to pass request
|
||||
# to flag_is_active
|
||||
request = HttpRequest()
|
||||
request.user = self
|
||||
only_single_portfolio = (
|
||||
not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None
|
||||
)
|
||||
if only_single_portfolio or flag_is_active(None, "multiple_portfolios"):
|
||||
try:
|
||||
invitation.retrieve()
|
||||
invitation.save()
|
||||
|
@ -433,11 +456,13 @@ class User(AbstractUser):
|
|||
|
||||
def is_org_user(self, request):
|
||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||
return has_organization_feature_flag and self.has_base_portfolio_permission()
|
||||
portfolio = request.session.get("portfolio")
|
||||
return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
|
||||
|
||||
def get_user_domain_ids(self, request):
|
||||
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
|
||||
if self.is_org_user(request) and self.has_view_all_domains_permission():
|
||||
return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True)
|
||||
portfolio = request.session.get("portfolio")
|
||||
if self.is_org_user(request) and self.has_view_all_domains_portfolio_permission(portfolio):
|
||||
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
|
||||
else:
|
||||
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
|
||||
|
|
119
src/registrar/models/user_portfolio_permission.py
Normal file
119
src/registrar/models/user_portfolio_permission.py
Normal file
|
@ -0,0 +1,119 @@
|
|||
from django.db import models
|
||||
from django.forms import ValidationError
|
||||
from django.http import HttpRequest
|
||||
from waffle import flag_is_active
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
|
||||
class UserPortfolioPermission(TimeStampedModel):
|
||||
"""This is a linking table that connects a user with a role on a portfolio."""
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "portfolio"]
|
||||
|
||||
PORTFOLIO_ROLE_PERMISSIONS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
||||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
],
|
||||
}
|
||||
|
||||
user = models.ForeignKey(
|
||||
"registrar.User",
|
||||
null=False,
|
||||
# when a user is deleted, permissions are too
|
||||
on_delete=models.CASCADE,
|
||||
related_name="portfolio_permissions",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
null=False,
|
||||
# when a portfolio is deleted, permissions are too
|
||||
on_delete=models.CASCADE,
|
||||
related_name="portfolio_users",
|
||||
)
|
||||
|
||||
roles = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select one or more roles.",
|
||||
)
|
||||
|
||||
additional_permissions = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"<Roles: {self.roles}>" if self.roles else ""
|
||||
|
||||
def _get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
return list(portfolio_permissions)
|
||||
|
||||
def clean(self):
|
||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||
super().clean()
|
||||
|
||||
# Check if a user is set without accessing the related object.
|
||||
has_user = bool(self.user_id)
|
||||
if self.pk is None and has_user:
|
||||
# Have to create a bogus request to set the user and pass to flag_is_active
|
||||
request = HttpRequest()
|
||||
request.user = self.user
|
||||
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
|
||||
if not flag_is_active(request, "multiple_portfolios") and existing_permissions.exists():
|
||||
raise ValidationError(
|
||||
"Only one portfolio permission is allowed per user when multiple portfolios are disabled."
|
||||
)
|
||||
|
||||
# Check if portfolio is set without accessing the related object.
|
||||
has_portfolio = bool(self.portfolio_id)
|
||||
if not has_portfolio and self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
|
||||
|
||||
if has_portfolio and not self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
|
@ -17,11 +17,10 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
|||
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
||||
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
||||
|
||||
VIEW_MEMBER = "view_member", "View members"
|
||||
EDIT_MEMBER = "edit_member", "Create and edit members"
|
||||
VIEW_MEMBERS = "view_members", "View members"
|
||||
EDIT_MEMBERS = "edit_members", "Create and edit members"
|
||||
|
||||
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
|
||||
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
|
||||
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
|
||||
|
||||
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
||||
|
|
|
@ -6,7 +6,7 @@ import logging
|
|||
from urllib.parse import parse_qs
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.user import User
|
||||
from registrar.models import User
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||
|
@ -125,8 +125,9 @@ class CheckUserProfileMiddleware:
|
|||
|
||||
class CheckPortfolioMiddleware:
|
||||
"""
|
||||
Checks if the current user has a portfolio
|
||||
If they do, redirect them to the portfolio homepage when they navigate to home.
|
||||
this middleware should serve two purposes:
|
||||
1 - set the portfolio in session if appropriate # views will need the session portfolio
|
||||
2 - if path is home and session portfolio is set, redirect based on permissions of user
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
|
@ -140,19 +141,33 @@ class CheckPortfolioMiddleware:
|
|||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
current_path = request.path
|
||||
|
||||
if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request):
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
if request.user.has_base_portfolio_permission():
|
||||
portfolio = request.user.portfolio
|
||||
# if multiple portfolios are allowed for this user
|
||||
if flag_is_active(request, "organization_feature"):
|
||||
self.set_portfolio_in_session(request)
|
||||
elif request.session.get("portfolio"):
|
||||
# Edge case: User disables flag while already logged in
|
||||
request.session["portfolio"] = None
|
||||
elif "portfolio" not in request.session:
|
||||
# Set the portfolio in the session if its not already in it
|
||||
request.session["portfolio"] = None
|
||||
|
||||
# Add the portfolio to the request object
|
||||
request.portfolio = portfolio
|
||||
|
||||
if request.user.has_domains_portfolio_permission():
|
||||
if request.user.is_org_user(request):
|
||||
if current_path == self.home:
|
||||
if request.user.has_any_domains_portfolio_permission(request.session["portfolio"]):
|
||||
portfolio_redirect = reverse("domains")
|
||||
else:
|
||||
portfolio_redirect = reverse("no-portfolio-domains")
|
||||
|
||||
return HttpResponseRedirect(portfolio_redirect)
|
||||
|
||||
return None
|
||||
|
||||
def set_portfolio_in_session(self, request):
|
||||
# NOTE: we will want to change later to have a workflow for selecting
|
||||
# portfolio and another for switching portfolio; for now, select first
|
||||
if flag_is_active(request, "multiple_portfolios"):
|
||||
request.session["portfolio"] = request.user.get_first_portfolio()
|
||||
else:
|
||||
request.session["portfolio"] = request.user.get_first_portfolio()
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<div id="content-main" class="analytics">
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
|
||||
<div class="grid-row grid-gap-2">
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
|
@ -29,28 +29,28 @@
|
|||
<div class="padding-top-2 padding-x-2">
|
||||
<ul class="usa-button-group wrapped-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_type' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_type' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">All domain metadata</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_full' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Current full</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_federal' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_federal' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Current federal</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_domain_requests_full' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_domain_requests_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">All domain requests metadata</span>
|
||||
|
@ -84,35 +84,35 @@
|
|||
</div>
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Domain growth</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Request growth</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Managed domains</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Unmanaged domains</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
|
||||
</svg><span class="margin-left-05">Update charts</span>
|
||||
|
|
|
@ -17,7 +17,7 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<span>Copy</span>
|
||||
Copy
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -25,7 +25,7 @@ Template for an input field with a clipboard
|
|||
<div class="admin-icon-group admin-icon-group__clipboard-link">
|
||||
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
||||
<button
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
|
@ -33,7 +33,7 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<span class="padding-left-05">Copy</span>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -32,6 +32,8 @@
|
|||
{% include "django/admin/includes/descriptions/website_description.html" %}
|
||||
{% elif opts.model_name == 'portfolioinvitation' %}
|
||||
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
||||
{% elif opts.model_name == 'allowedemail' %}
|
||||
{% include "django/admin/includes/descriptions/allowed_email_description.html" %}
|
||||
{% else %}
|
||||
<p>This table does not have a description yet.</p>
|
||||
{% endif %}
|
||||
|
|
260
src/registrar/templates/admin/transfer_user.html
Normal file
260
src/registrar/templates/admin/transfer_user.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
{% extends 'admin/base_site.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content_title %}<h1>Transfer user</h1>{% endblock %}
|
||||
|
||||
{% block extrastyle %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<!-- Making the user select a combobox: -->
|
||||
<!-- Load Django Admin's base JavaScript. This is NEEDED because select2 relies on it. -->
|
||||
<script src="{% static 'admin/js/vendor/jquery/jquery.min.js' %}"></script>
|
||||
|
||||
<!-- Include Select2 JavaScript. Since this view technically falls outside of admin, this is needed. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script type="application/javascript" src="{% static 'js/get-gov-admin-extra.js' %}" defer></script>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' 'registrar' %}">{% trans 'Registrar' %}</a>
|
||||
› <a href="{% url 'admin:registrar_user_changelist' %}">{% trans 'Users' %}</a>
|
||||
› <a href="{% url 'admin:registrar_user_change' current_user.pk %}">{{ current_user.first_name }} {{ current_user.last_name }}</a>
|
||||
› {% trans 'Transfer User' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
|
||||
<div class="module padding-4 display-flex flex-row flex-justify submit-row">
|
||||
|
||||
<div class="desktop:flex-align-center">
|
||||
<form class="transfer-user-selector" method="GET" action="{% url 'transfer_user' current_user.pk %}">
|
||||
<label for="selected_user" class="text-middle">Select user to transfer data from:</label>
|
||||
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
|
||||
<option value="">Select a user</option>
|
||||
{% for user in other_users %}
|
||||
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
|
||||
{{ user.first_name }} {{ user.last_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="submit" value="Select and preview" class="button--dja-toolbar">
|
||||
</form>
|
||||
</div>
|
||||
<div class="desktop:flex-align-center">
|
||||
{% if selected_user %}
|
||||
<a class="usa-button usa-button--dja" href="#transfer-and-delete" aria-controls="transfer-and-delete" data-open-modal>
|
||||
Transfer and delete user
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row grid-gap-2">
|
||||
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
<div class="module height-full">
|
||||
<h2>User to transfer data from</h2>
|
||||
<div class="padding-top-2 padding-x-2">
|
||||
{% if selected_user %}
|
||||
<dl class="dl-dja">
|
||||
<dt>Username:</dt>
|
||||
<dd>{{ selected_user.username }}</dd>
|
||||
<dt>Created at:</dt>
|
||||
<dd>{{ selected_user.created_at }}</dd>
|
||||
<dt>Last login:</dt>
|
||||
<dd>{{ selected_user.last_login }}</dd>
|
||||
<dt>First name:</dt>
|
||||
<dd>{{ selected_user.first_name }}</dd>
|
||||
<dt>Middle name:</dt>
|
||||
<dd>{{ selected_user.middle_name }}</dd>
|
||||
<dt>Last name:</dt>
|
||||
<dd>{{ selected_user.last_name }}</dd>
|
||||
<dt>Title:</dt>
|
||||
<dd>{{ selected_user.title }}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{{ selected_user.email }}</dd>
|
||||
<dt>Phone:</dt>
|
||||
<dd>{{ selected_user.phone }}</dd>
|
||||
<h3 class="font-heading-md">Data that will get transferred:</h3>
|
||||
<dt>Domains:</dt>
|
||||
<dd>
|
||||
{% if selected_user_domains %}
|
||||
<ul>
|
||||
{% for domain in selected_user_domains %}
|
||||
<li>{{ domain }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Domain requests:</dt>
|
||||
<dd>
|
||||
{% if selected_user_domain_requests %}
|
||||
<ul>
|
||||
{% for request in selected_user_domain_requests %}
|
||||
<li>{{ request }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Portfolios:</dt>
|
||||
<dd>
|
||||
{% if selected_user_portfolios %}
|
||||
<ul>
|
||||
{% for portfolio in selected_user_portfolios %}
|
||||
<li>{{ portfolio.portfolio }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<p>No user selected yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
<div class="module height-full">
|
||||
<h2>User to receive data</h2>
|
||||
<div class="padding-top-2 padding-x-2">
|
||||
<dl class="dl-dja">
|
||||
<dt>Username:</dt>
|
||||
<dd>{{ current_user.username }}</dd>
|
||||
<dt>Created at:</dt>
|
||||
<dd>{{ current_user.created_at }}</dd>
|
||||
<dt>Last login:</dt>
|
||||
<dd>{{ current_user.last_login }}</dd>
|
||||
<dt>First name:</dt>
|
||||
<dd>{{ current_user.first_name }}</dd>
|
||||
<dt>Middle name:</dt>
|
||||
<dd>{{ current_user.middle_name }}</dd>
|
||||
<dt>Last name:</dt>
|
||||
<dd>{{ current_user.last_name }}</dd>
|
||||
<dt>Title:</dt>
|
||||
<dd>{{ current_user.title }}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{{ current_user.email }}</dd>
|
||||
<dt>Phone:</dt>
|
||||
<dd>{{ current_user.phone }}</dd>
|
||||
<h3 class="font-heading-md" aria-label="Data that will added to:"> </h3>
|
||||
<dt>Domains:</dt>
|
||||
<dd>
|
||||
{% if current_user_domains %}
|
||||
<ul>
|
||||
{% for domain in current_user_domains %}
|
||||
<li>{{ domain }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Domain requests:</dt>
|
||||
<dd>
|
||||
{% if current_user_domain_requests %}
|
||||
<ul>
|
||||
{% for request in current_user_domain_requests %}
|
||||
<li>{{ request }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Portfolios:</dt>
|
||||
<dd>
|
||||
{% if current_user_portfolios %}
|
||||
<ul>
|
||||
{% for portfolio in current_user_portfolios %}
|
||||
<li>{{ portfolio.portfolio }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="transfer-and-delete"
|
||||
aria-labelledby="This action will delete {{ selected_user }}"
|
||||
aria-describedby="This action will delete {{ selected_user }}"
|
||||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="transfer-and-delete-heading">
|
||||
Are you sure you want to transfer data and delete this user?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
{% if selected_user != logged_in_user %}
|
||||
<p>Username: <b>{{ selected_user.username }}</b><br>
|
||||
Name: <b>{{ selected_user.first_name }} {{ selected_user.last_name }}</b><br>
|
||||
Email: <b>{{ selected_user.email }}</b></p>
|
||||
<p>This action cannot be undone.</p>
|
||||
{% else %}
|
||||
<p>Don't do it!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
{% if selected_user != logged_in_user %}
|
||||
<li class="usa-button-group__item">
|
||||
<form method="POST" action="{% url 'transfer_user' current_user.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="selected_user" value="{{ selected_user.pk }}">
|
||||
<input type="submit" class="usa-button usa-button--dja" value="Yes, transfer and delete user">
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
name="_cancel_domain_request_ineligible"
|
||||
data-close-modal
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-modal__close"
|
||||
aria-label="Close this window"
|
||||
data-close-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -120,7 +120,7 @@
|
|||
</button>
|
||||
</span>
|
||||
|
||||
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768">
|
||||
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
|
||||
</p>
|
||||
{{ block.super }}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<p>This table is an email allow list for <strong>non-production</strong> environments.</p>
|
||||
<p>
|
||||
If an email is sent out and the email does not exist within this table (or is not a subset of it),
|
||||
then no email will be sent.
|
||||
</p>
|
||||
<p>If this table is populated in a production environment, no change will occur as it will simply be ignored.</p>
|
|
@ -70,7 +70,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<div class="readonly textarea-wrapper">
|
||||
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-0 padding-top-0 margin-bottom-1 thin-border collapse--dgsimple collapsed">
|
||||
<label class="max-full" for="action_needed_reason_email_view_more">
|
||||
<strong>Sent to {% if has_profile_feature_flag %}creator{%else%}submitter{%endif%}</strong>
|
||||
<strong>Sent to creator</strong>
|
||||
</label>
|
||||
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
|
||||
{{ original_object.action_needed_reason_email }}
|
||||
|
@ -107,7 +107,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endif %}
|
||||
{% elif field.field.name == "requested_domain" %}
|
||||
{% with current_path=request.get_full_path %}
|
||||
<a class="margin-top-05 padding-top-05" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
|
||||
<a class="margin-top-05 padding-top-05" id="id_requested_domain" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
|
||||
{% endwith%}
|
||||
{% elif field.field.name == "current_websites" %}
|
||||
{% comment %}
|
||||
|
@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% elif field.field.name == "display_admins" %}
|
||||
<div class="readonly">{{ field.contents|safe }}</div>
|
||||
{% elif field.field.name == "display_members" %}
|
||||
<div class="readonly">
|
||||
{% if display_members_summary %}
|
||||
{{ display_members_summary }}
|
||||
{% else %}
|
||||
<p>No additional members found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
{% endif %}
|
||||
|
@ -145,20 +155,110 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
|
||||
{% block field_other %}
|
||||
{% if field.field.name == "action_needed_reason_email" %}
|
||||
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
|
||||
<div class="margin-top-05 collapse--dgsimple collapsed">
|
||||
{{ field.field.value|linebreaks }}
|
||||
</div>
|
||||
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
|
||||
<span>Show details</span>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{{ field.field }}
|
||||
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
|
||||
<div id="action-needed-reason-email-placeholder-text" class="margin-top-05 text-faded">
|
||||
-
|
||||
</div>
|
||||
<div>
|
||||
<div id="action-needed-reason-email-readonly" class="display-none usa-summary-box_admin padding-top-0 margin-top-0">
|
||||
<div class="flex-container">
|
||||
<div class="margin-top-05">
|
||||
<p class="{% if action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header"><b>Auto-generated email that will be sent to the creator</b></p>
|
||||
<p class="{% if not action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header-email-sent">
|
||||
<svg class="usa-icon text-green" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
||||
</svg>
|
||||
<b>Email sent to the creator</b>
|
||||
</p>
|
||||
</div>
|
||||
<div class="vertical-separator margin-top-1 margin-bottom-1"></div>
|
||||
<a
|
||||
href="#email-already-sent-modal"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1"
|
||||
aria-controls="email-already-sent-modal"
|
||||
data-open-modal
|
||||
>Edit email</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="email-already-sent-modal"
|
||||
aria-labelledby="Are you sure you want to edit this email?"
|
||||
aria-describedby="The creator of this request already received an email"
|
||||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
Are you sure you want to edit this email?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
<p>
|
||||
The creator of this request already received an email for this status/reason:
|
||||
</p>
|
||||
<ul>
|
||||
<li class="font-body-sm">Status: <b>Action needed</b></li>
|
||||
<li class="font-body-sm">Reason: <b>{{ original_object.get_action_needed_reason_display }}</b></li>
|
||||
</ul>
|
||||
<p>
|
||||
If you edit this email's text, <b>the system will send another email</b> to
|
||||
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
id="email-already-sent-modal_continue-editing-button"
|
||||
data-close-modal
|
||||
>
|
||||
Yes, continue editing
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
name="_cancel_edit_email"
|
||||
data-close-modal
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-modal__close"
|
||||
aria-label="Close this window"
|
||||
data-close-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sr-only" for="action-needed-reason-email-readonly-textarea">Email:</label>
|
||||
<textarea cols="40" rows="10" class="vLargeTextField" id="action-needed-reason-email-readonly-textarea" readonly>{{ field.field.value|striptags }}
|
||||
</textarea>
|
||||
</div>
|
||||
<div>
|
||||
{{ field.field }}
|
||||
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
|
||||
<input id="action-needed-email-last-sent-text" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
||||
</div>
|
||||
</div>
|
||||
<span id="action-needed-email-footer" class="help">
|
||||
{% if not action_needed_email_sent %}
|
||||
This email will be sent to the creator of this request after saving
|
||||
{% else %}
|
||||
This email has been sent to the creator of this request
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ field.field }}
|
||||
|
@ -187,11 +287,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% if not skip_additional_contact_info %}
|
||||
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
||||
{% endif%}
|
||||
{% elif field.field.name == "submitter" %}
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Submitter contact details"></label>
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
|
||||
</div>
|
||||
{% elif field.field.name == "senior_official" %}
|
||||
<div class="flex-container">
|
||||
<label aria-label="Senior official contact details"></label>
|
||||
|
@ -240,7 +335,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</details>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% elif field.field.name == "state_territory" %}
|
||||
{% elif field.field.name == "display_members" and field.contents %}
|
||||
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
|
||||
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{{ field.contents|safe }}
|
||||
</div>
|
||||
</details>
|
||||
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
|
||||
<div class="flex-container margin-top-2">
|
||||
<span>
|
||||
CISA region:
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get-senior-official-from-federal-agency-json' as url %}
|
||||
<input id="senior_official_from_agency_json_url" class="display-none" value="{{url}}" />
|
||||
{% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
|
||||
<input id="federal_and_portfolio_types_from_agency_json_url" class="display-none" value="{{url}}" />
|
||||
{{ block.super }}
|
||||
{% endblock content %}
|
||||
|
||||
|
@ -14,10 +17,28 @@
|
|||
This is a placeholder for now.
|
||||
|
||||
Disclaimer:
|
||||
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
|
||||
For instance, "portfolio_fieldset.html".
|
||||
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
|
||||
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
||||
{% endcomment %}
|
||||
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block submit_buttons_bottom %}
|
||||
<div class="submit-row-wrapper">
|
||||
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
|
||||
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
|
||||
</svg>
|
||||
<span>Hide</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||
Organization Name: <strong>{{ original.organization_name }}</strong>
|
||||
</p>
|
||||
{{ block.super }}
|
||||
</div>
|
||||
<span class="scroll-indicator"></span>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,42 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
|
||||
{% block field_sets %}
|
||||
<div class="display-flex flex-row flex-justify submit-row">
|
||||
<div class="desktop:flex-align-self-end">
|
||||
<a href="{% url 'transfer_user' original.pk %}" class="button">
|
||||
Transfer data from old account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for fieldset in adminform %}
|
||||
{% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block after_related_objects %}
|
||||
{% if portfolios %}
|
||||
<div class="module aligned padding-3">
|
||||
<h2>Portfolio information</h2>
|
||||
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
|
||||
<h3>Portfolios</h3>
|
||||
<ul class="margin-0 padding-0">
|
||||
{% for portfolio in portfolios %}
|
||||
<li>
|
||||
<a href="{% url 'admin:registrar_portfolio_change' portfolio.pk %}">
|
||||
{{ portfolio }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="module aligned padding-3">
|
||||
<h2>Associated requests and domains</h2>
|
||||
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
|
||||
|
|
|
@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
|
|||
|
||||
<div class="usa-combo-box"
|
||||
{% for name, value in widget.attrs.items %}
|
||||
{{ name }}="{{ value }}"
|
||||
{% if name != 'id' %}
|
||||
{{ name }}="{{ value }}"
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% include "django/forms/widgets/select.html" %}
|
||||
|
|
|
@ -72,9 +72,9 @@
|
|||
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
|
||||
{% endif %}
|
||||
|
||||
{% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %}
|
||||
{% if portfolio and has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %}
|
||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
|
||||
{% else %}
|
||||
{% url 'domain-org-name-address' pk=domain.id as url %}
|
||||
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
|
||||
|
|
|
@ -63,10 +63,10 @@
|
|||
|
||||
<div class="grid-row margin-top-1">
|
||||
<div class="grid-col">
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record text-secondary line-height-sans-5">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</svg>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,10 +74,10 @@
|
|||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg><span class="margin-left-05">Add new record</span>
|
||||
</svg>Add new record
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
|
@ -52,20 +52,20 @@
|
|||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-2">
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075 text-secondary line-height-sans-5">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</svg>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg><span class="margin-left-05">Add another name server</span>
|
||||
</svg>Add another name server
|
||||
</button>
|
||||
|
||||
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
{% input_with_errors form.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -16,6 +16,26 @@
|
|||
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
|
||||
</svg><span class="margin-left-05">Previous step</span>
|
||||
</a>
|
||||
{% comment %}
|
||||
TODO: uncomment in #2596
|
||||
{% else %}
|
||||
{% if portfolio %}
|
||||
{% url 'domain-requests' as url_2 %}
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url_2 }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
{% if requested_domain__name %}
|
||||
<span>{{ requested_domain__name }}</span>
|
||||
{% else %}
|
||||
<span>Start a new domain request</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %} {% endcomment %}
|
||||
{% endif %}
|
||||
|
||||
{% block form_messages %}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% input_with_errors forms.0.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors forms.0.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<h2 class="margin-top-1">Organization contact {{ forloop.counter }}</h2>
|
||||
</legend>
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2">
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2 text-secondary line-height-sans-5">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
|
|
|
@ -130,8 +130,8 @@
|
|||
|
||||
{% if step == Step.YOUR_CONTACT %}
|
||||
{% namespaced_url 'domain-request' step as domain_request_url %}
|
||||
{% if domain_request.submitter is not None %}
|
||||
{% with title=form_titles|get_item:step value=domain_request.submitter %}
|
||||
{% if domain_request.creator is not None %}
|
||||
{% with title=form_titles|get_item:step value=domain_request.creator %}
|
||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
|
|
|
@ -8,15 +8,33 @@
|
|||
{% block content %}
|
||||
<main id="main-content" class="grid-container">
|
||||
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
||||
<a href="{% url 'home' %}" class="breadcrumb__back">
|
||||
{% comment %}
|
||||
TODO: Uncomment in #2596
|
||||
{% if portfolio %}
|
||||
{% url 'domain-requests' as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>{{ DomainRequest.requested_domain.name }}</span
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% else %}{% endcomment %}
|
||||
{% url 'home' as url %}
|
||||
<a href="{{ url }}" class="breadcrumb__back">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
||||
</svg>
|
||||
|
||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
||||
Back to manage your domains
|
||||
Back to manage your domains
|
||||
</p>
|
||||
</a>
|
||||
{% comment %} {% endif %}{% endcomment %}
|
||||
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
|
||||
<div
|
||||
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"
|
||||
|
@ -109,8 +127,8 @@
|
|||
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
|
||||
{% endif %}
|
||||
|
||||
{% if DomainRequest.submitter and not has_profile_feature_flag %}
|
||||
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.submitter contact='true' heading_level=heading_level %}
|
||||
{% if DomainRequest.creator %}
|
||||
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
|
||||
{% endif %}
|
||||
|
||||
{% if DomainRequest.other_contacts.all %}
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
|
||||
{% if portfolio %}
|
||||
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
|
||||
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %}
|
||||
{% if has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||
{% with url_name="domain-suborganization" %}
|
||||
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
|
||||
{% endwith %}
|
||||
|
@ -72,13 +72,6 @@
|
|||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% if not has_profile_feature_flag %}
|
||||
{# Conditionally display profile link in main nav #}
|
||||
{% with url_name="domain-your-contact-information" %}
|
||||
{% include "includes/domain_sidenav_item.html" with item_text="Your contact information" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% with url_name="domain-security-email" %}
|
||||
{% include "includes/domain_sidenav_item.html" with item_text="Security email" %}
|
||||
{% endwith %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "domain_base.html" %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Suborganization{% endblock %}
|
||||
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{# this is right after the messages block in the parent template #}
|
||||
|
@ -15,7 +15,7 @@
|
|||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
|
||||
{% if has_domains_portfolio_permission and request.user.has_edit_suborganization %}
|
||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
||||
{% csrf_token %}
|
||||
{% input_with_errors form.sub_organization %}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
<section class="section--outlined">
|
||||
<section class="section-outlined">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
<h2 class> Domain managers </h2>
|
||||
<caption class="sr-only">Domain managers</caption>
|
||||
|
@ -112,7 +112,7 @@
|
|||
</section>
|
||||
|
||||
{% if domain.invitations.exists %}
|
||||
<section class="section--outlined">
|
||||
<section class="section-outlined">
|
||||
<h2>Invitations</h2>
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
<caption class="sr-only">Domain invitations</caption>
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
{% extends "domain_base.html" %}
|
||||
{% load static field_helpers %}
|
||||
|
||||
{% block title %}Your contact information | {{ domain.name }} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
||||
<h1>Your contact information</h1>
|
||||
|
||||
<p>If you’d like us to use a different name, email, or phone number you can make those changes below. <strong>Updating your contact information here will update the contact information for all domains in your account.</strong> Changing your information here won’t affect your Login.gov account information. The contact information you provide here won’t be made public and will only be used for the .gov program.
|
||||
</p>
|
||||
|
||||
{% include "includes/required_fields.html" %}
|
||||
|
||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
||||
{% csrf_token %}
|
||||
|
||||
{% input_with_errors form.first_name %}
|
||||
|
||||
{% input_with_errors form.middle_name %}
|
||||
|
||||
{% input_with_errors form.last_name %}
|
||||
|
||||
{% input_with_errors form.title %}
|
||||
|
||||
{% input_with_errors form.email %}
|
||||
|
||||
{% input_with_errors form.phone %}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>Save</button>
|
||||
</form>
|
||||
|
||||
{% endblock %} {# domain_content #}
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Action needed
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Action needed
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Action needed
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Action needed
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
Your .gov domain request has been withdrawn and will not be reviewed by our team.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Withdrawn
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
Congratulations! Your .gov domain request has been approved.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Approved
|
||||
|
||||
You can manage your approved domain on the .gov registrar <https://manage.get.gov>.
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
Your .gov domain request has been rejected.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Rejected
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
|||
We received your .gov domain request.
|
||||
|
||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Submitted
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
|
|
@ -3,12 +3,15 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domain_requests_json' as url %}
|
||||
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section--outlined domain-requests" id="domain-requests">
|
||||
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
|
||||
<div class="grid-row">
|
||||
{% if not has_domain_requests_portfolio_permission %}
|
||||
{% if not portfolio %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||
|
@ -45,7 +48,10 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
||||
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
|
||||
{% if portfolio %}
|
||||
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
|
||||
{% endif %}
|
||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||
<!-- AJAX will conditionally add a th for delete actions -->
|
||||
|
|
|
@ -5,16 +5,15 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domains_json' as url %}
|
||||
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% 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 %}">
|
||||
<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 %}">
|
||||
{% if not portfolio %}
|
||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||
<span class="display-none" id="no-portfolio-js-flag"></span>
|
||||
{% else %}
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||
<section aria-label="Domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
|
@ -43,10 +42,10 @@
|
|||
</section>
|
||||
</div>
|
||||
{% if user_domain_count and user_domain_count > 0 %}
|
||||
<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 %}">
|
||||
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
|
||||
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<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 %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
|
@ -157,8 +156,8 @@
|
|||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
{% if portfolio and request.user.has_view_suborganization %}
|
||||
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
|
||||
{% if portfolio and has_view_suborganization_portfolio_permission %}
|
||||
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||
{% endif %}
|
||||
<th
|
||||
scope="col"
|
||||
|
|
|
@ -12,46 +12,6 @@
|
|||
<button type="button" class="usa-nav__close">
|
||||
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
|
||||
</button>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
{%else %}
|
||||
{% url 'no-portfolio-domains' as url %}
|
||||
{% endif %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if has_domain_requests_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'domain-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'organization' as url %}
|
||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||
<a href="{{ url }}" class="usa-nav-link padding-y-0">
|
||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||
{{ portfolio.organization_name }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="usa-nav__secondary">
|
||||
<ul class="usa-nav__secondary-links">
|
||||
<li class="usa-nav__secondary-item">
|
||||
|
@ -75,6 +35,82 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_any_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
{% else %}
|
||||
{% url 'no-portfolio-domains' as url %}
|
||||
{% endif %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li> -->
|
||||
|
||||
{% if has_organization_requests_flag %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
||||
{% if has_edit_request_portfolio_permission %}
|
||||
{% url 'domain-requests' as url %}
|
||||
<button
|
||||
type="button"
|
||||
class="usa-accordion__button usa-nav__link{% if 'request'|in_path:request.path %} usa-current{% endif %}"
|
||||
aria-expanded="false"
|
||||
aria-controls="basic-nav-section-two"
|
||||
>
|
||||
<span>Domain requests</span>
|
||||
</button>
|
||||
<ul id="basic-nav-section-two" class="usa-nav__submenu">
|
||||
<li class="usa-nav__submenu-item">
|
||||
<a href="{{ url }}"
|
||||
><span>Domain requests</span></a
|
||||
>
|
||||
</li>
|
||||
<li class="usa-nav__submenu-item">
|
||||
<a href="{% url 'domain-request:' %}"
|
||||
><span>Start a new domain request</span></a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- user has view but no edit permissions -->
|
||||
{% elif has_any_requests_portfolio_permission %}
|
||||
{% url 'domain-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
<!-- user does not have permissions -->
|
||||
{% else %}
|
||||
{% url 'no-portfolio-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_organization_members_flag %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'organization' as url %}
|
||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
|
||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||
{{ portfolio.organization_name }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% include "includes/form_errors.html" with form=form %}
|
||||
{% endif %}
|
||||
|
||||
<h1>Senior Official</h1>
|
||||
<h1>Senior official</h1>
|
||||
|
||||
<p>
|
||||
Your senior official is a person within your organization who can authorize domain requests.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% load static field_helpers url_helpers custom_filters %}
|
||||
|
||||
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
|
||||
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
{% if field.value or not field.field.required %}
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
||||
|
@ -8,7 +8,7 @@
|
|||
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
||||
{%endif %}
|
||||
</svg>
|
||||
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
||||
<div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
||||
{% if field.name != "phone" %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block wrapper %}
|
||||
<div id="wrapper" class="dashboard--portfolio">
|
||||
{% block content %}
|
||||
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
|
||||
{% block content %}
|
||||
|
||||
<main id="main-content" class="grid-container">
|
||||
<main class="grid-container">
|
||||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
|
@ -26,10 +26,8 @@
|
|||
{% endif %}
|
||||
</main>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock content%}
|
||||
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
||||
|
||||
{% block content_bottom %}{% endblock %}
|
||||
</div>
|
||||
{% endblock wrapper %}
|
||||
|
|
|
@ -4,7 +4,13 @@
|
|||
|
||||
{% block title %} Domains | {% endblock %}
|
||||
|
||||
{% block wrapper_class %}
|
||||
{{ block.super }} dashboard--grey-1
|
||||
{% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domains-header">Domains</h1>
|
||||
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
{% block title %} Domains | {% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domains-header">Domains</h1>
|
||||
<section class="section--outlined">
|
||||
<div class="section--outlined__header margin-bottom-3">
|
||||
<section class="section-outlined">
|
||||
<div class="section-outlined__header margin-bottom-3">
|
||||
<h2 id="domains-header" class="display-inline-block">You aren’t managing any domains.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a domain, reach out to your organization’s administrators.</p>
|
||||
|
@ -27,4 +28,5 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
30
src/registrar/templates/portfolio_no_requests.html
Normal file
30
src/registrar/templates/portfolio_no_requests.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block title %} Domain Requests | {% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1 id="domains-header">Current domain requests</h1>
|
||||
<section class="section-outlined">
|
||||
<div class="section-outlined__header margin-bottom-3">
|
||||
<h2 id="domains-header" class="display-inline-block">You don’t have access to domain requests.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a request, reach out to your organization’s administrators.</p>
|
||||
<p>Your organizations administrators:</p>
|
||||
<ul class="margin-top-0">
|
||||
{% for administrator in portfolio_administrators %}
|
||||
{% if administrator.email %}
|
||||
<li>{{ administrator.email }}</li>
|
||||
{% else %}
|
||||
<li>{{ administrator }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p><strong>No administrators were found on your organization.</strong></p>
|
||||
<p>If you believe you should have access to a request, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %}
|
||||
{% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
|||
{% include 'portfolio_organization_sidebar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
|
||||
<h1>Organization</h1>
|
||||
|
||||
|
@ -41,7 +41,7 @@
|
|||
{% input_with_errors form.address_line2 %}
|
||||
{% input_with_errors form.city %}
|
||||
{% input_with_errors form.state_territory %}
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
<button type="submit" class="usa-button">
|
||||
|
|
|
@ -4,20 +4,35 @@
|
|||
|
||||
{% block title %} Domain requests | {% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1 id="domain-requests-header">Domain requests</h1>
|
||||
{% block wrapper_class %}
|
||||
{{ block.super }} dashboard--grey-1
|
||||
{% endblock %}
|
||||
|
||||
{% comment %}
|
||||
IMPORTANT:
|
||||
If this button is added on any other page, make sure to update the
|
||||
relevant view to reset request.session["new_request"] = True
|
||||
{% endcomment %}
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domain-requests-header">Domain requests</h1>
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
|
||||
</div>
|
||||
{% if has_edit_request_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
{% comment %}
|
||||
IMPORTANT:
|
||||
If this button is added on any other page, make sure to update the
|
||||
relevant view to reset request.session["new_request"] = True
|
||||
{% endcomment %}
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %}
|
||||
{% block title %}Senior official | {{ portfolio.name }}{% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
|||
{% include 'portfolio_organization_sidebar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
{% include "includes/senior_official.html" with can_edit=False %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -169,3 +169,8 @@ def has_contact_info(user):
|
|||
return False
|
||||
else:
|
||||
return bool(user.title or user.email or user.phone)
|
||||
|
||||
|
||||
@register.filter
|
||||
def model_name_lowercase(instance):
|
||||
return instance.__class__.__name__.lower()
|
||||
|
|
|
@ -27,6 +27,8 @@ from registrar.models import (
|
|||
PublicContact,
|
||||
Domain,
|
||||
FederalAgency,
|
||||
UserPortfolioPermission,
|
||||
Portfolio,
|
||||
)
|
||||
from epplibwrapper import (
|
||||
commands,
|
||||
|
@ -392,7 +394,6 @@ class AuditedAdminMockData:
|
|||
about_your_organization: str = "e-Government",
|
||||
anything_else: str = "There is more",
|
||||
senior_official: Contact = self.dummy_contact(item_name, "senior_official"),
|
||||
submitter: Contact = self.dummy_contact(item_name, "submitter"),
|
||||
creator: User = self.dummy_user(item_name, "creator"),
|
||||
}
|
||||
""" # noqa
|
||||
|
@ -410,7 +411,6 @@ class AuditedAdminMockData:
|
|||
about_your_organization="e-Government",
|
||||
anything_else="There is more",
|
||||
senior_official=self.dummy_contact(item_name, "senior_official"),
|
||||
submitter=self.dummy_contact(item_name, "submitter"),
|
||||
creator=creator,
|
||||
)
|
||||
return common_args
|
||||
|
@ -775,13 +775,13 @@ class MockDb(TestCase):
|
|||
cls.domain_request_3.alternative_domains.add(website, website_2)
|
||||
cls.domain_request_3.current_websites.add(website_3, website_4)
|
||||
cls.domain_request_3.cisa_representative_email = "test@igorville.com"
|
||||
cls.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_3.last_submitted_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_3.save()
|
||||
|
||||
cls.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_4.last_submitted_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_4.save()
|
||||
|
||||
cls.domain_request_6.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_6.last_submitted_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||
cls.domain_request_6.save()
|
||||
|
||||
@classmethod
|
||||
|
@ -791,6 +791,8 @@ class MockDb(TestCase):
|
|||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
DomainInvitation.objects.all().delete()
|
||||
cls.federal_agency_1.delete()
|
||||
|
@ -897,7 +899,6 @@ def completed_domain_request( # noqa
|
|||
has_cisa_representative=True,
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
user=False,
|
||||
submitter=False,
|
||||
name="city.gov",
|
||||
investigator=None,
|
||||
generic_org_type="federal",
|
||||
|
@ -907,6 +908,7 @@ def completed_domain_request( # noqa
|
|||
federal_type=None,
|
||||
action_needed_reason=None,
|
||||
portfolio=None,
|
||||
organization_name=None,
|
||||
):
|
||||
"""A completed domain request."""
|
||||
if not user:
|
||||
|
@ -921,14 +923,6 @@ def completed_domain_request( # noqa
|
|||
domain, _ = DraftDomain.objects.get_or_create(name=name)
|
||||
alt, _ = Website.objects.get_or_create(website="city1.gov")
|
||||
current, _ = Website.objects.get_or_create(website="city.com")
|
||||
if not submitter:
|
||||
submitter, _ = Contact.objects.get_or_create(
|
||||
first_name="Testy2",
|
||||
last_name="Tester2",
|
||||
title="Admin Tester",
|
||||
email="mayor@igorville.gov",
|
||||
phone="(555) 555 5556",
|
||||
)
|
||||
other, _ = Contact.objects.get_or_create(
|
||||
first_name="Testy",
|
||||
last_name="Tester",
|
||||
|
@ -950,14 +944,13 @@ def completed_domain_request( # noqa
|
|||
federal_type="executive",
|
||||
purpose="Purpose of the site",
|
||||
is_policy_acknowledged=True,
|
||||
organization_name="Testorg",
|
||||
organization_name=organization_name if organization_name else "Testorg",
|
||||
address_line1="address 1",
|
||||
address_line2="address 2",
|
||||
state_territory="NY",
|
||||
zipcode="10002",
|
||||
senior_official=so,
|
||||
requested_domain=domain,
|
||||
submitter=submitter,
|
||||
creator=user,
|
||||
status=status,
|
||||
investigator=investigator,
|
||||
|
@ -1743,3 +1736,12 @@ class MockEppLib(TestCase):
|
|||
|
||||
def tearDown(self):
|
||||
self.mockSendPatch.stop()
|
||||
|
||||
|
||||
def get_wsgi_request_object(client, user, url="/"):
|
||||
"""Returns client.get(url).wsgi_request for testing functions or classes
|
||||
that need a request object directly passed to them."""
|
||||
client.force_login(user)
|
||||
request = client.get(url).wsgi_request
|
||||
request.user = user
|
||||
return request
|
||||
|
|
|
@ -2,6 +2,7 @@ from datetime import datetime
|
|||
from django.utils import timezone
|
||||
from django.test import TestCase, RequestFactory, Client
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django_webtest import WebTest # type: ignore
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from django.urls import reverse
|
||||
from registrar.admin import (
|
||||
|
@ -41,11 +42,12 @@ from registrar.models import (
|
|||
TransitionDomain,
|
||||
Portfolio,
|
||||
Suborganization,
|
||||
UserPortfolioPermission,
|
||||
UserDomainRole,
|
||||
SeniorOfficial,
|
||||
PortfolioInvitation,
|
||||
VerifiedByStaff,
|
||||
)
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.senior_official import SeniorOfficial
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||
from .common import (
|
||||
MockDbForSharedTests,
|
||||
AuditedAdminMockData,
|
||||
|
@ -58,9 +60,12 @@ from .common import (
|
|||
multiple_unalphabetical_domain_objects,
|
||||
GenericTestHelper,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from unittest.mock import ANY, patch, Mock
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
|
@ -380,7 +385,7 @@ class TestDomainInformationAdmin(TestCase):
|
|||
|
||||
contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson")
|
||||
domain_request = completed_domain_request(
|
||||
submitter=contact, name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
name="city1244.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
)
|
||||
domain_request.approve()
|
||||
|
||||
|
@ -505,7 +510,6 @@ class TestDomainInformationAdmin(TestCase):
|
|||
# These should exist in the response
|
||||
expected_values = [
|
||||
("creator", "Person who submitted the domain request"),
|
||||
("submitter", 'Person listed under "your contact information" in the request form'),
|
||||
("domain_request", "Request associated with this domain"),
|
||||
("no_other_contacts_rationale", "Required if creator does not list other employees"),
|
||||
("urbanization", "Required for Puerto Rico only"),
|
||||
|
@ -629,16 +633,6 @@ class TestDomainInformationAdmin(TestCase):
|
|||
# Check for the field itself
|
||||
self.assertContains(response, "Meoward Jones")
|
||||
|
||||
# == Check for the submitter == #
|
||||
self.assertContains(response, "mayor@igorville.gov", count=2)
|
||||
expected_submitter_fields = [
|
||||
# Field, expected value
|
||||
("title", "Admin Tester"),
|
||||
("phone", "(555) 555 5556"),
|
||||
]
|
||||
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
|
||||
self.assertContains(response, "Testy2 Tester2")
|
||||
|
||||
# == Check for the senior_official == #
|
||||
self.assertContains(response, "testy@town.com", count=2)
|
||||
expected_so_fields = [
|
||||
|
@ -660,7 +654,7 @@ class TestDomainInformationAdmin(TestCase):
|
|||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||
|
||||
# Test for the copy link
|
||||
self.assertContains(response, "button--clipboard", count=4)
|
||||
self.assertContains(response, "button--clipboard", count=3)
|
||||
|
||||
# cleanup this test
|
||||
domain_info.delete()
|
||||
|
@ -684,7 +678,6 @@ class TestDomainInformationAdmin(TestCase):
|
|||
"more_organization_information",
|
||||
"domain",
|
||||
"domain_request",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
|
@ -703,19 +696,19 @@ class TestDomainInformationAdmin(TestCase):
|
|||
# Assert that sorting in reverse works correctly
|
||||
self.test_helper.assert_table_sorted("-1", ("-domain__name",))
|
||||
|
||||
def test_submitter_sortable(self):
|
||||
"""Tests if DomainInformation sorts by submitter correctly"""
|
||||
def test_creator_sortable(self):
|
||||
"""Tests if DomainInformation sorts by creator correctly"""
|
||||
with less_console_noise():
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Assert that our sort works correctly
|
||||
self.test_helper.assert_table_sorted(
|
||||
"4",
|
||||
("submitter__first_name", "submitter__last_name"),
|
||||
("creator__first_name", "creator__last_name"),
|
||||
)
|
||||
|
||||
# Assert that sorting in reverse works correctly
|
||||
self.test_helper.assert_table_sorted("-4", ("-submitter__first_name", "-submitter__last_name"))
|
||||
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
|
||||
|
||||
|
||||
class TestUserDomainRoleAdmin(TestCase):
|
||||
|
@ -970,7 +963,7 @@ class TestListHeaderAdmin(TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestMyUserAdmin(MockDbForSharedTests):
|
||||
class TestMyUserAdmin(MockDbForSharedTests, WebTest):
|
||||
"""Tests for the MyUserAdmin class as super or staff user
|
||||
|
||||
Notes:
|
||||
|
@ -990,6 +983,7 @@ class TestMyUserAdmin(MockDbForSharedTests):
|
|||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app.set_user(self.superuser.username)
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -1224,6 +1218,20 @@ class TestMyUserAdmin(MockDbForSharedTests):
|
|||
self.assertNotContains(response, "Portfolio roles:")
|
||||
self.assertNotContains(response, "Portfolio additional permissions:")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_user_can_see_related_portfolios(self):
|
||||
"""Tests if a user can see the portfolios they are associated with on the user page"""
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="test", creator=self.superuser)
|
||||
permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.superuser, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
response = self.app.get(reverse("admin:registrar_user_change", args=[self.superuser.pk]))
|
||||
expected_href = reverse("admin:registrar_portfolio_change", args=[portfolio.pk])
|
||||
self.assertContains(response, expected_href)
|
||||
self.assertContains(response, str(portfolio))
|
||||
permission.delete()
|
||||
portfolio.delete()
|
||||
|
||||
|
||||
class AuditedAdminTest(TestCase):
|
||||
|
||||
|
@ -1298,7 +1306,6 @@ class AuditedAdminTest(TestCase):
|
|||
# Senior offical is commented out for now - this is alphabetized
|
||||
# and this test does not accurately reflect that.
|
||||
# DomainRequest.senior_official.field,
|
||||
DomainRequest.submitter.field,
|
||||
# DomainRequest.investigator.field,
|
||||
DomainRequest.creator.field,
|
||||
DomainRequest.requested_domain.field,
|
||||
|
@ -1358,7 +1365,6 @@ class AuditedAdminTest(TestCase):
|
|||
# Senior offical is commented out for now - this is alphabetized
|
||||
# and this test does not accurately reflect that.
|
||||
# DomainInformation.senior_official.field,
|
||||
DomainInformation.submitter.field,
|
||||
# DomainInformation.creator.field,
|
||||
(DomainInformation.domain.field, ["name"]),
|
||||
(DomainInformation.domain_request.field, ["requested_domain__name"]),
|
||||
|
@ -1667,91 +1673,6 @@ class TestContactAdmin(TestCase):
|
|||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_change_view_for_joined_contact_five_or_less(self):
|
||||
"""Create a contact, join it to 4 domain requests.
|
||||
Assert that the warning on the contact form lists 4 joins."""
|
||||
with less_console_noise():
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
# Create an instance of the model
|
||||
contact, _ = Contact.objects.get_or_create(
|
||||
first_name="Henry",
|
||||
last_name="McFakerson",
|
||||
)
|
||||
|
||||
# join it to 4 domain requests.
|
||||
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
|
||||
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
|
||||
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
|
||||
domain_request4 = completed_domain_request(submitter=contact, name="city4.gov")
|
||||
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
# Use the test client to simulate the request
|
||||
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
# Note: The 5th join will be a user.
|
||||
mock_warning.assert_called_once_with(
|
||||
response.wsgi_request,
|
||||
"<ul class='messagelist_content-list--unstyled'>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request1.pk}/change/'>city1.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request2.pk}/change/'>city2.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request3.pk}/change/'>city3.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request4.pk}/change/'>city4.gov</a></li>"
|
||||
"</ul>",
|
||||
)
|
||||
|
||||
# cleanup this test
|
||||
DomainRequest.objects.all().delete()
|
||||
contact.delete()
|
||||
|
||||
def test_change_view_for_joined_contact_five_or_more(self):
|
||||
"""Create a contact, join it to 6 domain requests.
|
||||
Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis."""
|
||||
with less_console_noise():
|
||||
self.client.force_login(self.superuser)
|
||||
# Create an instance of the model
|
||||
# join it to 6 domain requests.
|
||||
contact, _ = Contact.objects.get_or_create(
|
||||
first_name="Henry",
|
||||
last_name="McFakerson",
|
||||
)
|
||||
domain_request1 = completed_domain_request(submitter=contact, name="city1.gov")
|
||||
domain_request2 = completed_domain_request(submitter=contact, name="city2.gov")
|
||||
domain_request3 = completed_domain_request(submitter=contact, name="city3.gov")
|
||||
domain_request4 = completed_domain_request(submitter=contact, name="city4.gov")
|
||||
domain_request5 = completed_domain_request(submitter=contact, name="city5.gov")
|
||||
completed_domain_request(submitter=contact, name="city6.gov")
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
# Use the test client to simulate the request
|
||||
response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
|
||||
logger.debug(mock_warning)
|
||||
# Assert that the error message was called with the correct argument
|
||||
# Note: The 6th join will be a user.
|
||||
mock_warning.assert_called_once_with(
|
||||
response.wsgi_request,
|
||||
"<ul class='messagelist_content-list--unstyled'>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request1.pk}/change/'>city1.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request2.pk}/change/'>city2.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request3.pk}/change/'>city3.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request4.pk}/change/'>city4.gov</a></li>"
|
||||
"<li>Joined to DomainRequest: <a href='/admin/registrar/"
|
||||
f"domainrequest/{domain_request5.pk}/change/'>city5.gov</a></li>"
|
||||
"</ul>"
|
||||
"<p class='font-sans-3xs'>And 1 more...</p>",
|
||||
)
|
||||
# cleanup this test
|
||||
DomainRequest.objects.all().delete()
|
||||
contact.delete()
|
||||
|
||||
|
||||
class TestVerifiedByStaffAdmin(TestCase):
|
||||
|
||||
|
@ -2066,6 +1987,7 @@ class TestPortfolioAdmin(TestCase):
|
|||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_created_on_display(self):
|
||||
|
@ -2107,9 +2029,7 @@ class TestPortfolioAdmin(TestCase):
|
|||
domain_2.save()
|
||||
|
||||
domains = self.admin.domains(self.portfolio)
|
||||
self.assertIn("domain1.gov", domains)
|
||||
self.assertIn("domain2.gov", domains)
|
||||
self.assertIn('<ul class="add-list-reset">', domains)
|
||||
self.assertIn("2 domains", domains)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_requests_display(self):
|
||||
|
@ -2118,6 +2038,311 @@ class TestPortfolioAdmin(TestCase):
|
|||
completed_domain_request(name="request2.gov", portfolio=self.portfolio)
|
||||
|
||||
domain_requests = self.admin.domain_requests(self.portfolio)
|
||||
self.assertIn("request1.gov", domain_requests)
|
||||
self.assertIn("request2.gov", domain_requests)
|
||||
self.assertIn('<ul class="add-list-reset">', domain_requests)
|
||||
self.assertIn("2 domain requests", domain_requests)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_portfolio_members_display(self):
|
||||
"""Tests the custom portfolio members field, admin and member sections"""
|
||||
admin_user_1 = User.objects.create(
|
||||
username="testuser1",
|
||||
first_name="Gerald",
|
||||
last_name="Meoward",
|
||||
title="Captain",
|
||||
email="meaoward@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_1, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
admin_user_2 = User.objects.create(
|
||||
username="testuser2",
|
||||
first_name="Arnold",
|
||||
last_name="Poopy",
|
||||
title="Major",
|
||||
email="poopy@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_2, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
admin_user_3 = User.objects.create(
|
||||
username="testuser3",
|
||||
first_name="Mad",
|
||||
last_name="Max",
|
||||
title="Road warrior",
|
||||
email="madmax@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_3, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||
)
|
||||
|
||||
admin_user_4 = User.objects.create(
|
||||
username="testuser4",
|
||||
first_name="Agent",
|
||||
last_name="Smith",
|
||||
title="Program",
|
||||
email="thematrix@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_4,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
|
||||
display_admins = self.admin.display_admins(self.portfolio)
|
||||
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_1.pk}/change/">Gerald Meoward meaoward@gov.gov</a>',
|
||||
display_admins,
|
||||
)
|
||||
self.assertIn("Captain", display_admins)
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_2.pk}/change/">Arnold Poopy poopy@gov.gov</a>', display_admins
|
||||
)
|
||||
self.assertIn("Major", display_admins)
|
||||
|
||||
display_members_summary = self.admin.display_members_summary(self.portfolio)
|
||||
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_3.pk}/change/">Mad Max madmax@gov.gov</a>',
|
||||
display_members_summary,
|
||||
)
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_4.pk}/change/">Agent Smith thematrix@gov.gov</a>',
|
||||
display_members_summary,
|
||||
)
|
||||
|
||||
display_members = self.admin.display_members(self.portfolio)
|
||||
|
||||
self.assertIn("Mad Max", display_members)
|
||||
self.assertIn("<span class='usa-tag'>Member</span>", display_members)
|
||||
self.assertIn("Road warrior", display_members)
|
||||
self.assertIn("Agent Smith", display_members)
|
||||
self.assertIn("<span class='usa-tag'>Domain requestor</span>", display_members)
|
||||
self.assertIn("Program", display_members)
|
||||
|
||||
|
||||
class TestTransferUser(WebTest):
|
||||
"""User transfer custom admin page"""
|
||||
|
||||
# csrf checks do not work well with WebTest.
|
||||
# We disable them here.
|
||||
csrf_checks = False
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.app.set_user(self.superuser)
|
||||
self.user1, _ = User.objects.get_or_create(
|
||||
username="madmax", first_name="Max", last_name="Rokatanski", title="Road warrior"
|
||||
)
|
||||
self.user2, _ = User.objects.get_or_create(
|
||||
username="furiosa", first_name="Furiosa", last_name="Jabassa", title="Imperator"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
Suborganization.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_shows_current_and_selected_user_information(self):
|
||||
"""Assert we pull the current user info and display it on the transfer page"""
|
||||
completed_domain_request(user=self.user1, name="wasteland.gov")
|
||||
domain_request = completed_domain_request(
|
||||
user=self.user1, name="citadel.gov", status=DomainRequest.DomainRequestStatus.SUBMITTED
|
||||
)
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||
domain_request.save()
|
||||
portfolio1 = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user1, portfolio=portfolio1, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio2 = Portfolio.objects.create(organization_name="Tokyo Hotel", creator=self.user2)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2, portfolio=portfolio2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
self.assertContains(user_transfer_page, "madmax")
|
||||
self.assertContains(user_transfer_page, "Max")
|
||||
self.assertContains(user_transfer_page, "Rokatanski")
|
||||
self.assertContains(user_transfer_page, "Road warrior")
|
||||
self.assertContains(user_transfer_page, "wasteland.gov")
|
||||
self.assertContains(user_transfer_page, "citadel.gov")
|
||||
self.assertContains(user_transfer_page, "Hotel California")
|
||||
|
||||
select_form = user_transfer_page.forms[0]
|
||||
select_form["selected_user"] = str(self.user2.id)
|
||||
preview_result = select_form.submit()
|
||||
|
||||
self.assertContains(preview_result, "furiosa")
|
||||
self.assertContains(preview_result, "Furiosa")
|
||||
self.assertContains(preview_result, "Jabassa")
|
||||
self.assertContains(preview_result, "Imperator")
|
||||
self.assertContains(preview_result, "Tokyo Hotel")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_user_portfolio_roles(self):
|
||||
"""Assert that a portfolio user role gets transferred"""
|
||||
portfolio = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
|
||||
user_portfolio_permission = UserPortfolioPermission.objects.create(
|
||||
user=self.user2, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
|
||||
user_portfolio_permission.refresh_from_db()
|
||||
|
||||
self.assertEquals(user_portfolio_permission.user, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_request_creator_and_investigator(self):
|
||||
"""Assert that domain request fields get transferred"""
|
||||
domain_request = completed_domain_request(user=self.user2, name="wasteland.gov", investigator=self.user2)
|
||||
|
||||
self.assertEquals(domain_request.creator, self.user2)
|
||||
self.assertEquals(domain_request.investigator, self.user2)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
domain_request.refresh_from_db()
|
||||
|
||||
self.assertEquals(domain_request.creator, self.user1)
|
||||
self.assertEquals(domain_request.investigator, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_information_creator(self):
|
||||
"""Assert that domain fields get transferred"""
|
||||
domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user2)
|
||||
|
||||
self.assertEquals(domain_information.creator, self.user2)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
domain_information.refresh_from_db()
|
||||
|
||||
self.assertEquals(domain_information.creator, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_role(self):
|
||||
"""Assert that user domain role get transferred"""
|
||||
domain_1, _ = Domain.objects.get_or_create(name="chrome.gov", state=Domain.State.READY)
|
||||
domain_2, _ = Domain.objects.get_or_create(name="v8.gov", state=Domain.State.READY)
|
||||
user_domain_role1, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user2, domain=domain_1, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
user_domain_role2, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user2, domain=domain_2, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
user_domain_role1.refresh_from_db()
|
||||
user_domain_role2.refresh_from_db()
|
||||
|
||||
self.assertEquals(user_domain_role1.user, self.user1)
|
||||
self.assertEquals(user_domain_role2.user, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_verified_by_staff_requestor(self):
|
||||
"""Assert that verified by staff creator gets transferred"""
|
||||
vip, _ = VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
vip.refresh_from_db()
|
||||
|
||||
self.assertEquals(vip.requestor, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_deletes_old_user(self):
|
||||
"""Assert that the slected user gets deleted"""
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
# Refresh user2 from the database and check if it still exists
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
self.user2.refresh_from_db()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_throws_transfer_and_delete_success_messages(self):
|
||||
"""Test that success messages for data transfer and user deletion are displayed."""
|
||||
# Ensure the setup for VerifiedByStaff
|
||||
VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
|
||||
|
||||
# Access the transfer user page
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
with patch("django.contrib.messages.success") as mock_success_message:
|
||||
|
||||
# Fill the form with the selected user and submit
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
after_submit = submit_form.submit().follow()
|
||||
|
||||
self.assertContains(after_submit, "<h1>Change user</h1>")
|
||||
|
||||
mock_success_message.assert_any_call(
|
||||
ANY,
|
||||
(
|
||||
"Data transferred successfully for the following objects: ['Changed requestor "
|
||||
+ 'from "Furiosa Jabassa " to "Max Rokatanski " on immortan.joe@citadel.com\']'
|
||||
),
|
||||
)
|
||||
|
||||
mock_success_message.assert_any_call(ANY, f"Deleted {self.user2} {self.user2.username}")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_throws_error_message(self):
|
||||
"""Test that an error message is thrown if the transfer fails."""
|
||||
with patch(
|
||||
"registrar.views.TransferUserView.transfer_user_fields_and_log", side_effect=Exception("Simulated Error")
|
||||
):
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
# Access the transfer user page
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
# Fill the form with the selected user and submit
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit().follow()
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_error.assert_called_once_with(ANY, "An error occurred during the transfer: Simulated Error")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_modal(self):
|
||||
"""Assert modal on page"""
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
self.assertContains(user_transfer_page, "This action cannot be undone.")
|
||||
|
|
|
@ -14,7 +14,9 @@ from registrar.models import (
|
|||
DomainInformation,
|
||||
User,
|
||||
Host,
|
||||
Portfolio,
|
||||
)
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
completed_domain_request,
|
||||
|
@ -356,9 +358,11 @@ class TestDomainAdminWithClient(TestCase):
|
|||
def tearDown(self):
|
||||
super().tearDown()
|
||||
Host.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(self):
|
||||
|
@ -423,13 +427,6 @@ class TestDomainAdminWithClient(TestCase):
|
|||
# Check for the field itself
|
||||
self.assertContains(response, "Meoward Jones")
|
||||
|
||||
# == Check for the submitter == #
|
||||
self.assertContains(response, "mayor@igorville.gov")
|
||||
|
||||
self.assertContains(response, "Admin Tester")
|
||||
self.assertContains(response, "(555) 555 5556")
|
||||
self.assertContains(response, "Testy2 Tester2")
|
||||
|
||||
# == Check for the senior_official == #
|
||||
self.assertContains(response, "testy@town.com")
|
||||
self.assertContains(response, "Chief Tester")
|
||||
|
@ -452,6 +449,36 @@ class TestDomainAdminWithClient(TestCase):
|
|||
domain_request.delete()
|
||||
_creator.delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domains_by_portfolio(self):
|
||||
"""
|
||||
Tests that domains display for a portfolio. And that domains outside the portfolio do not display.
|
||||
"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser)
|
||||
# Create a fake domain request and domain
|
||||
_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio
|
||||
)
|
||||
_domain_request.approve()
|
||||
|
||||
domain = _domain_request.approved_domain
|
||||
domain2, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
||||
UserDomainRole.objects.get_or_create()
|
||||
UserDomainRole.objects.get_or_create(user=self.superuser, domain=domain2, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domain/?portfolio={}".format(portfolio.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, domain.name)
|
||||
self.assertNotContains(response, domain2.name)
|
||||
self.assertContains(response, portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
|
|
@ -22,6 +22,8 @@ from registrar.models import (
|
|||
Contact,
|
||||
Website,
|
||||
SeniorOfficial,
|
||||
Portfolio,
|
||||
AllowedEmail,
|
||||
)
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
|
@ -35,6 +37,7 @@ from .common import (
|
|||
GenericTestHelper,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from django.conf import settings
|
||||
import boto3_mocking # type: ignore
|
||||
|
@ -69,6 +72,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
model=DomainRequest,
|
||||
)
|
||||
self.mock_client = MockSESClient()
|
||||
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
@ -78,12 +83,14 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
Contact.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
SeniorOfficial.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.mock_client.EMAILS_SENT.clear()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(self):
|
||||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
AllowedEmail.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
||||
|
@ -94,7 +101,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title")
|
||||
|
||||
contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson")
|
||||
domain_request = completed_domain_request(submitter=contact, name="city1.gov")
|
||||
domain_request = completed_domain_request(name="city1.gov")
|
||||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||
model_admin = AuditedAdmin(DomainRequest, self.site)
|
||||
|
||||
|
@ -149,11 +156,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# These should exist in the response
|
||||
expected_values = [
|
||||
("creator", "Person who submitted the domain request; will not receive email updates"),
|
||||
(
|
||||
"submitter",
|
||||
'Person listed under "your contact information" in the request form; will receive email updates',
|
||||
),
|
||||
("creator", "Person who submitted the domain request. Will receive email updates"),
|
||||
("approved_domain", "Domain associated with this request; will be blank until request is approved"),
|
||||
("no_other_contacts_rationale", "Required if creator does not list other employees"),
|
||||
("alternative_domains", "Other domain names the creator provided for consideration"),
|
||||
|
@ -263,6 +266,33 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assertContains(response, domain_request.requested_domain.name)
|
||||
self.assertContains(response, "<span>Show details</span>")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_requests_by_portfolio(self):
|
||||
"""
|
||||
Tests that domain_requests display for a portfolio. And requests not in portfolio do not display.
|
||||
"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser)
|
||||
# Create a fake domain request and domain
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio
|
||||
)
|
||||
domain_request2 = completed_domain_request(
|
||||
name="testdomain2.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
)
|
||||
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/?portfolio={}".format(portfolio.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, domain_request.requested_domain.name)
|
||||
self.assertNotContains(response, domain_request2.requested_domain.name)
|
||||
self.assertContains(response, portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_can_see_and_edit_alternative_domain(self):
|
||||
"""Tests if an analyst can still see and edit the alternative domain field"""
|
||||
|
@ -409,8 +439,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",))
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_submitter_sortable(self):
|
||||
"""Tests if the DomainRequest sorts by submitter correctly"""
|
||||
def test_creator_sortable(self):
|
||||
"""Tests if the DomainRequest sorts by creator correctly"""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
multiple_unalphabetical_domain_objects("domain_request")
|
||||
|
@ -422,19 +452,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Assert that our sort works correctly
|
||||
self.test_helper.assert_table_sorted(
|
||||
"11",
|
||||
"13",
|
||||
(
|
||||
"submitter__first_name",
|
||||
"submitter__last_name",
|
||||
"creator__first_name",
|
||||
"creator__last_name",
|
||||
),
|
||||
)
|
||||
|
||||
# Assert that sorting in reverse works correctly
|
||||
self.test_helper.assert_table_sorted(
|
||||
"-11",
|
||||
"-13",
|
||||
(
|
||||
"-submitter__first_name",
|
||||
"-submitter__last_name",
|
||||
"-creator__first_name",
|
||||
"-creator__last_name",
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -454,7 +484,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Assert that our sort works correctly
|
||||
self.test_helper.assert_table_sorted(
|
||||
"12",
|
||||
"14",
|
||||
(
|
||||
"investigator__first_name",
|
||||
"investigator__last_name",
|
||||
|
@ -463,7 +493,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Assert that sorting in reverse works correctly
|
||||
self.test_helper.assert_table_sorted(
|
||||
"-12",
|
||||
"-14",
|
||||
(
|
||||
"-investigator__first_name",
|
||||
"-investigator__last_name",
|
||||
|
@ -476,7 +506,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
@less_console_noise_decorator
|
||||
def test_default_sorting_in_domain_requests_list(self):
|
||||
"""
|
||||
Make sure the default sortin in on the domain requests list page is reverse submission_date
|
||||
Make sure the default sortin in on the domain requests list page is reverse last_submitted_date
|
||||
then alphabetical requested_domain
|
||||
"""
|
||||
|
||||
|
@ -486,12 +516,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"]
|
||||
]
|
||||
|
||||
domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16))
|
||||
domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16))
|
||||
domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16))
|
||||
domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16))
|
||||
domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16))
|
||||
domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16))
|
||||
domain_requests[0].last_submitted_date = timezone.make_aware(datetime(2024, 10, 16))
|
||||
domain_requests[1].last_submitted_date = timezone.make_aware(datetime(2001, 10, 16))
|
||||
domain_requests[2].last_submitted_date = timezone.make_aware(datetime(1980, 10, 16))
|
||||
domain_requests[3].last_submitted_date = timezone.make_aware(datetime(1998, 10, 16))
|
||||
domain_requests[4].last_submitted_date = timezone.make_aware(datetime(2013, 10, 16))
|
||||
domain_requests[5].last_submitted_date = timezone.make_aware(datetime(1980, 10, 16))
|
||||
|
||||
# Save the modified domain requests to update their attributes in the database
|
||||
for domain_request in domain_requests:
|
||||
|
@ -570,7 +600,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
):
|
||||
"""Helper method for the email test cases."""
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||
|
||||
|
@ -597,7 +628,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
):
|
||||
"""Helper method for the email test cases.
|
||||
email_index is the index of the email in mock_client."""
|
||||
|
||||
AllowedEmail.objects.get_or_create(email=email_address)
|
||||
AllowedEmail.objects.get_or_create(email=bcc_email_address)
|
||||
with less_console_noise():
|
||||
# Access the arguments passed to send_email
|
||||
call_args = self.mock_client.EMAILS_SENT
|
||||
|
@ -630,15 +662,24 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
def test_action_needed_sends_reason_email_prod_bcc(self):
|
||||
"""When an action needed reason is set, an email is sent out and help@get.gov
|
||||
is BCC'd in production"""
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
# Create fake creator
|
||||
EMAIL = "meoward.jones@igorville.gov"
|
||||
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email=EMAIL,
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=in_review)
|
||||
domain_request = completed_domain_request(status=in_review, user=_creator)
|
||||
|
||||
# Test the email sent out for already_has_domains
|
||||
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||
|
@ -667,7 +708,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
|
||||
self.assert_email_is_accurate(
|
||||
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
|
||||
|
@ -688,7 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
|
||||
domain_request.refresh_from_db()
|
||||
self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||
|
||||
# Tests if a new email gets sent when just the email is changed.
|
||||
|
@ -712,7 +753,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
action_needed_reason=eligibility_unclear,
|
||||
action_needed_reason_email="custom content when starting anew",
|
||||
)
|
||||
self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
self.assert_email_is_accurate(
|
||||
"custom content when starting anew", 5, _creator.email, bcc_email_address=BCC_EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
|
||||
|
||||
# def test_action_needed_sends_reason_email_prod_bcc(self):
|
||||
|
@ -776,22 +819,31 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
Also test that the default email set in settings is NOT BCCd on non-prod whenever
|
||||
an email does go out."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
EMAIL = "meoward.jones@igorville.gov"
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email=EMAIL,
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Test Submitted Status from started
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, True)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, True)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN)
|
||||
self.assert_email_is_accurate(
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL, True
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, _creator.email, True
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
|
@ -850,30 +902,37 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
Also test that the default email set in settings IS BCCd on prod whenever
|
||||
an email does go out."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request()
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Test Submitted Status from started
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, False, BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN)
|
||||
self.assert_email_is_accurate(
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, _creator.email
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
# Test Submitted Status Again (from withdrawn)
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 0, _creator.email, False, BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
|
||||
# Move it to IN_REVIEW
|
||||
|
@ -898,21 +957,29 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
|
||||
@override_flag("profile_feature", True)
|
||||
@less_console_noise_decorator
|
||||
def test_save_model_sends_approved_email(self):
|
||||
"""When transitioning to approved on a domain request,
|
||||
an email is sent out every time."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Test Submitted Status
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
|
@ -921,7 +988,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.DOMAIN_PURPOSE,
|
||||
)
|
||||
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
# Test Submitted Status Again (No new email should be sent)
|
||||
|
@ -933,12 +1000,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is domain purpose."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason DOMAIN_PURPOSE and test email
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -949,13 +1023,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because the purpose you provided did not meet our \nrequirements.",
|
||||
0,
|
||||
EMAIL,
|
||||
_creator.email,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -963,12 +1037,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is requestor."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason REQUESTOR and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -978,13 +1059,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov "
|
||||
"domain on behalf of Testorg",
|
||||
0,
|
||||
EMAIL,
|
||||
_creator.email,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -992,12 +1073,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is second domain."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason SECOND_DOMAIN_REASONING and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -1005,25 +1093,35 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING,
|
||||
)
|
||||
self.assert_email_is_accurate("Your domain request was rejected because Testorg has a .gov domain.", 0, EMAIL)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_save_model_sends_rejected_email_contacts_or_org_legitimacy(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is contacts or org legitimacy."""
|
||||
# Create fake creator
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
EMAIL = "meoward.jones@igorville.gov"
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email=EMAIL,
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason CONTACTS_OR_ORGANIZATION_LEGITIMACY and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -1035,13 +1133,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"Your domain request was rejected because we could not verify the organizational \n"
|
||||
"contacts you provided. If you have questions or comments, reply to this email.",
|
||||
0,
|
||||
EMAIL,
|
||||
_creator.email,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -1049,12 +1147,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is org eligibility."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason ORGANIZATION_ELIGIBILITY and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -1066,26 +1171,32 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"Your domain request was rejected because we determined that Testorg is not \neligible for "
|
||||
"a .gov domain.",
|
||||
0,
|
||||
EMAIL,
|
||||
_creator.email,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_save_model_sends_rejected_email_naming(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is naming."""
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -1094,13 +1205,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
DomainRequest.RejectionReasons.NAMING_REQUIREMENTS,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because it does not meet our naming requirements.", 0, EMAIL
|
||||
"Your domain request was rejected because it does not meet our naming requirements.", 0, _creator.email
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -1108,12 +1219,19 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is other."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelist user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
|
@ -1121,12 +1239,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.OTHER,
|
||||
)
|
||||
self.assert_email_is_accurate("Choosing a .gov domain name", 0, EMAIL)
|
||||
self.assert_email_is_accurate("Choosing a .gov domain name", 0, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -1145,6 +1263,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||
|
||||
self.admin.save_model(request, domain_request, None, True)
|
||||
|
@ -1173,6 +1292,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
||||
|
||||
|
@ -1188,23 +1308,30 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"""When transitioning to withdrawn on a domain request,
|
||||
an email is sent out every time."""
|
||||
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email="meoward.jones@igorville.gov",
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
# Create a sample domain request and whitelists user email
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
|
||||
AllowedEmail.objects.get_or_create(email=_creator.email)
|
||||
|
||||
# Test Submitted Status
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN)
|
||||
self.assert_email_is_accurate(
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL
|
||||
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, _creator.email
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 1, EMAIL)
|
||||
self.assert_email_is_accurate("We received your .gov domain request.", 1, _creator.email)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
# Test Submitted Status Again (No new email should be sent)
|
||||
|
@ -1224,11 +1351,13 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
# Modify the domain request's property
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
# Modify the domain request's property
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
self.admin.save_model(request, domain_request, form=None, change=True)
|
||||
# Use the model admin's save_model method
|
||||
self.admin.save_model(request, domain_request, form=None, change=True)
|
||||
|
||||
# Test that approved domain exists and equals requested domain
|
||||
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
|
||||
|
@ -1381,16 +1510,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
# Check for the field itself
|
||||
self.assertContains(response, "Meoward Jones")
|
||||
|
||||
# == Check for the submitter == #
|
||||
self.assertContains(response, "mayor@igorville.gov", count=2)
|
||||
expected_submitter_fields = [
|
||||
# Field, expected value
|
||||
("title", "Admin Tester"),
|
||||
("phone", "(555) 555 5556"),
|
||||
]
|
||||
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
|
||||
self.assertContains(response, "Testy2 Tester2")
|
||||
|
||||
# == Check for the senior_official == #
|
||||
self.assertContains(response, "testy@town.com", count=2)
|
||||
expected_so_fields = [
|
||||
|
@ -1411,7 +1530,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||
|
||||
# Test for the copy link
|
||||
self.assertContains(response, "button--clipboard", count=5)
|
||||
self.assertContains(response, "button--clipboard", count=4)
|
||||
|
||||
# Test that Creator counts display properly
|
||||
self.assertNotContains(response, "Approved domains")
|
||||
|
@ -1546,7 +1665,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"senior_official",
|
||||
"approved_domain",
|
||||
"requested_domain",
|
||||
"submitter",
|
||||
"purpose",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
|
@ -1556,7 +1674,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"cisa_representative_last_name",
|
||||
"has_cisa_representative",
|
||||
"is_policy_acknowledged",
|
||||
"submission_date",
|
||||
"first_submitted_date",
|
||||
"last_submitted_date",
|
||||
"last_status_update",
|
||||
"notes",
|
||||
"alternative_domains",
|
||||
]
|
||||
|
@ -1583,7 +1703,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"approved_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
|
@ -1686,6 +1805,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
stack.enter_context(patch.object(messages, "success"))
|
||||
|
||||
domain_request.status = another_state
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue