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` Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should: 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. - 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. - 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

4666
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1434,6 +1434,20 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# Get the filtered values # Get the filtered values
return super().changelist_view(request, extra_context=extra_context) 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): class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class.""" """Custom portfolio invitation admin class."""
@ -2736,8 +2750,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return response return response
def change_view(self, request, object_id, form_url="", extra_context=None): def change_view(self, request, object_id, form_url="", extra_context=None):
"""Display restricted warning, """Display restricted warning, setup the auditlog trail and pass it in extra context,
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) obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj) self.display_restricted_warning(request, obj)

View file

@ -13,6 +13,7 @@ export function handleRequestingEntityFieldset() {
const selectParent = select?.parentElement; const selectParent = select?.parentElement;
const suborgContainer = document.getElementById("suborganization-container"); const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details"); const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding. // 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. // 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) { function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; 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); 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 // 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 // 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,24 +417,21 @@ export class MembersTable extends BaseTable {
* @param {string} submit_delete_url - `${member_type}-${member_id}/delete` * @param {string} submit_delete_url - `${member_type}-${member_id}/delete`
* @param {HTMLElement} wrapper_element - The element to which the modal is appended * @param {HTMLElement} wrapper_element - The element to which the modal is appended
*/ */
static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { static addMemberDeleteModal(num_domains, member_email, submit_delete_url, id, wrapper_element) {
let modalHeading = '';
let modalDescription = '';
if (num_domains == 0){ let modalHeading = ``;
let modalDescription = ``;
if (num_domains >= 0){
modalHeading = `Are you sure you want to delete ${member_email}?`; modalHeading = `Are you sure you want to delete ${member_email}?`;
modalDescription = `They will no longer be able to access this organization. modalDescription = `They will no longer be able to access this organization.
This action cannot be undone.`; This action cannot be undone.`;
} else if (num_domains == 1) { 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. modalDescription = `<b>${member_email}</b> currently manages ${num_domains} domain${num_domains > 1 ? "s": ""} in the organization.
Removing them from the organization will remove all of their domains. They will no longer be able to 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.`; 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
access this organization. This action cannot be undone.`;
} }
const modalSubmit = ` const modalSubmit = `

View file

@ -1,4 +1,4 @@
from datetime import timedelta from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
import logging import logging
import random import random
@ -126,7 +126,22 @@ class DomainRequestFixture:
# TODO for a future ticket: Allow for more than just "federal" here # 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" request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal"
if request.status != "started": 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.federal_type = (
request_dict["federal_type"] request_dict["federal_type"]
if "federal_type" in request_dict if "federal_type" in request_dict

View file

@ -159,9 +159,12 @@ class RequestingEntityYesNoForm(BaseYesNoForm):
"""Extend the initialization of the form from RegistrarForm __init__""" """Extend the initialization of the form from RegistrarForm __init__"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.domain_request.portfolio: if self.domain_request.portfolio:
choose_text = (
"(choose from list)" if self.domain_request.portfolio.portfolio_suborganizations.exists() else ""
)
self.form_choices = ( self.form_choices = (
(False, self.domain_request.portfolio), (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() self.fields[self.field_name] = self.get_typed_choice_field()

View file

@ -6,11 +6,7 @@
<ul class="usa-list"> <ul class="usa-list">
<li>Be available </li> <li>Be available </li>
<li>Relate to your organizations name, location, and/or services </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> <li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
{% endif %}
</ul> </ul>
</p> </p>

View file

@ -38,8 +38,9 @@
<div id="suborganization-container" class="margin-top-4"> <div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2> <h2>Add suborganization information</h2>
<p> <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, 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>.
select “other.” <span id="suborganization-addtl-instruction"> If you dont see your suborganization in the list,
select “other.”</span>
</p> </p>
{% with attr_required=True %} {% with attr_required=True %}
{% input_with_errors forms.1.sub_organization %} {% input_with_errors forms.1.sub_organization %}

View file

@ -131,13 +131,11 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin 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): 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""" """Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
@ -145,9 +143,6 @@ class TestDomainInvitationAdmin(TestCase):
"""Delete all DomainInvitation objects""" """Delete all DomainInvitation objects"""
DomainInvitation.objects.all().delete() DomainInvitation.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
@classmethod
def tearDownClass(self):
User.objects.all().delete() User.objects.all().delete()
@less_console_noise_decorator @less_console_noise_decorator
@ -168,6 +163,7 @@ class TestDomainInvitationAdmin(TestCase):
) )
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
@less_console_noise_decorator
def test_get_filters(self): def test_get_filters(self):
"""Ensures that our filters are displaying correctly""" """Ensures that our filters are displaying correctly"""
with less_console_noise(): with less_console_noise():
@ -192,6 +188,59 @@ class TestDomainInvitationAdmin(TestCase):
self.assertContains(response, invited_html, count=1) self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_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): class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class""" """Tests for the PortfolioInivtationAdmin class"""

View file

@ -28,6 +28,8 @@ from registrar.models import (
AllowedEmail, AllowedEmail,
Suborganization, Suborganization,
) )
from registrar.models.host import Host
from registrar.models.public_contact import PublicContact
from .common import ( from .common import (
MockSESClient, MockSESClient,
completed_domain_request, completed_domain_request,
@ -40,7 +42,7 @@ from .common import (
GenericTestHelper, GenericTestHelper,
normalize_html, normalize_html,
) )
from unittest.mock import patch from unittest.mock import ANY, patch
from django.conf import settings from django.conf import settings
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -80,6 +82,8 @@ class TestDomainRequestAdmin(MockEppLib):
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
@ -2107,6 +2111,37 @@ class TestDomainRequestAdmin(MockEppLib):
"Cannot edit a domain request with a restricted creator.", "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): 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, """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. 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]) self.assertEqual(expected_domain_request.creator.email, creator[i])
# Check action url, action label and svg icon # Check action url, action label and svg icon
# Example domain requests will test each of below three scenarios # 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 # Test case where action is View
self.assertEqual("View", action_labels[i]) self.assertEqual("View", action_labels[i])
self.assertEqual( self.assertEqual(

View file

@ -1952,7 +1952,7 @@ class DomainRequestGrowth(DomainRequestExport):
"Domain request", "Domain request",
"Domain type", "Domain type",
"Federal type", "Federal type",
"Submitted at", "First submitted date",
] ]
@classmethod @classmethod
@ -1976,7 +1976,6 @@ class DomainRequestGrowth(DomainRequestExport):
start_date_formatted = format_start_date(start_date) start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date) end_date_formatted = format_end_date(end_date)
return Q( return Q(
status=DomainRequest.DomainRequestStatus.SUBMITTED,
last_submitted_date__lte=end_date_formatted, last_submitted_date__lte=end_date_formatted,
last_submitted_date__gte=start_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, 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 # Determine action label based on user permissions and request status
editable_statuses = [ editable_statuses = [
DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.STARTED,
@ -141,11 +132,26 @@ def serialize_domain_request(request, domain_request, user):
DomainRequest.DomainRequestStatus.WITHDRAWN, 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" action_label = "Edit" if domain_request.status in editable_statuses else "Manage"
else: else:
action_label = "View" 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"
# Map the action label to corresponding URLs and icons # Map the action label to corresponding URLs and icons
action_url_map = { action_url_map = {
"Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}), "Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}),

View file

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