Merge branch 'main' into el/2232-admin-list-markup

This commit is contained in:
Rachid Mrad 2025-01-08 16:04:19 -05:00 committed by GitHub
commit e8ef9ead7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1795 additions and 4503 deletions

View file

@ -4,7 +4,6 @@ Pull requests should be titled in the format of `#issue_number: Descriptive name
Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should:
- Add at least 2 developers as PR reviewers (only 1 will need to approve).
- Message on Slack or in standup to notify the team that a PR is ready for review.
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.

1317
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

4662
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@uswds/compile": "1.1.0",
"@uswds/compile": "1.2.1",
"babel-loader": "^9.2.1",
"sass-loader": "^12.6.0",
"webpack": "^5.96.1",

View file

@ -1434,6 +1434,20 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change):
"""
Override the save_model method.
On creation of a new domain invitation, attempt to retrieve the invitation,
which will be successful if a single User exists for that email; otherwise, will
just continue to create the invitation.
"""
if not change and User.objects.filter(email=obj.email).count() == 1:
# Domain Invitation creation for an existing User
obj.retrieve()
# Call the parent save method to save the object
super().save_model(request, obj, form, change)
class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class."""
@ -2736,8 +2750,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return response
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Display restricted warning,
Setup the auditlog trail and pass it in extra context."""
"""Display restricted warning, setup the auditlog trail and pass it in extra context,
display warning that status cannot be changed from 'Approved' if domain is in Ready state"""
# Fetch the domain request instance
domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id)
if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY:
domain = domain_request.approved_domain
# get change url for domain
app_label = domain_request.approved_domain._meta.app_label
model_name = domain._meta.model_name
obj_id = domain.id
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
message = format_html(
"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa: E501
"<a href='{}'>{}</a>",
mark_safe(change_url), # nosec
escape(str(domain)),
)
messages.warning(
request,
message,
)
obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj)

View file

@ -13,6 +13,7 @@ export function handleRequestingEntityFieldset() {
const selectParent = select?.parentElement;
const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding.
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
@ -26,7 +27,13 @@ export function handleRequestingEntityFieldset() {
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
if (select.options.length == 2) { // --Select-- and other are the only options
hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg
hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list
requestingNewSuborganization.value = "True";
} else {
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False";
}
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
}

View file

@ -137,7 +137,7 @@ export class MembersTable extends BaseTable {
}
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row);
if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberDeleteModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row);
}
/**
@ -417,25 +417,22 @@ export class MembersTable extends BaseTable {
* @param {string} submit_delete_url - `${member_type}-${member_id}/delete`
* @param {HTMLElement} wrapper_element - The element to which the modal is appended
*/
static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
let modalHeading = '';
let modalDescription = '';
static addMemberDeleteModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
if (num_domains == 0){
let modalHeading = ``;
let modalDescription = ``;
if (num_domains >= 0){
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `They will no longer be able to access this organization.
This action cannot be undone.`;
} else if (num_domains == 1) {
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain in the organization.
Removing them from the organization will remove all of their domains. They will no longer be able to
access this organization. This action cannot be undone.`;
} else if (num_domains > 1) {
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domains in the organization.
Removing them from the organization will remove all of their domains. They will no longer be able to
if (num_domains >= 1)
{
modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain${num_domains > 1 ? "s": ""} in the organization.
Removing them from the organization will remove them from all of their domains. They will no longer be able to
access this organization. This action cannot be undone.`;
}
}
const modalSubmit = `
<button type="button"

View file

@ -1,4 +1,4 @@
from datetime import timedelta
from datetime import datetime, timedelta
from django.utils import timezone
import logging
import random
@ -126,7 +126,22 @@ class DomainRequestFixture:
# TODO for a future ticket: Allow for more than just "federal" here
request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal"
if request.status != "started":
request.last_submitted_date = fake.date()
# Generate fake data for first_submitted_date and last_submitted_date
# First generate a random date set to be later than 2020 (or something)
# (if we just use fake.date() we might get years like 1970 or earlier)
earliest_date_allowed = datetime(2020, 1, 1).date()
end_date = datetime.today().date() # Today's date (latest allowed date)
days_range = (end_date - earliest_date_allowed).days
first_submitted_date = earliest_date_allowed + timedelta(days=random.randint(0, days_range)) # nosec
# Generate a random positive offset to ensure last_submitted_date is later
# (Start with 1 to ensure at least 1 day difference)
offset_days = random.randint(1, 30) # nosec
last_submitted_date = first_submitted_date + timedelta(days=offset_days)
# Convert back to strings before assigning
request.first_submitted_date = first_submitted_date.strftime("%Y-%m-%d")
request.last_submitted_date = last_submitted_date.strftime("%Y-%m-%d")
request.federal_type = (
request_dict["federal_type"]
if "federal_type" in request_dict

View file

@ -159,9 +159,12 @@ class RequestingEntityYesNoForm(BaseYesNoForm):
"""Extend the initialization of the form from RegistrarForm __init__"""
super().__init__(*args, **kwargs)
if self.domain_request.portfolio:
choose_text = (
"(choose from list)" if self.domain_request.portfolio.portfolio_suborganizations.exists() else ""
)
self.form_choices = (
(False, self.domain_request.portfolio),
(True, "A suborganization (choose from list)"),
(True, f"A suborganization {choose_text}"),
)
self.fields[self.field_name] = self.get_typed_choice_field()

View file

@ -6,11 +6,7 @@
<ul class="usa-list">
<li>Be available </li>
<li>Relate to your organizations name, location, and/or services </li>
{% if portfolio %}
<li>Be clear to the general public. Your domain name must not be easily confused with other organizations.</li>
{% else %}
<li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
{% endif %}
</ul>
</p>

View file

@ -38,8 +38,9 @@
<div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2>
<p>
This information will be published in <a class="usa-link usa-link--always-blue" target="_blank" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other.”
This information will be published in <a class="usa-link usa-link--always-blue" target="_blank" href="{% public_site_url 'about/data' %}">.govs public data</a>.
<span id="suborganization-addtl-instruction"> If you dont see your suborganization in the list,
select “other.”</span>
</p>
{% with attr_required=True %}
{% input_with_errors forms.1.sub_organization %}

View file

@ -131,13 +131,11 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin
"""
@classmethod
def setUpClass(cls):
cls.factory = RequestFactory()
cls.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
cls.superuser = create_superuser()
def setUp(self):
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.superuser = create_superuser()
self.domain = Domain.objects.create(name="example.com")
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
@ -145,9 +143,6 @@ class TestDomainInvitationAdmin(TestCase):
"""Delete all DomainInvitation objects"""
DomainInvitation.objects.all().delete()
Contact.objects.all().delete()
@classmethod
def tearDownClass(self):
User.objects.all().delete()
@less_console_noise_decorator
@ -168,6 +163,7 @@ class TestDomainInvitationAdmin(TestCase):
)
self.assertContains(response, "Show more")
@less_console_noise_decorator
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
@ -192,6 +188,59 @@ class TestDomainInvitationAdmin(TestCase):
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@less_console_noise_decorator
def test_save_model_user_exists(self):
"""Test saving a domain invitation when the user exists.
Should attempt to retrieve the domain invitation."""
# Create a user with the same email
User.objects.create_user(email="test@example.com", username="username")
# Create a domain invitation instance
invitation = DomainInvitation(email="test@example.com", domain=self.domain)
admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None)
# Create a request object
request = self.factory.post("/admin/registrar/DomainInvitation/add/")
request.user = self.superuser
# Patch the retrieve method
with patch.object(DomainInvitation, "retrieve") as mock_retrieve:
admin_instance.save_model(request, invitation, form=None, change=False)
# Assert retrieve was called
mock_retrieve.assert_called_once()
# Assert the invitation was saved
self.assertEqual(DomainInvitation.objects.count(), 1)
self.assertEqual(DomainInvitation.objects.first().email, "test@example.com")
@less_console_noise_decorator
def test_save_model_user_does_not_exist(self):
"""Test saving a domain invitation when the user does not exist.
Should not attempt to retrieve the domain invitation."""
# Create a domain invitation instance
invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain)
admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None)
# Create a request object
request = self.factory.post("/admin/registrar/DomainInvitation/add/")
request.user = self.superuser
# Patch the retrieve method to ensure it is not called
with patch.object(DomainInvitation, "retrieve") as mock_retrieve:
admin_instance.save_model(request, invitation, form=None, change=False)
# Assert retrieve was not called
mock_retrieve.assert_not_called()
# Assert the invitation was saved
self.assertEqual(DomainInvitation.objects.count(), 1)
self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com")
class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class"""

View file

@ -28,6 +28,8 @@ from registrar.models import (
AllowedEmail,
Suborganization,
)
from registrar.models.host import Host
from registrar.models.public_contact import PublicContact
from .common import (
MockSESClient,
completed_domain_request,
@ -40,7 +42,7 @@ from .common import (
GenericTestHelper,
normalize_html,
)
from unittest.mock import patch
from unittest.mock import ANY, patch
from django.conf import settings
import boto3_mocking # type: ignore
@ -80,6 +82,8 @@ class TestDomainRequestAdmin(MockEppLib):
def tearDown(self):
super().tearDown()
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
@ -2107,6 +2111,37 @@ class TestDomainRequestAdmin(MockEppLib):
"Cannot edit a domain request with a restricted creator.",
)
@less_console_noise_decorator
def test_approved_domain_request_with_ready_domain_has_warning_message(self):
"""Tests if the domain request has a warning message when the approved domain is in Ready state"""
# Create an instance of the model
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Approve the domain request
domain_request.approve()
domain_request.save()
# Add nameservers to get to Ready state
domain_request.approved_domain.nameservers = [
("ns1.city.gov", ["1.1.1.1"]),
("ns2.city.gov", ["1.1.1.2"]),
]
domain_request.approved_domain.save()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with patch("django.contrib.messages.warning") as mock_warning:
# Create a request object
self.client.force_login(self.superuser)
self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Assert that the error message was called with the correct argument
mock_warning.assert_called_once_with(
ANY, # don't care about the request argument
f"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: <a href='/admin/registrar/domain/{domain_request.approved_domain.id}/change/'>{domain_request.approved_domain.name}</a>", # noqa
)
def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None):
"""Helper method that triggers domain request state changes from approved to another state,
with an associated domain that can be either active (READY) or not.

View file

@ -338,7 +338,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
self.assertEqual(expected_domain_request.creator.email, creator[i])
# Check action url, action label and svg icon
# Example domain requests will test each of below three scenarios
if creator[i] != self.user.email:
if creator[i] != self.user.email or not self.user.has_edit_request_portfolio_permission(self.portfolio):
# Test case where action is View
self.assertEqual("View", action_labels[i])
self.assertEqual(

View file

@ -1952,7 +1952,7 @@ class DomainRequestGrowth(DomainRequestExport):
"Domain request",
"Domain type",
"Federal type",
"Submitted at",
"First submitted date",
]
@classmethod
@ -1976,7 +1976,6 @@ class DomainRequestGrowth(DomainRequestExport):
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
return Q(
status=DomainRequest.DomainRequestStatus.SUBMITTED,
last_submitted_date__lte=end_date_formatted,
last_submitted_date__gte=start_date_formatted,
)

View file

@ -125,15 +125,6 @@ def serialize_domain_request(request, domain_request, user):
DomainRequest.DomainRequestStatus.WITHDRAWN,
]
# Determine if the request is deletable
if not user.is_org_user(request):
is_deletable = domain_request.status in deletable_statuses
else:
portfolio = request.session.get("portfolio")
is_deletable = (
domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio)
) and domain_request.creator == user
# Determine action label based on user permissions and request status
editable_statuses = [
DomainRequest.DomainRequestStatus.STARTED,
@ -141,7 +132,22 @@ def serialize_domain_request(request, domain_request, user):
DomainRequest.DomainRequestStatus.WITHDRAWN,
]
if user.has_edit_request_portfolio_permission and domain_request.creator == user:
# No portfolio action_label
if domain_request.creator == user:
action_label = "Edit" if domain_request.status in editable_statuses else "Manage"
else:
action_label = "View"
# No portfolio deletable
is_deletable = domain_request.status in deletable_statuses
# If we're working with a portfolio
if user.is_org_user(request):
portfolio = request.session.get("portfolio")
is_deletable = (
domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio)
) and domain_request.creator == user
if user.has_edit_request_portfolio_permission(portfolio) and domain_request.creator == user:
action_label = "Edit" if domain_request.status in editable_statuses else "Manage"
else:
action_label = "View"

View file

@ -1,68 +1,68 @@
-i https://pypi.python.org/simple
annotated-types==0.7.0; python_version >= '3.8'
asgiref==3.8.1; python_version >= '3.8'
boto3==1.35.41; python_version >= '3.8'
botocore==1.35.41; python_version >= '3.8'
boto3==1.35.91; python_version >= '3.8'
botocore==1.35.91; python_version >= '3.8'
cachetools==5.5.0; python_version >= '3.7'
certifi==2024.8.30; python_version >= '3.6'
certifi==2024.12.14; python_version >= '3.6'
cfenv==0.5.3
cffi==1.17.1; platform_python_implementation != 'PyPy'
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
cryptography==43.0.1; python_version >= '3.7'
cffi==1.17.1; python_version >= '3.8'
charset-normalizer==3.4.1; python_version >= '3.7'
cryptography==44.0.0; python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
diff-match-patch==20230430; python_version >= '3.7'
dj-database-url==2.2.0
diff-match-patch==20241021; python_version >= '3.7'
dj-database-url==2.3.0
dj-email-url==1.0.6
django==4.2.17; python_version >= '3.8'
django-admin-multiple-choice-list-filter==0.1.1
django-allow-cidr==0.7.1
django-auditlog==3.0.0; python_version >= '3.8'
django-cache-url==3.4.5
django-cors-headers==4.5.0; python_version >= '3.9'
django-cors-headers==4.6.0; python_version >= '3.9'
django-csp==3.8
django-fsm==2.8.1
django-import-export==4.1.1; python_version >= '3.8'
django-import-export==4.3.3; python_version >= '3.9'
django-login-required-middleware==0.9.0
django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8'
django-waffle==4.1.0; python_version >= '3.8'
django-waffle==4.2.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==11.0.0; python_version >= '3.8'
faker==30.3.0; python_version >= '3.8'
environs[django]==11.2.1; python_version >= '3.8'
faker==33.1.0; python_version >= '3.8'
fred-epplib @ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
gevent==24.10.2; python_version >= '3.9'
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'
gevent==24.11.1; python_version >= '3.9'
greenlet==3.1.1; python_version >= '3.7'
gunicorn==23.0.0; python_version >= '3.7'
idna==3.10; python_version >= '3.6'
jmespath==1.0.1; python_version >= '3.7'
lxml==5.3.0; python_version >= '3.6'
mako==1.3.5; python_version >= '3.8'
markupsafe==3.0.1; python_version >= '3.9'
marshmallow==3.22.0; python_version >= '3.8'
mako==1.3.8; python_version >= '3.8'
markupsafe==3.0.2; python_version >= '3.9'
marshmallow==3.23.2; python_version >= '3.9'
oic==1.7.0; python_version ~= '3.8'
orderedmultidict==1.0.1
packaging==24.1; python_version >= '3.8'
phonenumberslite==8.13.47
psycopg2-binary==2.9.9; python_version >= '3.7'
packaging==24.2; python_version >= '3.8'
phonenumberslite==8.13.52
psycopg2-binary==2.9.10; python_version >= '3.8'
pycparser==2.22; python_version >= '3.8'
pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pydantic==2.9.2; python_version >= '3.8'
pydantic-core==2.23.4; python_version >= '3.8'
pydantic-settings==2.5.2; python_version >= '3.8'
pydantic==2.10.4; python_version >= '3.8'
pydantic-core==2.27.2; python_version >= '3.8'
pydantic-settings==2.7.1; python_version >= '3.8'
pyjwkest==1.4.2
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'
python-dotenv==1.0.1; python_version >= '3.8'
pyzipper==0.3.6; python_version >= '3.4'
requests==2.32.3; python_version >= '3.8'
s3transfer==0.10.3; python_version >= '3.8'
setuptools==75.1.0; python_version >= '3.8'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.5.1; python_version >= '3.8'
tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
s3transfer==0.10.4; python_version >= '3.8'
setuptools==75.6.0; python_version >= '3.9'
six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'
sqlparse==0.5.3; python_version >= '3.8'
tablib==3.7.0; python_version >= '3.9'
tblib==3.0.0; python_version >= '3.8'
typing-extensions==4.12.2; python_version >= '3.8'
urllib3==2.2.3; python_version >= '3.8'
whitenoise==6.7.0; python_version >= '3.8'
urllib3==2.3.0; python_version >= '3.9'
whitenoise==6.8.2; python_version >= '3.9'
zope.event==5.0; python_version >= '3.7'
zope.interface==7.1.0; python_version >= '3.8'
zope.interface==7.2; python_version >= '3.8'