diff --git a/src/Pipfile b/src/Pipfile index fdf127d7c..07b1db715 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -django = "4.2.10" +django = "4.2.17" cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 33b858314..56daf5db5 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1789,11 +1789,12 @@ }, "waitress": { "hashes": [ - "sha256:005da479b04134cdd9dd602d1ee7c49d79de0537610d653674cc6cbde222b8a1", - "sha256:2a06f242f4ba0cc563444ca3d1998959447477363a2d7e9b8b4d75d35cfd1669" + "sha256:26cdbc593093a15119351690752c99adc13cbc6786d75f7b6341d1234a3730ac", + "sha256:ef0c1f020d9f12a515c4ec65c07920a702613afcad1dbfdc3bcec256b6c072b3" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.0.0" + "index": "pypi", + "markers": "python_full_version >= '3.9.0'", + "version": "==3.0.1" }, "webob": { "hashes": [ diff --git a/src/package.json b/src/package.json index e433d0126..7f7448bd2 100644 --- a/src/package.json +++ b/src/package.json @@ -22,5 +22,8 @@ "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" + }, + "overrides": { + "semver": "^7.5.3" } } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4465b7098..52e214bb9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1830,10 +1830,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): form = DomainRequestAdminForm change_form_template = "django/admin/domain_request_change_form.html" + # ------ Filters ------ + # Define custom filters class StatusListFilter(MultipleChoiceListFilter): """Custom status filter which is a multiple choice filter""" - title = "Status" + title = "status" parameter_name = "status__in" template = "django/admin/multiple_choice_list_filter.html" @@ -1877,7 +1879,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): If we have a portfolio, use the portfolio's federal type. If not, use the organization in the Domain Request object.""" - title = "federal Type" + title = "federal type" parameter_name = "converted_federal_types" def lookups(self, request, model_admin): @@ -1965,13 +1967,58 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) + class PortfolioFilter(admin.SimpleListFilter): + """Define a custom filter for portfolio""" + + title = _("portfolio") + parameter_name = "portfolio__isnull" + + def lookups(self, request, model_admin): + return ( + ("1", _("Yes")), + ("0", _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == "1": + return queryset.filter(Q(portfolio__isnull=False)) + if self.value() == "0": + return queryset.filter(Q(portfolio__isnull=True)) + + # ------ Custom fields ------ + def custom_election_board(self, obj): + return "Yes" if obj.is_election_board else "No" + + custom_election_board.admin_order_field = "is_election_board" # type: ignore + custom_election_board.short_description = "Election office" # type: ignore + + @admin.display(description=_("Requested Domain")) + def custom_requested_domain(self, obj): + # Example: Show different icons based on `status` + url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}" + text = obj.requested_domain + if obj.portfolio: + return format_html(' {}', url, text) + return format_html('{}', url, text) + + custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore + + # ------ Converted fields ------ + # These fields map to @Property methods and + # require these custom definitions to work properly @admin.display(description=_("Generic Org Type")) def converted_generic_org_type(self, obj): return obj.converted_generic_org_type_display @admin.display(description=_("Organization Name")) def converted_organization_name(self, obj): - return obj.converted_organization_name + # Example: Show different icons based on `status` + if obj.portfolio: + url = reverse("admin:registrar_portfolio_change", args=[obj.portfolio.id]) + text = obj.converted_organization_name + return format_html('{}', url, text) + else: + return obj.converted_organization_name @admin.display(description=_("Federal Agency")) def converted_federal_agency(self, obj): @@ -1989,34 +2036,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def converted_state_territory(self, obj): return obj.converted_state_territory - # Columns - list_display = [ - "requested_domain", - "first_submitted_date", - "last_submitted_date", - "last_status_update", - "status", - "custom_election_board", - "converted_generic_org_type", - "converted_organization_name", - "converted_federal_agency", - "converted_federal_type", - "converted_city", - "converted_state_territory", - "investigator", - ] - - orderable_fk_fields = [ - ("requested_domain", "name"), - ("investigator", ["first_name", "last_name"]), - ] - - def custom_election_board(self, obj): - return "Yes" if obj.is_election_board else "No" - - custom_election_board.admin_order_field = "is_election_board" # type: ignore - custom_election_board.short_description = "Election office" # type: ignore - + # ------ Portfolio fields ------ # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None @@ -2086,10 +2106,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def status_history(self, obj): return "No changelog to display." - status_history.short_description = "Status History" # type: ignore + status_history.short_description = "Status history" # type: ignore + + # Columns + list_display = [ + "custom_requested_domain", + "first_submitted_date", + "last_submitted_date", + "last_status_update", + "status", + "custom_election_board", + "converted_generic_org_type", + "converted_organization_name", + "converted_federal_agency", + "converted_federal_type", + "converted_city", + "converted_state_territory", + "investigator", + ] + + orderable_fk_fields = [ + ("requested_domain", "name"), + ("investigator", ["first_name", "last_name"]), + ] # Filters list_filter = ( + PortfolioFilter, StatusListFilter, GenericOrgFilter, FederalTypeFilter, @@ -2099,13 +2142,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) # Search + # NOTE: converted fields are included in the override for get_search_results search_fields = [ "requested_domain__name", "creator__email", "creator__first_name", "creator__last_name", ] - search_help_text = "Search by domain or creator." + search_help_text = "Search by domain, creator, or organization name." fieldsets = [ ( @@ -2271,9 +2315,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", ] autocomplete_fields = [ @@ -2692,6 +2733,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): qs = qs.filter(portfolio=portfolio_id) return qs + def get_search_results(self, request, queryset, search_term): + # Call the parent's method to apply default search logic + base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + # Add custom search logic for the annotated field + if search_term: + annotated_queryset = queryset.filter( + # converted_organization_name + Q(portfolio__organization_name__icontains=search_term) + | Q(portfolio__isnull=True, organization_name__icontains=search_term) + ) + + # Combine the two querysets using union + combined_queryset = base_queryset | annotated_queryset + else: + combined_queryset = base_queryset + + return combined_queryset, use_distinct + class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" @@ -3746,9 +3806,9 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - analyst_readonly_fields = [ - "organization_name", - ] + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [] # type: ignore def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio diff --git a/src/registrar/assets/src/js/getgov/table-domains.js b/src/registrar/assets/src/js/getgov/table-domains.js index 20d9ef7de..a6373a5c2 100644 --- a/src/registrar/assets/src/js/getgov/table-domains.js +++ b/src/registrar/assets/src/js/getgov/table-domains.js @@ -31,6 +31,9 @@ export class DomainsTable extends BaseTable { ` } + const isExpiring = domain.state_display === "Expiring soon" + const iconType = isExpiring ? "error_outline" : "info_outline"; + const iconColor = isExpiring ? "text-secondary-vivid" : "text-accent-cool" row.innerHTML = ` ${domain.name} @@ -41,14 +44,14 @@ export class DomainsTable extends BaseTable { ${domain.state_display} - + ${markupForSuborganizationRow} @@ -77,3 +80,30 @@ export function initDomainsTable() { } }); } + +// For clicking the "Expiring" checkbox +document.addEventListener('DOMContentLoaded', () => { + const expiringLink = document.getElementById('link-expiring-domains'); + + if (expiringLink) { + // Grab the selection for the status filter by + const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); + + expiringLink.addEventListener('click', (event) => { + event.preventDefault(); + // Loop through all statuses + statusCheckboxes.forEach(checkbox => { + // To find the for checkbox for "Expiring soon" + if (checkbox.value === "expiring") { + // If the checkbox is not already checked, check it + if (!checkbox.checked) { + checkbox.checked = true; + // Do the checkbox action + let event = new Event('change'); + checkbox.dispatchEvent(event) + } + } + }); + }); + } +}); \ No newline at end of file diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 9f5d0162f..7230b04c6 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -69,9 +69,19 @@ def portfolio_permissions(request): "has_organization_requests_flag": False, "has_organization_members_flag": False, "is_portfolio_admin": False, + "has_domain_renewal_flag": False, } try: portfolio = request.session.get("portfolio") + + # These feature flags will display and doesn't depend on portfolio + portfolio_context.update( + { + "has_organization_feature_flag": True, + "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), + } + ) + # Linting: line too long view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio) edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio) @@ -90,6 +100,7 @@ def portfolio_permissions(request): "has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(), "is_portfolio_admin": request.user.is_portfolio_admin(portfolio), + "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), } return portfolio_context diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 19e96719f..6eb2fac07 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -2,7 +2,7 @@ from itertools import zip_longest import logging import ipaddress import re -from datetime import date +from datetime import date, timedelta from typing import Optional from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore @@ -40,6 +40,7 @@ from .utility.time_stamped_model import TimeStampedModel from .public_contact import PublicContact from .user_domain_role import UserDomainRole +from waffle.decorators import flag_is_active logger = logging.getLogger(__name__) @@ -1152,14 +1153,29 @@ class Domain(TimeStampedModel, DomainHelper): now = timezone.now().date() return self.expiration_date < now - def state_display(self): + def is_expiring(self): + """ + Check if the domain's expiration date is within 60 days. + Return True if domain expiration date exists and within 60 days + and otherwise False bc there's no expiration date meaning so not expiring + """ + if self.expiration_date is None: + return False + + now = timezone.now().date() + + threshold_date = now + timedelta(days=60) + return now < self.expiration_date <= threshold_date + + def state_display(self, request=None): """Return the display status of the domain.""" - if self.is_expired() and self.state != self.State.UNKNOWN: + if self.is_expired() and (self.state != self.State.UNKNOWN): return "Expired" + elif flag_is_active(request, "domain_renewal") and self.is_expiring(): + return "Expiring soon" elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED: return "DNS needed" - else: - return self.state.capitalize() + return self.state.capitalize() def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type): """Maps the Epp contact representation to a PublicContact object. diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index bdfc6f804..2b5b56a78 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -14,6 +14,8 @@ from .domain import Domain from .domain_request import DomainRequest from registrar.utility.waffle import flag_is_active_for_user from waffle.decorators import flag_is_active +from django.utils import timezone +from datetime import timedelta from phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -163,6 +165,20 @@ class User(AbstractUser): active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count() return active_requests_count + def get_num_expiring_domains(self, request): + """Return number of expiring domains""" + domain_ids = self.get_user_domain_ids(request) + now = timezone.now().date() + expiration_window = 60 + threshold_date = now + timedelta(days=expiration_window) + num_of_expiring_domains = Domain.objects.filter( + id__in=domain_ids, + expiration_date__isnull=False, + expiration_date__lte=threshold_date, + expiration_date__gt=now, + ).count() + return num_of_expiring_domains + def get_rejected_requests_count(self): """Return count of rejected requests""" return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count() @@ -259,6 +275,9 @@ class User(AbstractUser): def is_portfolio_admin(self, portfolio): return "Admin" in self.portfolio_role_summary(portfolio) + def has_domain_renewal_flag(self): + return flag_is_active_for_user(self, "domain_renewal") + def get_first_portfolio(self): permission = self.portfolio_permissions.first() if permission: diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index add7ca725..a5b8e52cb 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -35,18 +35,27 @@ Status: + {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %} Expired + {% elif has_domain_renewal_flag and domain.is_expiring %} + Expiring soon {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} DNS needed {% else %} - {{ domain.state|title }} + {{ domain.state|title }} {% endif %} {% if domain.get_state_help_text %}
- {{ domain.get_state_help_text }} + {% if has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} + This domain will expire soon. Renew to maintain access. + {% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} + This domain will expire soon. Contact one of the listed domain managers to renew the domain. + {% else %} + {{ domain.get_state_help_text }} + {% endif %}
{% endif %}

@@ -119,4 +128,4 @@ {% endif %} -{% endblock %} {# domain_content #} +{% endblock %} {# domain_content #} \ No newline at end of file diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 8a919e795..56cdc2cec 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -54,11 +54,14 @@ {% if portfolio %}
- + +
{% endif %} diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 42b4a186d..15becea7a 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -1,10 +1,30 @@ {% load static %} - {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get_domains_json' as url %} + + + + + +{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %} +
+
+
+

+ {% if num_expiring_domains == 1%} + One domain will expire soon. Go to "Manage" to renew the domain. Show expiring domain. + {% else%} + Multiple domains will expire soon. Go to "Manage" to renew the domains. Show expiring domains. + {% endif %} +

+
+
+
+{% endif %} +
{% if not portfolio %} @@ -53,7 +73,24 @@
{% endif %} - {% if portfolio %} + + + {% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %} +
+
+
+

+ {% if num_expiring_domains == 1%} + One domain will expire soon. Go to "Manage" to renew the domain. Show expiring domain. + {% else%} + Multiple domains will expire soon. Go to "Manage" to renew the domains. Show expiring domains. + {% endif %} +

+
+
+
+ {% endif %} +
Filter by
@@ -135,6 +172,19 @@ >Deleted
+ {% if has_domain_renewal_flag and num_expiring_domains > 0 %} +
+ + +
+ {% endif %}
@@ -149,7 +199,6 @@ - {% endif %}