diff --git a/.github/SECURITY.md b/.github/SECURITY.md index fc27feff3..e6fba722b 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,5 +1,5 @@ -* If you've found a security or privacy issue on the **.gov top-level domain infrastructure**, submit it to our [vulnerabilty disclosure form](https://forms.office.com/Pages/ResponsePage.aspx?id=bOfNPG2UEkq7evydCEI1SqHke9Gh6wJEl3kQ5EjWUKlUMTZZS1lBVkxHUzZURFpLTkE2NEJFVlhVRi4u) or email dotgov@cisa.dhs.gov. -* If you see a security or privacy issue on **an individual .gov domain**, check [current-full.csv](https://flatgithub.com/cisagov/dotgov-data/blob/main/?filename=current-full.csv) or [Whois](https://domains.dotgov.gov/dotgov-web/registration/whois.xhtml) (same data) to check whether the domain has a security contact to report your finding directly. You are welcome to Cc dotgov@cisa.dhs.gov on the email. - * If you are unable to find a contact or receive no response from the security contact, email dotgov@cisa.dhs.gov. +* If you've found a security or privacy issue on the **.gov top-level domain infrastructure**, submit it to our [vulnerabilty disclosure form](https://forms.office.com/Pages/ResponsePage.aspx?id=bOfNPG2UEkq7evydCEI1SqHke9Gh6wJEl3kQ5EjWUKlUMTZZS1lBVkxHUzZURFpLTkE2NEJFVlhVRi4u) or email help@get.gov. +* If you see a security or privacy issue on **an individual .gov domain**, check [current-full.csv](https://flatgithub.com/cisagov/dotgov-data/blob/main/?filename=current-full.csv) to see whether the domain has a security contact to report your finding directly. You are welcome to Cc help@get.gov on the email. + * If you are unable to find a contact or receive no response from the security contact, email help@get.gov. Note that most federal (executive branch) agencies maintain a [vulnerability disclosure policy](https://github.com/cisagov/vdp-in-fceb/). diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d91021225..364ae81f6 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -14,6 +14,8 @@ from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models.domain import Domain from registrar.models.user import User from registrar.utility import csv_export +from registrar.views.utility.mixins import OrderableFieldsMixin +from django.contrib.admin.views.main import ORDER_VAR from . import models from auditlog.models import LogEntry # type: ignore from auditlog.admin import LogEntryAdmin # type: ignore @@ -22,6 +24,73 @@ from django_fsm import TransitionNotAllowed # type: ignore logger = logging.getLogger(__name__) +# Based off of this excellent example: https://djangosnippets.org/snippets/10471/ +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 + + + Usage: + + class MyCustomAdmin(admin.ModelAdmin): + + ... + + def get_changelist(self, request, **kwargs): + return MultiFieldSortableChangeList + + ... + + """ + + def get_ordering(self, request, queryset): + """ + Returns the list of ordering fields for the change list. + + Mostly identical to the base implementation, except that now it can return + a list of order_field objects rather than just one. + """ + params = self.params + ordering = list(self.model_admin.get_ordering(request) or self._get_default_ordering()) + + if ORDER_VAR in params: + # Clear ordering and used params + ordering = [] + + order_params = params[ORDER_VAR].split(".") + for p in order_params: + try: + none, pfx, idx = p.rpartition("-") + field_name = self.list_display[int(idx)] + + order_fields = self.get_ordering_field(field_name) + + if isinstance(order_fields, list): + for order_field in order_fields: + if order_field: + ordering.append(pfx + order_field) + else: + ordering.append(pfx + order_fields) + + except (IndexError, ValueError): + continue # Invalid ordering specified, skip it. + + # Add the given query's ordering fields, if any. + ordering.extend(queryset.query.order_by) + + # Ensure that the primary key is systematically present in the list of + # ordering fields so we can guarantee a deterministic order across all + # database backends. + pk_name = self.lookup_opts.pk.name + if not (set(ordering) & set(["pk", "-pk", pk_name, "-" + pk_name])): + # The two sets do not intersect, meaning the pk isn't present. So + # we add it. + ordering.append("-pk") + + return ordering + + class CustomLogEntryAdmin(LogEntryAdmin): """Overwrite the generated LogEntry admin class""" @@ -119,8 +188,19 @@ class AuditedAdmin(admin.ModelAdmin): return super().formfield_for_foreignkey(db_field, request, **kwargs) -class ListHeaderAdmin(AuditedAdmin): - """Custom admin to add a descriptive subheader to list views.""" +class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin): + """Custom admin to add a descriptive subheader to list views + and custom table sort behaviour""" + + def get_changelist(self, request, **kwargs): + """Returns a custom ChangeList class, as opposed to the default. + This is so we can override the behaviour of the `admin_order_field` field. + By default, django does not support ordering by multiple fields for this + particular field (i.e. self.admin_order_field=["first_name", "last_name"] is invalid). + + Reference: https://code.djangoproject.com/ticket/31975 + """ + return MultiFieldSortableChangeList def changelist_view(self, request, extra_context=None): if extra_context is None: @@ -399,6 +479,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin): "role", ] + orderable_fk_fields = [ + ("domain", "name"), + ("user", ["first_name", "last_name", "email"]), + ] + # Search search_fields = [ "user__first_name", @@ -468,6 +553,11 @@ class DomainInformationAdmin(ListHeaderAdmin): "submitter", ] + orderable_fk_fields = [ + ("domain", "name"), + ("submitter", ["first_name", "last_name"]), + ] + # Filters list_filter = ["organization_type"] @@ -624,6 +714,12 @@ class DomainApplicationAdmin(ListHeaderAdmin): "investigator", ] + orderable_fk_fields = [ + ("requested_domain", "name"), + ("submitter", ["first_name", "last_name"]), + ("investigator", ["first_name", "last_name"]), + ] + # Filters list_filter = ("status", "organization_type", InvestigatorFilter) @@ -887,6 +983,10 @@ class DomainAdmin(ListHeaderAdmin): "name", "organization_type", "state", + "expiration_date", + "created_at", + "first_ready", + "deleted", ] # this ordering effects the ordering of results @@ -905,7 +1005,7 @@ class DomainAdmin(ListHeaderAdmin): search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" change_list_template = "django/admin/domain_change_list.html" - readonly_fields = ["state", "expiration_date"] + readonly_fields = ["state", "expiration_date", "first_ready", "deleted"] # Table ordering ordering = ["name"] @@ -1150,8 +1250,9 @@ admin.site.register(models.DomainInvitation, DomainInvitationAdmin) admin.site.register(models.DomainInformation, DomainInformationAdmin) admin.site.register(models.Domain, DomainAdmin) admin.site.register(models.DraftDomain, DraftDomainAdmin) -admin.site.register(models.Host, MyHostAdmin) -admin.site.register(models.Nameserver, MyHostAdmin) +# Host and HostIP removed from django admin because changes in admin +# do not propogate to registry and logic not applied +# admin.site.register(models.Host, MyHostAdmin) admin.site.register(models.Website, WebsiteAdmin) admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 53eeb22a3..cdbbc83ee 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -275,3 +275,39 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk)); viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); } + +/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button, + * attach the seleted start and end dates to a url that'll trigger the view, and finally + * redirect to that url. +*/ +(function (){ + + // Get the current date in the format YYYY-MM-DD + var currentDate = new Date().toISOString().split('T')[0]; + + // Default the value of the start date input field to the current date + let startDateInput =document.getElementById('start'); + startDateInput.value = currentDate; + + // Default the value of the end date input field to the current date + let endDateInput =document.getElementById('end'); + endDateInput.value = currentDate; + + let exportGrowthReportButton = document.getElementById('exportLink'); + + if (exportGrowthReportButton) { + exportGrowthReportButton.addEventListener('click', function() { + // Get the selected start and end dates + let startDate = startDateInput.value; + let endDate = endDateInput.value; + let exportUrl = document.getElementById('exportLink').dataset.exportUrl; + + // Build the URL with parameters + exportUrl += "?start_date=" + startDate + "&end_date=" + endDate; + + // Redirect to the export URL + window.location.href = exportUrl; + }); + } + +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 3afc81a35..a3d631243 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -136,10 +136,17 @@ html[data-theme="dark"] { } #branding h1, -h1, h2, h3 { +h1, h2, h3, +.module h2 { font-weight: font-weight('bold'); } +.module h3 { + padding: 0; + color: var(--primary); + margin: units(2) 0 units(1) 0; +} + .change-list { .usa-table--striped tbody tr:nth-child(odd) td, .usa-table--striped tbody tr:nth-child(odd) th, diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index c99daf72b..2de7e6eb2 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -210,7 +210,6 @@ STATICFILES_DIRS = [ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR / "registrar" / "templates"], # look for templates inside installed apps # required by django-debug-toolbar "APP_DIRS": True, @@ -520,7 +519,7 @@ LOGIN_REQUIRED_IGNORE_PATHS = [ ] # where to go after logging out -LOGOUT_REDIRECT_URL = "home" +LOGOUT_REDIRECT_URL = "https://get.gov/" # disable dynamic client registration, # only the OP inside OIDC_PROVIDERS will be available diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index d30c85ce9..607bf5f61 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -9,6 +9,10 @@ from django.urls import include, path from django.views.generic import RedirectView from registrar import views + +from registrar.views.admin_views import ExportData + + from registrar.views.application import Step from registrar.views.utility import always_404 from api.views import available, get_current_federal, get_current_full @@ -49,6 +53,7 @@ urlpatterns = [ "admin/logout/", RedirectView.as_view(pattern_name="logout", permanent=False), ), + path("export_data/", ExportData.as_view(), name="admin_export_data"), path("admin/", admin.site.urls), path( "application//edit/", diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index e0bc5fe52..fcf6bda7a 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -577,12 +577,44 @@ class OtherContactsForm(RegistrarForm): error_messages={"required": "Enter a phone number for this contact."}, ) + def clean(self): + """ + This method overrides the default behavior for forms. + This cleans the form after field validation has already taken place. + In this override, allow for a form which is empty to be considered + valid even though certain required fields have not passed field + validation + """ + + # Set form_is_empty to True initially + form_is_empty = True + for name, field in self.fields.items(): + # get the value of the field from the widget + value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name)) + # if any field in the submitted form is not empty, set form_is_empty to False + if value is not None and value != "": + form_is_empty = False + + if form_is_empty: + # clear any errors raised by the form fields + # (before this clean() method is run, each field + # performs its own clean, which could result in + # errors that we wish to ignore at this point) + # + # NOTE: we cannot just clear() the errors list. + # That causes problems. + for field in self.fields: + if field in self.errors: + del self.errors[field] + + return self.cleaned_data + class BaseOtherContactsFormSet(RegistrarFormSet): JOIN = "other_contacts" def should_delete(self, cleaned): - empty = (isinstance(v, str) and not v.strip() for v in cleaned.values()) + empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values()) return all(empty) def to_database(self, obj: DomainApplication): diff --git a/src/registrar/management/commands/extend_expiration_dates.py b/src/registrar/management/commands/extend_expiration_dates.py index 59b0274ab..cefc38b9e 100644 --- a/src/registrar/management/commands/extend_expiration_dates.py +++ b/src/registrar/management/commands/extend_expiration_dates.py @@ -30,7 +30,7 @@ class Command(BaseCommand): self.update_skipped = [] self.update_failed = [] self.expiration_minimum_cutoff = date(2023, 11, 1) - self.expiration_maximum_cutoff = date(2023, 12, 30) + self.expiration_maximum_cutoff = date(2024, 12, 30) def add_arguments(self, parser): """Add command line arguments.""" diff --git a/src/registrar/migrations/0058_alter_domaininformation_options.py b/src/registrar/migrations/0058_alter_domaininformation_options.py new file mode 100644 index 000000000..2e128cbda --- /dev/null +++ b/src/registrar/migrations/0058_alter_domaininformation_options.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.7 on 2023-12-27 22:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0057_domainapplication_submission_date"), + ] + + operations = [ + migrations.AlterModelOptions( + name="domaininformation", + options={"verbose_name_plural": "Domain information"}, + ), + ] diff --git a/src/registrar/migrations/0059_delete_nameserver.py b/src/registrar/migrations/0059_delete_nameserver.py new file mode 100644 index 000000000..404f8abf0 --- /dev/null +++ b/src/registrar/migrations/0059_delete_nameserver.py @@ -0,0 +1,15 @@ +# Generated by Django 4.2.7 on 2023-12-21 11:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0058_alter_domaininformation_options"), + ] + + operations = [ + migrations.DeleteModel( + name="Nameserver", + ), + ] diff --git a/src/registrar/migrations/0060_domain_deleted_domain_first_ready.py b/src/registrar/migrations/0060_domain_deleted_domain_first_ready.py new file mode 100644 index 000000000..e4caa1525 --- /dev/null +++ b/src/registrar/migrations/0060_domain_deleted_domain_first_ready.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2023-12-29 22:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0059_delete_nameserver"), + ] + + operations = [ + migrations.AddField( + model_name="domain", + name="deleted", + field=models.DateField(editable=False, help_text="Deleted at date", null=True), + ), + migrations.AddField( + model_name="domain", + name="first_ready", + field=models.DateField( + editable=False, help_text="The last time this domain moved into the READY state", null=True + ), + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 1203c7878..6afad5a5c 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -7,7 +7,6 @@ from .draft_domain import DraftDomain from .host_ip import HostIP from .host import Host from .domain_invitation import DomainInvitation -from .nameserver import Nameserver from .user_domain_role import UserDomainRole from .public_contact import PublicContact from .user import User @@ -24,7 +23,6 @@ __all__ = [ "DomainInvitation", "HostIP", "Host", - "Nameserver", "UserDomainRole", "PublicContact", "User", @@ -41,7 +39,6 @@ auditlog.register(DomainInvitation) auditlog.register(DomainInformation) auditlog.register(HostIP) auditlog.register(Host) -auditlog.register(Nameserver) auditlog.register(UserDomainRole) auditlog.register(PublicContact) auditlog.register(User, m2m_fields=["user_permissions", "groups"]) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7d2086499..1ae9b8029 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -10,7 +10,8 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor from django.db import models from django.utils import timezone from typing import Any - +from registrar.models.host import Host +from registrar.models.host_ip import HostIP from registrar.utility.errors import ( ActionNotAllowed, @@ -295,13 +296,15 @@ class Domain(TimeStampedModel, DomainHelper): while non-subordinate hosts MUST NOT. """ try: + # attempt to retrieve hosts from registry and store in cache and db hosts = self._get_property("hosts") - except Exception as err: - # Do not raise error when missing nameservers - # this is a standard occurence when a domain - # is first created - logger.info("Domain is missing nameservers %s" % err) - return [] + except Exception: + # If exception raised returning hosts from registry, get from db + hosts = [] + for hostobj in self.host.all(): + host_name = hostobj.name + ips = [ip.address for ip in hostobj.ip.all()] + hosts.append({"name": host_name, "addrs": ips}) # TODO-687 fix this return value hostList = [] @@ -730,8 +733,10 @@ class Domain(TimeStampedModel, DomainHelper): email=contact.email, voice=contact.voice, fax=contact.fax, + auth_info=epp.ContactAuthInfo(pw="2fooBAR123fooBaz"), ) # type: ignore + updateContact.disclose = self._disclose_fields(contact=contact) # type: ignore try: registry.send(updateContact, cleaned=True) except RegistryError as e: @@ -969,6 +974,18 @@ class Domain(TimeStampedModel, DomainHelper): help_text=("Duplication of registry's expiration date saved for ease of reporting"), ) + deleted = DateField( + null=True, + editable=False, + help_text="Deleted at date", + ) + + first_ready = DateField( + null=True, + editable=False, + help_text="The last time this domain moved into the READY state", + ) + def isActive(self): return self.state == Domain.State.CREATED @@ -1298,6 +1315,7 @@ class Domain(TimeStampedModel, DomainHelper): try: logger.info("deletedInEpp()-> inside _delete_domain") self._delete_domain() + self.deleted = timezone.now() except RegistryError as err: logger.error(f"Could not delete domain. Registry returned error: {err}") raise err @@ -1341,6 +1359,11 @@ class Domain(TimeStampedModel, DomainHelper): """ logger.info("Changing to ready state") logger.info("able to transition to ready state") + # if self.first_ready is not None, this means that this + # domain was READY, then not READY, then is READY again. + # We do not want to overwrite first_ready. + if self.first_ready is None: + self.first_ready = timezone.now() @transition( field="state", @@ -1605,6 +1628,8 @@ class Domain(TimeStampedModel, DomainHelper): cache = self._extract_data_from_response(data_response) cleaned = self._clean_cache(cache, data_response) self._update_hosts_and_contacts(cleaned, fetch_hosts, fetch_contacts) + if fetch_hosts: + self._update_hosts_and_ips_in_db(cleaned) self._update_dates(cleaned) self._cache = cleaned @@ -1651,7 +1676,11 @@ class Domain(TimeStampedModel, DomainHelper): return dnssec_data def _update_hosts_and_contacts(self, cleaned, fetch_hosts, fetch_contacts): - """Capture and store old hosts and contacts from cache if they don't exist""" + """ + Update hosts and contacts if fetch_hosts and/or fetch_contacts. + Additionally, capture and cache old hosts and contacts from cache if they + don't exist in cleaned + """ old_cache_hosts = self._cache.get("hosts") old_cache_contacts = self._cache.get("contacts") @@ -1666,6 +1695,50 @@ class Domain(TimeStampedModel, DomainHelper): if old_cache_contacts is not None: cleaned["contacts"] = old_cache_contacts + def _update_hosts_and_ips_in_db(self, cleaned): + """Update hosts and host_ips in database if retrieved from registry. + Only called when fetch_hosts is True. + + Parameters: + self: the domain to be updated with hosts and ips from cleaned + cleaned: dict containing hosts. Hosts are provided as a list of dicts, e.g. + [{"name": "ns1.example.com",}, {"name": "ns1.example.gov"}, "addrs": ["0.0.0.0"])] + """ + cleaned_hosts = cleaned["hosts"] + # Get all existing hosts from the database for this domain + existing_hosts_in_db = Host.objects.filter(domain=self) + # Identify hosts to delete + cleaned_host_names = set(cleaned_host["name"] for cleaned_host in cleaned_hosts) + hosts_to_delete_from_db = [ + existing_host for existing_host in existing_hosts_in_db if existing_host.name not in cleaned_host_names + ] + # Delete hosts and their associated HostIP instances + for host_to_delete in hosts_to_delete_from_db: + # Delete associated HostIP instances + HostIP.objects.filter(host=host_to_delete).delete() + # Delete the host itself + host_to_delete.delete() + # Update or create Hosts and HostIPs + for cleaned_host in cleaned_hosts: + # Check if the cleaned_host already exists + host_in_db, host_created = Host.objects.get_or_create(domain=self, name=cleaned_host["name"]) + # Get cleaned list of ips for update + cleaned_ips = cleaned_host["addrs"] + if not host_created: + # Get all existing ips from the database for this host + existing_ips_in_db = HostIP.objects.filter(host=host_in_db) + # Identify IPs to delete + ips_to_delete_from_db = [ + existing_ip for existing_ip in existing_ips_in_db if existing_ip.address not in cleaned_ips + ] + # Delete IPs + for ip_to_delete in ips_to_delete_from_db: + # Delete the ip + ip_to_delete.delete() + # Update or create HostIP instances + for ip_address in cleaned_ips: + HostIP.objects.get_or_create(address=ip_address, host=host_in_db) + def _update_dates(self, cleaned): """Update dates (expiration and creation) from cleaned""" requires_save = False diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 6f88c4ea0..a1ade7890 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -751,6 +751,7 @@ class DomainApplication(TimeStampedModel): # Only reject if it exists on EPP if domain_state != Domain.State.UNKNOWN: self.approved_domain.deletedInEpp() + self.approved_domain.save() self.approved_domain.delete() self.approved_domain = None except Exception as err: @@ -789,6 +790,7 @@ class DomainApplication(TimeStampedModel): # Only reject if it exists on EPP if domain_state != Domain.State.UNKNOWN: self.approved_domain.deletedInEpp() + self.approved_domain.save() self.approved_domain.delete() self.approved_domain = None except Exception as err: diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index e6c323128..bdff6061b 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -245,4 +245,4 @@ class DomainInformation(TimeStampedModel): return domain_info class Meta: - verbose_name_plural = "Domain Information" + verbose_name_plural = "Domain information" diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py index bab968afc..2d756111e 100644 --- a/src/registrar/models/host.py +++ b/src/registrar/models/host.py @@ -11,8 +11,8 @@ class Host(TimeStampedModel): The registry is the source of truth for this data. - This model exists ONLY to allow a new registrant to draft DNS entries - before their application is approved. + This model exists to make hosts/nameservers and ip addresses + available when registry is not available. """ name = models.CharField( diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py index 4d646898b..777d14430 100644 --- a/src/registrar/models/host_ip.py +++ b/src/registrar/models/host_ip.py @@ -10,8 +10,8 @@ class HostIP(TimeStampedModel): The registry is the source of truth for this data. - This model exists ONLY to allow a new registrant to draft DNS entries - before their application is approved. + This model exists to make hosts/nameservers and ip addresses + available when registry is not available. """ address = models.CharField( diff --git a/src/registrar/models/nameserver.py b/src/registrar/models/nameserver.py deleted file mode 100644 index 13295f5b5..000000000 --- a/src/registrar/models/nameserver.py +++ /dev/null @@ -1,16 +0,0 @@ -from .host import Host - - -class Nameserver(Host): - """ - A nameserver is a host which has been delegated to respond to DNS queries. - - The registry is the source of truth for this data. - - This model exists ONLY to allow a new registrant to draft DNS entries - before their application is approved. - """ - - # there is nothing here because all of the fields are - # defined over there on the Host class - pass diff --git a/src/registrar/templates/admin/index.html b/src/registrar/templates/admin/index.html new file mode 100644 index 000000000..04601ef32 --- /dev/null +++ b/src/registrar/templates/admin/index.html @@ -0,0 +1,33 @@ +{% extends "admin/index.html" %} + +{% block content %} +
+ {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %} +
+

Reports

+

Domain growth report

+ + {% comment %} + Inputs of type date suck for accessibility. + We'll need to replace those guys with a django form once we figure out how to hook one onto this page. + The challenge is in the path definition in urls. Itdoes NOT like admin/export_data/ + + See the commit "Review for ticket #999" + {% endcomment %} + +
+
+ + +
+
+ + +
+ + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/application_authorizing_official.html b/src/registrar/templates/application_authorizing_official.html index 9c364f612..3e33ab34e 100644 --- a/src/registrar/templates/application_authorizing_official.html +++ b/src/registrar/templates/application_authorizing_official.html @@ -6,7 +6,9 @@ Who is the authorizing official for your organization? -

Your authorizing official is a person within your organization who can authorize your domain request. This person must be in a role of significant, executive responsibility within the organization.

+ {% if not is_federal %} +

Your authorizing official is a person within your organization who can authorize your domain request. This person must be in a role of significant, executive responsibility within the organization.

+ {% endif %}
{% include "includes/ao_example.html" %} diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index dea14553b..53364d1b2 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -47,6 +47,15 @@ {% if value|length == 1 %} {% if users %}

{{ value.0.user.email }}

+ {% elif domains %} + {{ value.0.0 }} + {% if value.0.1 %} + ({% spaceless %} + {% for addr in value.0.1 %} + {{addr}}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% endspaceless %}) + {% endif %} {% else %}

{{ value | first }}

{% endif %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 80f55d584..f54691202 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -9,7 +9,7 @@ import uuid from django.test import TestCase from unittest.mock import MagicMock, Mock, patch from typing import List, Dict - +from django.contrib.sessions.middleware import SessionMiddleware from django.conf import settings from django.contrib.auth import get_user_model, login @@ -93,6 +93,73 @@ def less_console_noise(output_stream=None): output_stream.close() +class GenericTestHelper(TestCase): + """A helper class that contains various helper functions for TestCases""" + + def __init__(self, admin, model=None, url=None, user=None, factory=None, **kwargs): + """ + Parameters: + admin (ModelAdmin): The Django ModelAdmin instance associated with the model. + model (django.db.models.Model, optional): The Django model associated with the admin page. + url (str, optional): The URL of the Django Admin page to test. + user (User, optional): The Django User who is making the request. + factory (RequestFactory, optional): An instance of Django's RequestFactory. + """ + super().__init__() + self.factory = factory + self.user = user + self.admin = admin + self.model = model + self.url = url + + def assert_table_sorted(self, o_index, sort_fields): + """ + This helper function validates the sorting functionality of a Django Admin table view. + + It creates a mock HTTP GET request to the provided URL with a specific ordering parameter, + and compares the returned sorted queryset with the expected sorted queryset. + + Parameters: + o_index (str): The index of the field in the table to sort by. This is passed as a string + to the 'o' parameter in the GET request. + sort_fields (tuple): The fields of the model to sort by. These fields are used to generate + the expected sorted queryset. + + + Example Usage: + ``` + self.assert_sort_helper( + self.factory, self.superuser, self.admin, self.url, DomainInformation, "1", ("domain__name",) + ) + ``` + + The function asserts that the returned sorted queryset from the admin page matches the + expected sorted queryset. If the assertion fails, it means the sorting functionality + on the admin page is not working as expected. + """ + # 'o' is a search param defined by the current index position in the + # table, plus one. + dummy_request = self.factory.get( + self.url, + {"o": o_index}, + ) + dummy_request.user = self.user + + # Mock a user request + middleware = SessionMiddleware(lambda req: req) + middleware.process_request(dummy_request) + dummy_request.session.save() + + expected_sort_order = list(self.model.objects.order_by(*sort_fields)) + + # Use changelist_view to get the sorted queryset + response = self.admin.changelist_view(dummy_request) + response.render() # Render the response before accessing its content + returned_sort_order = list(response.context_data["cl"].result_list) + + self.assertEqual(expected_sort_order, returned_sort_order) + + class MockUserLogin: def __init__(self, get_response): self.get_response = get_response @@ -273,6 +340,7 @@ class AuditedAdminMockData: creator: User = self.dummy_user(item_name, "creator"), } """ # noqa + creator = self.dummy_user(item_name, "creator") common_args = dict( organization_type=org_type, federal_type=federal_type, @@ -287,7 +355,7 @@ class AuditedAdminMockData: anything_else="There is more", authorizing_official=self.dummy_contact(item_name, "authorizing_official"), submitter=self.dummy_contact(item_name, "submitter"), - creator=self.dummy_user(item_name, "creator"), + creator=creator, ) return common_args @@ -1094,6 +1162,8 @@ class MockEppLib(TestCase): email=contact.email, voice=contact.voice, fax=contact.fax, + disclose=di, + auth_info=ai, ) def tearDown(self): diff --git a/src/registrar/tests/data/fake_current_federal.csv b/src/registrar/tests/data/fake_current_federal.csv index 33f679e9e..df4fe871f 100644 --- a/src/registrar/tests/data/fake_current_federal.csv +++ b/src/registrar/tests/data/fake_current_federal.csv @@ -1,3 +1,3 @@ -Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email +Domain name,Domain type,Agency,Organization name,City,State,Security contact email cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \ No newline at end of file diff --git a/src/registrar/tests/data/fake_current_full.csv b/src/registrar/tests/data/fake_current_full.csv index 43eefc271..9fef96c60 100644 --- a/src/registrar/tests/data/fake_current_full.csv +++ b/src/registrar/tests/data/fake_current_full.csv @@ -1,4 +1,4 @@ -Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email +Domain name,Domain type,Agency,Organization name,City,State,Security contact email cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, ddomain3.gov,Federal,Armed Forces Retirement Home,,,, adomain2.gov,Interstate,,,,, \ No newline at end of file diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 3b3d38a55..09758ae5b 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3,7 +3,6 @@ from django.contrib.admin.sites import AdminSite from contextlib import ExitStack from django.contrib import messages from django.urls import reverse - from registrar.admin import ( DomainAdmin, DomainApplicationAdmin, @@ -13,12 +12,14 @@ from registrar.admin import ( MyUserAdmin, AuditedAdmin, ContactAdmin, + DomainInformationAdmin, UserDomainRoleAdmin, ) from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website from registrar.models.user_domain_role import UserDomainRole from .common import ( MockSESClient, + AuditedAdminMockData, completed_application, generic_domain_object, less_console_noise, @@ -28,6 +29,7 @@ from .common import ( create_ready_domain, multiple_unalphabetical_domain_objects, MockEppLib, + GenericTestHelper, ) from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model @@ -319,6 +321,85 @@ class TestDomainApplicationAdmin(MockEppLib): self.superuser = create_superuser() self.staffuser = create_user() self.client = Client(HTTP_HOST="localhost:8080") + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/DomainApplication/", + model=DomainApplication, + ) + + def test_domain_sortable(self): + """Tests if the DomainApplication sorts by domain correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + multiple_unalphabetical_domain_objects("application") + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted("1", ("requested_domain__name",)) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",)) + + def test_submitter_sortable(self): + """Tests if the DomainApplication sorts by domain correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + multiple_unalphabetical_domain_objects("application") + + additional_application = generic_domain_object("application", "Xylophone") + new_user = User.objects.filter(username=additional_application.investigator.username).get() + new_user.first_name = "Xylophonic" + new_user.save() + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted( + "5", + ( + "submitter__first_name", + "submitter__last_name", + ), + ) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted( + "-5", + ( + "-submitter__first_name", + "-submitter__last_name", + ), + ) + + def test_investigator_sortable(self): + """Tests if the DomainApplication sorts by domain correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + multiple_unalphabetical_domain_objects("application") + additional_application = generic_domain_object("application", "Xylophone") + new_user = User.objects.filter(username=additional_application.investigator.username).get() + new_user.first_name = "Xylophonic" + new_user.save() + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted( + "6", + ( + "investigator__first_name", + "investigator__last_name", + ), + ) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted( + "-6", + ( + "-investigator__first_name", + "-investigator__last_name", + ), + ) def test_short_org_name_in_applications_list(self): """ @@ -947,52 +1028,6 @@ class TestDomainApplicationAdmin(MockEppLib): ], ) - def test_investigator_filter_filters_correctly(self): - """ - This test verifies that the investigator filter in the admin interface for - the DomainApplication model works correctly. - - It creates two DomainApplication instances, each with a different investigator. - It then simulates a staff user logging in and applying the investigator filter - on the DomainApplication admin page. - - It then verifies that it was applied correctly. - The test checks that the response contains the expected DomainApplication pbjects - in the table. - """ - - # Create a mock DomainApplication object, with a fake investigator - application: DomainApplication = generic_domain_object("application", "SomeGuy") - investigator_user = User.objects.filter(username=application.investigator.username).get() - investigator_user.is_staff = True - investigator_user.save() - - # Create a second mock DomainApplication object, to test filtering - application: DomainApplication = generic_domain_object("application", "BadGuy") - another_user = User.objects.filter(username=application.investigator.username).get() - another_user.is_staff = True - another_user.save() - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domainapplication/", - { - "investigator__id__exact": investigator_user.id, - }, - follow=True, - ) - - expected_name = "SomeGuy first_name:investigator SomeGuy last_name:investigator" - # We expect to see this four times, two of them are from the html for the filter, - # and the other two are the html from the list entry in the table. - self.assertContains(response, expected_name, count=4) - - # Check that we don't also get the thing we aren't filtering for. - # We expect to see this two times in the filter - unexpected_name = "BadGuy first_name:investigator BadGuy last_name:investigator" - self.assertContains(response, unexpected_name, count=2) - def test_investigator_dropdown_displays_only_staff(self): """ This test verifies that the dropdown for the 'investigator' field in the DomainApplicationAdmin @@ -1117,14 +1152,98 @@ class DomainInvitationAdminTest(TestCase): self.assertContains(response, retrieved_html, count=1) +class DomainInformationAdminTest(TestCase): + def setUp(self): + """Setup environment for a mock admin user""" + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = DomainInformationAdmin(model=DomainInformation, admin_site=self.site) + self.client = Client(HTTP_HOST="localhost:8080") + self.superuser = create_superuser() + self.mock_data_generator = AuditedAdminMockData() + + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/DomainInformation/", + model=DomainInformation, + ) + + # Create fake DomainInformation objects + DomainInformation.objects.create( + creator=self.mock_data_generator.dummy_user("fake", "creator"), + domain=self.mock_data_generator.dummy_domain("Apple"), + submitter=self.mock_data_generator.dummy_contact("Zebra", "submitter"), + ) + + DomainInformation.objects.create( + creator=self.mock_data_generator.dummy_user("fake", "creator"), + domain=self.mock_data_generator.dummy_domain("Zebra"), + submitter=self.mock_data_generator.dummy_contact("Apple", "submitter"), + ) + + DomainInformation.objects.create( + creator=self.mock_data_generator.dummy_user("fake", "creator"), + domain=self.mock_data_generator.dummy_domain("Circus"), + submitter=self.mock_data_generator.dummy_contact("Xylophone", "submitter"), + ) + + DomainInformation.objects.create( + creator=self.mock_data_generator.dummy_user("fake", "creator"), + domain=self.mock_data_generator.dummy_domain("Xylophone"), + submitter=self.mock_data_generator.dummy_contact("Circus", "submitter"), + ) + + def tearDown(self): + """Delete all Users, Domains, and UserDomainRoles""" + DomainInformation.objects.all().delete() + DomainApplication.objects.all().delete() + Domain.objects.all().delete() + Contact.objects.all().delete() + User.objects.all().delete() + + def test_domain_sortable(self): + """Tests if DomainInformation sorts by domain correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted("1", ("domain__name",)) + + # 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""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted( + "4", + ("submitter__first_name", "submitter__last_name"), + ) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted("-4", ("-submitter__first_name", "-submitter__last_name")) + + class UserDomainRoleAdminTest(TestCase): def setUp(self): """Setup environment for a mock admin user""" self.site = AdminSite() self.factory = RequestFactory() - self.admin = ListHeaderAdmin(model=UserDomainRoleAdmin, admin_site=None) + self.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=self.site) self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/UserDomainRole/", + model=UserDomainRole, + ) def tearDown(self): """Delete all Users, Domains, and UserDomainRoles""" @@ -1132,6 +1251,48 @@ class UserDomainRoleAdminTest(TestCase): Domain.objects.all().delete() UserDomainRole.objects.all().delete() + def test_domain_sortable(self): + """Tests if the UserDomainrole sorts by domain correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + fake_user = User.objects.create( + username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com" + ) + + # Create a list of UserDomainRoles that are in random order + mocks_to_create = ["jkl.gov", "ghi.gov", "abc.gov", "def.gov"] + for name in mocks_to_create: + fake_domain = Domain.objects.create(name=name) + UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager") + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted("2", ("domain__name",)) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted("-2", ("-domain__name",)) + + def test_user_sortable(self): + """Tests if the UserDomainrole sorts by user correctly""" + p = "adminpass" + self.client.login(username="superuser", password=p) + + mock_data_generator = AuditedAdminMockData() + + fake_domain = Domain.objects.create(name="igorville.gov") + # Create a list of UserDomainRoles that are in random order + mocks_to_create = ["jkl", "ghi", "abc", "def"] + for name in mocks_to_create: + # Creates a fake "User" object + fake_user = mock_data_generator.dummy_user(name, "user") + UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager") + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted("1", ("user__first_name", "user__last_name")) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted("-1", ("-user__first_name", "-user__last_name")) + def test_email_not_in_search(self): """Tests the search bar in Django Admin for UserDomainRoleAdmin. Should return no results for an invalid email.""" diff --git a/src/registrar/tests/test_admin_views.py b/src/registrar/tests/test_admin_views.py new file mode 100644 index 000000000..aa150d55c --- /dev/null +++ b/src/registrar/tests/test_admin_views.py @@ -0,0 +1,42 @@ +from django.test import TestCase, Client +from django.urls import reverse +from registrar.tests.common import create_superuser + + +class TestViews(TestCase): + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + self.superuser = create_superuser() + + def test_export_data_view(self): + self.client.force_login(self.superuser) + + # Reverse the URL for the admin index page + admin_index_url = reverse("admin:index") + + # Make a GET request to the admin index page + response = self.client.get(admin_index_url) + + # Assert that the response status code is 200 (OK) + self.assertEqual(response.status_code, 200) + + # Ensure that the start_date and end_date are set + start_date = "2023-01-01" + end_date = "2023-12-31" + + # Construct the URL for the export data view with start_date and end_date parameters: + # This stuff is currently done in JS + export_data_url = reverse("admin_export_data") + f"?start_date={start_date}&end_date={end_date}" + + # Make a GET request to the export data page + response = self.client.get(export_data_url) + + # Assert that the response status code is 200 (OK) or the expected status code + self.assertEqual(response.status_code, 200) + + # Assert that the content type is CSV + self.assertEqual(response["Content-Type"], "text/csv") + + # Check if the filename in the Content-Disposition header matches the expected pattern + expected_filename = f"domain-growth-report-{start_date}-to-{end_date}.csv" + self.assertIn(f'attachment; filename="{expected_filename}"', response["Content-Disposition"]) diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index d54e81427..e0afb2d71 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -211,7 +211,7 @@ class TestFormValidation(MockEppLib): def test_other_contact_email_invalid(self): """must be a valid email address.""" - form = OtherContactsForm(data={"email": "boss@boss"}) + form = OtherContactsForm(data={"email": "splendid@boss"}) self.assertEqual( form.errors["email"], ["Enter an email address in the required format, like name@example.com."], @@ -219,7 +219,7 @@ class TestFormValidation(MockEppLib): def test_other_contact_phone_invalid(self): """Must be a valid phone number.""" - form = OtherContactsForm(data={"phone": "boss@boss"}) + form = OtherContactsForm(data={"phone": "super@boss"}) self.assertTrue(form.errors["phone"][0].startswith("Enter a valid phone number ")) def test_requirements_form_blank(self): diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 069359625..d06248b2e 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -20,6 +20,7 @@ from .common import MockSESClient, less_console_noise, completed_application from django_fsm import TransitionNotAllowed +# Test comment for push -- will remove # The DomainApplication submit method has a side effect of sending an email # with AWS SES, so mock that out in all of these test cases @boto3_mocking.patching diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 461d87661..9db7257b3 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -7,7 +7,7 @@ from django.test import TestCase from django.db.utils import IntegrityError from unittest.mock import MagicMock, patch, call import datetime -from registrar.models import Domain +from registrar.models import Domain, Host, HostIP from unittest import skip from registrar.models.domain_application import DomainApplication @@ -39,6 +39,8 @@ logger = logging.getLogger(__name__) class TestDomainCache(MockEppLib): def tearDown(self): PublicContact.objects.all().delete() + HostIP.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -390,6 +392,34 @@ class TestDomainStatuses(MockEppLib): """Domain 'revert_client_hold' method causes the registry to change statuses""" raise + def test_first_ready(self): + """ + first_ready is set when a domain is first transitioned to READY. It does not get overwritten + in case the domain gets out of and back into READY. + """ + domain, _ = Domain.objects.get_or_create(name="pig-knuckles.gov", state=Domain.State.DNS_NEEDED) + self.assertEqual(domain.first_ready, None) + + domain.ready() + + # check that status is READY + self.assertTrue(domain.is_active()) + self.assertNotEqual(domain.first_ready, None) + + # Capture the value of first_ready + first_ready = domain.first_ready + + # change domain status + domain.dns_needed() + self.assertFalse(domain.is_active()) + + # change back to READY + domain.ready() + self.assertTrue(domain.is_active()) + + # assert that the value of first_ready has not changed + self.assertEqual(domain.first_ready, first_ready) + def tearDown(self) -> None: PublicContact.objects.all().delete() Domain.objects.all().delete() @@ -1116,6 +1146,7 @@ class TestRegistrantNameservers(MockEppLib): Then `commands.CreateHost` and `commands.UpdateDomain` is sent to the registry And `domain.is_active` returns False + And domain.first_ready is null """ # set 1 nameserver @@ -1142,6 +1173,8 @@ class TestRegistrantNameservers(MockEppLib): # as you have less than 2 nameservers self.assertFalse(self.domain.is_active()) + self.assertEqual(self.domain.first_ready, None) + def test_user_adds_two_nameservers(self): """ Scenario: Registrant adds 2 or more nameservers, thereby activating the domain @@ -1150,6 +1183,7 @@ class TestRegistrantNameservers(MockEppLib): Then `commands.CreateHost` and `commands.UpdateDomain` is sent to the registry And `domain.is_active` returns True + And domain.first_ready is not null """ # set 2 nameservers @@ -1180,6 +1214,7 @@ class TestRegistrantNameservers(MockEppLib): self.assertEqual(4, self.mockedSendFunction.call_count) # check that status is READY self.assertTrue(self.domain.is_active()) + self.assertNotEqual(self.domain.first_ready, None) def test_user_adds_too_many_nameservers(self): """ @@ -1516,6 +1551,62 @@ class TestRegistrantNameservers(MockEppLib): with self.assertRaises(ActionNotAllowed): domain.nameservers = [self.nameserver1, self.nameserver2] + def test_nameserver_returns_on_registry_error(self): + """ + Scenario: Nameservers previously set through EPP and stored in registrar's database. + Registry is unavailable and throws exception when attempting to build cache from + registry. Nameservers retrieved from database. + """ + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + # set the host and host_ips directly in the database; this is normally handled through + # fetch_cache + host, _ = Host.objects.get_or_create(domain=domain, name="ns1.fake.gov") + host_ip, _ = HostIP.objects.get_or_create(host=host, address="1.1.1.1") + + # mock that registry throws an error on the InfoHost send + + def side_effect(_request, cleaned): + raise RegistryError(code=ErrorCode.COMMAND_FAILED) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + nameservers = domain.nameservers + + self.assertEqual(len(nameservers), 1) + self.assertEqual(nameservers[0][0], "ns1.fake.gov") + self.assertEqual(nameservers[0][1], ["1.1.1.1"]) + + patcher.stop() + + def test_nameservers_stored_on_fetch_cache(self): + """ + Scenario: Nameservers are stored in db when they are retrieved from fetch_cache. + Verify the success of this by asserting get_or_create calls to db. + The mocked data for the EPP calls returns a host name + of 'fake.host.com' from InfoDomain and an array of 2 IPs: 1.2.3.4 and 2.3.4.5 + from InfoHost + """ + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + # mock the get_or_create methods for Host and HostIP + with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object( + HostIP.objects, "get_or_create" + ) as mock_host_ip_get_or_create: + # Set the return value for the mocks + mock_host_get_or_create.return_value = (Host(), True) + mock_host_ip_get_or_create.return_value = (HostIP(), True) + + # force fetch_cache to be called, which will return above documented mocked hosts + domain.nameservers + # assert that the mocks are called + mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com") + # Retrieve the mocked_host from the return value of the mock + actual_mocked_host, _ = mock_host_get_or_create.return_value + mock_host_ip_get_or_create.assert_called_with(address="2.3.4.5", host=actual_mocked_host) + self.assertEqual(mock_host_ip_get_or_create.call_count, 2) + @skip("not implemented yet") def test_update_is_unsuccessful(self): """ @@ -1534,6 +1625,8 @@ class TestRegistrantNameservers(MockEppLib): domain.nameservers = [("ns1.failednameserver.gov", ["4.5.6"])] def tearDown(self): + HostIP.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() return super().tearDown() @@ -2302,11 +2395,14 @@ class TestAnalystDelete(MockEppLib): When `domain.deletedInEpp()` is called Then `commands.DeleteDomain` is sent to the registry And `state` is set to `DELETED` + + The deleted date is set. """ # Put the domain in client hold self.domain.place_client_hold() # Delete it... self.domain.deletedInEpp() + self.domain.save() self.mockedSendFunction.assert_has_calls( [ call( @@ -2322,6 +2418,9 @@ class TestAnalystDelete(MockEppLib): # Domain should have the right state self.assertEqual(self.domain.state, Domain.State.DELETED) + # Domain should have a deleted + self.assertNotEqual(self.domain.deleted, None) + # Cache should be invalidated self.assertEqual(self.domain._cache, {}) @@ -2340,6 +2439,7 @@ class TestAnalystDelete(MockEppLib): # Delete it with self.assertRaises(RegistryError) as err: domain.deletedInEpp() + domain.save() self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION) self.mockedSendFunction.assert_has_calls( [ @@ -2363,12 +2463,18 @@ class TestAnalystDelete(MockEppLib): and domain is of `state` is `READY` Then an FSM error is returned And `state` is not set to `DELETED` + + The deleted date is still null. """ self.assertEqual(self.domain.state, Domain.State.READY) with self.assertRaises(TransitionNotAllowed) as err: self.domain.deletedInEpp() + self.domain.save() self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION) # Domain should not be deleted self.assertNotEqual(self.domain, None) # Domain should have the right state self.assertEqual(self.domain.state, Domain.State.READY) + + # deleted should be null + self.assertEqual(self.domain.deleted, None) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 4b854a0a0..85e24ce33 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -6,7 +6,12 @@ from registrar.models.domain_information import DomainInformation from registrar.models.domain import Domain from registrar.models.user import User from django.contrib.auth import get_user_model -from registrar.utility.csv_export import export_domains_to_writer +from registrar.utility.csv_export import ( + write_header, + write_body, + get_default_start_date, + get_default_end_date, +) from django.core.management import call_command from unittest.mock import MagicMock, call, mock_open, patch from api.views import get_current_federal, get_current_full @@ -14,6 +19,8 @@ from django.conf import settings from botocore.exceptions import ClientError import boto3_mocking from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore +from datetime import date, datetime, timedelta +from django.utils import timezone class CsvReportsTest(TestCase): @@ -35,7 +42,6 @@ class CsvReportsTest(TestCase): self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) - self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) self.domain_information_1, _ = DomainInformation.objects.get_or_create( creator=self.user, @@ -75,7 +81,7 @@ class CsvReportsTest(TestCase): mock_client = MagicMock() fake_open = mock_open() expected_file_content = [ - call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"), + call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), ] @@ -94,7 +100,7 @@ class CsvReportsTest(TestCase): mock_client = MagicMock() fake_open = mock_open() expected_file_content = [ - call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"), + call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("adomain2.gov,Interstate,,,,, \r\n"), @@ -174,7 +180,7 @@ class CsvReportsTest(TestCase): # Check that the response contains what we expect expected_file_content = ( - "Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n" + "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,," ).encode() @@ -206,7 +212,7 @@ class CsvReportsTest(TestCase): # Check that the response contains what we expect expected_file_content = ( - "Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n" + "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\n" "adomain2.gov,Interstate,,,,," @@ -225,11 +231,39 @@ class ExportDataTest(TestCase): username=username, first_name=first_name, last_name=last_name, email=email ) - self.domain_1, _ = Domain.objects.get_or_create(name="cdomain1.gov", state=Domain.State.READY) + self.domain_1, _ = Domain.objects.get_or_create( + name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now() + ) self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) + self.domain_5, _ = Domain.objects.get_or_create( + name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1)) + ) + self.domain_6, _ = Domain.objects.get_or_create( + name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16)) + ) + self.domain_7, _ = Domain.objects.get_or_create( + name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now() + ) + self.domain_8, _ = Domain.objects.get_or_create( + name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now() + ) + # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today()) + # and a specific time (using datetime.min.time()). + # Deleted yesterday + self.domain_9, _ = Domain.objects.get_or_create( + name="zdomain9.gov", + state=Domain.State.DELETED, + deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())), + ) + # ready tomorrow + self.domain_10, _ = Domain.objects.get_or_create( + name="adomain10.gov", + state=Domain.State.READY, + first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())), + ) self.domain_information_1, _ = DomainInformation.objects.get_or_create( creator=self.user, @@ -255,6 +289,42 @@ class ExportDataTest(TestCase): organization_type="federal", federal_agency="Armed Forces Retirement Home", ) + self.domain_information_5, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_5, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_6, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_6, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_7, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_7, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_8, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_8, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_9, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_9, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) + self.domain_information_10, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_10, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + ) def tearDown(self): Domain.objects.all().delete() @@ -262,8 +332,8 @@ class ExportDataTest(TestCase): User.objects.all().delete() super().tearDown() - def test_export_domains_to_writer(self): - """Test that export_domains_to_writer returns the + def test_write_body(self): + """Test that write_body returns the existing domain, test that sort by domain name works, test that filter works""" # Create a CSV file in memory @@ -284,7 +354,7 @@ class ExportDataTest(TestCase): "Submitter title", "Submitter email", "Submitter phone", - "Security Contact Email", + "Security contact email", "Status", ] sort_fields = ["domain__name"] @@ -296,8 +366,9 @@ class ExportDataTest(TestCase): ], } - # Call the export function - export_domains_to_writer(writer, columns, sort_fields, filter_condition) + # Call the export functions + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -310,10 +381,11 @@ class ExportDataTest(TestCase): expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,AO," "AO email,Submitter,Submitter title,Submitter email,Submitter phone," - "Security Contact Email,Status\n" - "adomain2.gov,Interstate,dnsneeded\n" - "cdomain1.gov,Federal - Executive,World War I Centennial Commission,ready\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,onhold\n" + "Security contact email,Status\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n" + "adomain2.gov,Interstate,Dns needed\n" + "cdomain1.gov,Federal - Executive,World War I Centennial Commission,Ready\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,On hold\n" ) # Normalize line endings and remove commas, @@ -323,7 +395,7 @@ class ExportDataTest(TestCase): self.assertEqual(csv_content, expected_content) - def test_export_domains_to_writer_additional(self): + def test_write_body_additional(self): """An additional test for filters and multi-column sort""" # Create a CSV file in memory csv_file = StringIO() @@ -337,7 +409,7 @@ class ExportDataTest(TestCase): "Organization name", "City", "State", - "Security Contact Email", + "Security contact email", ] sort_fields = ["domain__name", "federal_agency", "organization_type"] filter_condition = { @@ -349,8 +421,9 @@ class ExportDataTest(TestCase): ], } - # Call the export function - export_domains_to_writer(writer, columns, sort_fields, filter_condition) + # Call the export functions + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -363,7 +436,8 @@ class ExportDataTest(TestCase): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City," - "State,Security Contact Email\n" + "State,Security contact email\n" + "adomain10.gov,Federal,Armed Forces Retirement Home\n" "cdomain1.gov,Federal - Executive,World War I Centennial Commission\n" "ddomain3.gov,Federal,Armed Forces Retirement Home\n" ) @@ -374,3 +448,113 @@ class ExportDataTest(TestCase): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) + + def test_write_body_with_date_filter_pulls_domains_in_range(self): + """Test that domains that are + 1. READY and their first_ready dates are in range + 2. DELETED and their deleted dates are in range + are pulled when the growth report conditions are applied to export_domains_to_writed. + Test that ready domains are sorted by first_ready/deleted dates first, names second. + + We considered testing export_data_growth_to_csv which calls write_body + and would have been easy to set up, but expected_content would contain created_at dates + which are hard to mock. + + TODO: Simplify is created_at is not needed for the report.""" + + # Create a CSV file in memory + csv_file = StringIO() + writer = csv.writer(csv_file) + # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today()) + # and a specific time (using datetime.min.time()). + end_date = timezone.make_aware(datetime.combine(date.today() + timedelta(days=2), datetime.min.time())) + start_date = timezone.make_aware(datetime.combine(date.today() - timedelta(days=2), datetime.min.time())) + + # Define columns, sort fields, and filter condition + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Status", + "Expiration date", + ] + sort_fields = [ + "created_at", + "domain__name", + ] + sort_fields_for_deleted_domains = [ + "domain__deleted", + "domain__name", + ] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + ], + "domain__first_ready__lte": end_date, + "domain__first_ready__gte": start_date, + } + filter_conditions_for_deleted_domains = { + "domain__state__in": [ + Domain.State.DELETED, + ], + "domain__deleted__lte": end_date, + "domain__deleted__gte": start_date, + } + + # Call the export functions + write_header(writer, columns) + write_body( + writer, + columns, + sort_fields, + filter_condition, + ) + write_body( + writer, + columns, + sort_fields_for_deleted_domains, + filter_conditions_for_deleted_domains, + ) + + # Reset the CSV file's position to the beginning + csv_file.seek(0) + + # Read the content into a variable + csv_content = csv_file.read() + + # We expect READY domains first, created between today-2 and today+2, sorted by created_at then name + # and DELETED domains deleted between today-2 and today+2, sorted by deleted then name + expected_content = ( + "Domain name,Domain type,Agency,Organization name,City," + "State,Status,Expiration date\n" + "cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,\n" + "zdomain9.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n" + "sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n" + "xdomain7.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n" + ) + + # Normalize line endings and remove commas, + # spaces and leading/trailing whitespace + csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() + expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + + self.assertEqual(csv_content, expected_content) + + +class HelperFunctions(TestCase): + """This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy.""" + + def test_get_default_start_date(self): + expected_date = timezone.make_aware(datetime(2023, 11, 1)) + actual_date = get_default_start_date() + self.assertEqual(actual_date, expected_date) + + def test_get_default_end_date(self): + # Note: You may need to mock timezone.now() for accurate testing + expected_date = timezone.now() + actual_date = get_default_end_date() + self.assertEqual(actual_date.date(), expected_date.date()) diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index bc661d119..abd58b190 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -28,6 +28,8 @@ from registrar.models import ( DomainInvitation, Contact, PublicContact, + Host, + HostIP, Website, UserDomainRole, User, @@ -725,6 +727,92 @@ class DomainApplicationTests(TestWithUser, WebTest): actual_url_slug = no_contacts_page.request.path.split("/")[-2] self.assertEqual(expected_url_slug, actual_url_slug) + def test_application_delete_other_contact(self): + """Other contacts can be deleted after being saved to database.""" + # Populate the databse with a domain application that + # has 1 "other contact" assigned to it + ao, _ = Contact.objects.get_or_create( + first_name="Testy", + last_name="Tester", + title="Chief Tester", + email="testy@town.com", + phone="(555) 555 5555", + ) + you, _ = Contact.objects.get_or_create( + first_name="Testy you", + last_name="Tester you", + title="Admin Tester", + email="testy-admin@town.com", + phone="(555) 555 5556", + ) + other, _ = Contact.objects.get_or_create( + first_name="Testy2", + last_name="Tester2", + title="Another Tester", + email="testy2@town.com", + phone="(555) 555 5557", + ) + application, _ = DomainApplication.objects.get_or_create( + organization_type="federal", + federal_type="executive", + purpose="Purpose of the site", + anything_else="No", + is_policy_acknowledged=True, + organization_name="Testorg", + address_line1="address 1", + state_territory="NY", + zipcode="10002", + authorizing_official=ao, + submitter=you, + creator=self.user, + status="started", + ) + application.other_contacts.add(other) + + # prime the form by visiting /edit + self.app.get(reverse("edit-application", kwargs={"id": application.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + other_contacts_page = self.app.get(reverse("application:other_contacts")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + other_contacts_form = other_contacts_page.forms[0] + + # Minimal check to ensure the form is loaded with data (if this part of + # the application doesn't work, we should be equipped with other unit + # tests to flag it) + self.assertEqual(other_contacts_form["other_contacts-0-first_name"].value, "Testy2") + + # clear the form + other_contacts_form["other_contacts-0-first_name"] = "" + other_contacts_form["other_contacts-0-middle_name"] = "" + other_contacts_form["other_contacts-0-last_name"] = "" + other_contacts_form["other_contacts-0-title"] = "" + other_contacts_form["other_contacts-0-email"] = "" + other_contacts_form["other_contacts-0-phone"] = "" + + # Submit the now empty form + result = other_contacts_form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Verify that the contact we saved earlier has been removed from the database + application = DomainApplication.objects.get() # There are no contacts anymore + self.assertEqual( + application.other_contacts.count(), + 0, + ) + + # Verify that on submit, user is advanced to "no contacts" page + no_contacts_page = result.follow() + expected_url_slug = str(Step.NO_OTHER_CONTACTS) + actual_url_slug = no_contacts_page.request.path.split("/")[-2] + self.assertEqual(expected_url_slug, actual_url_slug) + def test_application_about_your_organiztion_interstate(self): """Special districts have to answer an additional question.""" type_page = self.app.get(reverse("application:")).follow() @@ -1173,6 +1261,8 @@ class TestWithDomainPermissions(TestWithUser): DomainApplication.objects.all().delete() DomainInformation.objects.all().delete() PublicContact.objects.all().delete() + HostIP.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() UserDomainRole.objects.all().delete() except ValueError: # pass if already deleted diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 64136c3a5..4c46ee3a3 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1,44 +1,83 @@ import csv +import logging +from datetime import datetime from registrar.models.domain import Domain from registrar.models.domain_information import DomainInformation from registrar.models.public_contact import PublicContact from django.db.models import Value from django.db.models.functions import Coalesce +from django.utils import timezone + +logger = logging.getLogger(__name__) -def export_domains_to_writer(writer, columns, sort_fields, filter_condition): - # write columns headers to writer +def write_header(writer, columns): + """ + Receives params from the parent methods and outputs a CSV with a header row. + Works with write_header as longas the same writer object is passed. + """ writer.writerow(columns) - domainInfos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields) - for domainInfo in domainInfos: - security_contacts = domainInfo.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY) - # For linter - ao = " " - if domainInfo.authorizing_official: - first_name = domainInfo.authorizing_official.first_name or "" - last_name = domainInfo.authorizing_official.last_name or "" - ao = first_name + " " + last_name - # create a dictionary of fields which can be included in output - FIELDS = { - "Domain name": domainInfo.domain.name, - "Domain type": domainInfo.get_organization_type_display() + " - " + domainInfo.get_federal_type_display() - if domainInfo.federal_type - else domainInfo.get_organization_type_display(), - "Agency": domainInfo.federal_agency, - "Organization name": domainInfo.organization_name, - "City": domainInfo.city, - "State": domainInfo.state_territory, - "AO": ao, - "AO email": domainInfo.authorizing_official.email if domainInfo.authorizing_official else " ", - "Security Contact Email": security_contacts[0].email if security_contacts else " ", - "Status": domainInfo.domain.state, - "Expiration Date": domainInfo.domain.expiration_date, - } - writer.writerow([FIELDS.get(column, "") for column in columns]) + +def get_domain_infos(filter_condition, sort_fields): + domain_infos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields) + return domain_infos + + +def write_row(writer, columns, domain_info): + security_contacts = domain_info.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY) + # For linter + ao = " " + if domain_info.authorizing_official: + first_name = domain_info.authorizing_official.first_name or "" + last_name = domain_info.authorizing_official.last_name or "" + ao = first_name + " " + last_name + # create a dictionary of fields which can be included in output + FIELDS = { + "Domain name": domain_info.domain.name, + "Domain type": domain_info.get_organization_type_display() + " - " + domain_info.get_federal_type_display() + if domain_info.federal_type + else domain_info.get_organization_type_display(), + "Agency": domain_info.federal_agency, + "Organization name": domain_info.organization_name, + "City": domain_info.city, + "State": domain_info.state_territory, + "AO": ao, + "AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ", + "Security contact email": security_contacts[0].email if security_contacts else " ", + "Status": domain_info.domain.get_state_display(), + "Expiration date": domain_info.domain.expiration_date, + "Created at": domain_info.domain.created_at, + "First ready": domain_info.domain.first_ready, + "Deleted": domain_info.domain.deleted, + } + writer.writerow([FIELDS.get(column, "") for column in columns]) + + +def write_body( + writer, + columns, + sort_fields, + filter_condition, +): + """ + Receives params from the parent methods and outputs a CSV with fltered and sorted domains. + Works with write_header as longas the same writer object is passed. + """ + + # Get the domainInfos + domain_infos = get_domain_infos(filter_condition, sort_fields) + + all_domain_infos = list(domain_infos) + + # Write rows to CSV + for domain_info in all_domain_infos: + write_row(writer, columns, domain_info) def export_data_type_to_csv(csv_file): + """All domains report with extra columns""" + writer = csv.writer(csv_file) # define columns to include in export columns = [ @@ -50,9 +89,9 @@ def export_data_type_to_csv(csv_file): "State", "AO", "AO email", - "Security Contact Email", + "Security contact email", "Status", - "Expiration Date", + "Expiration date", ] # Coalesce is used to replace federal_type of None with ZZZZZ sort_fields = [ @@ -68,10 +107,13 @@ def export_data_type_to_csv(csv_file): Domain.State.ON_HOLD, ], } - export_domains_to_writer(writer, columns, sort_fields, filter_condition) + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) def export_data_full_to_csv(csv_file): + """All domains report""" + writer = csv.writer(csv_file) # define columns to include in export columns = [ @@ -81,7 +123,7 @@ def export_data_full_to_csv(csv_file): "Organization name", "City", "State", - "Security Contact Email", + "Security contact email", ] # Coalesce is used to replace federal_type of None with ZZZZZ sort_fields = [ @@ -97,10 +139,13 @@ def export_data_full_to_csv(csv_file): Domain.State.ON_HOLD, ], } - export_domains_to_writer(writer, columns, sort_fields, filter_condition) + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) def export_data_federal_to_csv(csv_file): + """Federal domains report""" + writer = csv.writer(csv_file) # define columns to include in export columns = [ @@ -110,7 +155,7 @@ def export_data_federal_to_csv(csv_file): "Organization name", "City", "State", - "Security Contact Email", + "Security contact email", ] # Coalesce is used to replace federal_type of None with ZZZZZ sort_fields = [ @@ -127,4 +172,74 @@ def export_data_federal_to_csv(csv_file): Domain.State.ON_HOLD, ], } - export_domains_to_writer(writer, columns, sort_fields, filter_condition) + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) + + +def get_default_start_date(): + # Default to a date that's prior to our first deployment + return timezone.make_aware(datetime(2023, 11, 1)) + + +def get_default_end_date(): + # Default to now() + return timezone.now() + + +def export_data_growth_to_csv(csv_file, start_date, end_date): + """ + Growth report: + Receive start and end dates from the view, parse them. + Request from write_body READY domains that are created between + the start and end dates, as well as DELETED domains that are deleted between + the start and end dates. Specify sort params for both lists. + """ + + start_date_formatted = ( + timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date() + ) + + end_date_formatted = ( + timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date() + ) + + writer = csv.writer(csv_file) + + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Status", + "Expiration date", + "Created at", + "First ready", + "Deleted", + ] + sort_fields = [ + "domain__first_ready", + "domain__name", + ] + filter_condition = { + "domain__state__in": [Domain.State.READY], + "domain__first_ready__lte": end_date_formatted, + "domain__first_ready__gte": start_date_formatted, + } + + # We also want domains deleted between sar and end dates, sorted + sort_fields_for_deleted_domains = [ + "domain__deleted", + "domain__name", + ] + filter_condition_for_deleted_domains = { + "domain__state__in": [Domain.State.DELETED], + "domain__deleted__lte": end_date_formatted, + "domain__deleted__gte": start_date_formatted, + } + + write_header(writer, columns) + write_body(writer, columns, sort_fields, filter_condition) + write_body(writer, columns, sort_fields_for_deleted_domains, filter_condition_for_deleted_domains) diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py new file mode 100644 index 000000000..f7164663b --- /dev/null +++ b/src/registrar/views/admin_views.py @@ -0,0 +1,26 @@ +"""Admin-related views.""" + +from django.http import HttpResponse +from django.views import View + +from registrar.utility import csv_export + +import logging + +logger = logging.getLogger(__name__) + + +class ExportData(View): + def get(self, request, *args, **kwargs): + # Get start_date and end_date from the request's GET parameters + # #999: not needed if we switch to django forms + start_date = request.GET.get("start_date", "") + end_date = request.GET.get("end_date", "") + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"' + # For #999: set export_data_growth_to_csv to return the resulting queryset, which we can then use + # in context to display this data in the template. + csv_export.export_data_growth_to_csv(response, start_date, end_date) + + return response diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 5ff291d69..0cf5970df 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -15,6 +15,136 @@ import logging logger = logging.getLogger(__name__) +class OrderableFieldsMixin: + """ + Mixin to add multi-field ordering capabilities to a Django ModelAdmin on admin_order_field. + """ + + custom_sort_name_prefix = "get_sortable_" + orderable_fk_fields = [] # type: ignore + + def __new__(cls, *args, **kwargs): + """ + This magic method is called when a new instance of the class (or subclass) is created. + It dynamically adds a new method to the class for each field in `orderable_fk_fields`. + Then, it will update the `list_display` attribute such that it uses these generated methods. + """ + new_class = super().__new__(cls) + + # If the class doesn't define anything for orderable_fk_fields, then we should + # just skip this additional logic + if not hasattr(cls, "orderable_fk_fields") or len(cls.orderable_fk_fields) == 0: + return new_class + + # Check if the list_display attribute exists, and if it does, create a local copy of that list. + list_display_exists = hasattr(cls, "list_display") and isinstance(cls.list_display, list) + new_list_display = cls.list_display.copy() if list_display_exists else [] + + for field, sort_field in cls.orderable_fk_fields: + updated_name = cls.custom_sort_name_prefix + field + + # For each item in orderable_fk_fields, create a function and associate it with admin_order_field. + setattr(new_class, updated_name, cls._create_orderable_field_method(field, sort_field)) + + # Update the list_display variable to use our newly created functions + if list_display_exists and field in cls.list_display: + index = new_list_display.index(field) + new_list_display[index] = updated_name + elif list_display_exists: + new_list_display.append(updated_name) + + # Replace the old list with the updated one + if list_display_exists: + cls.list_display = new_list_display + + return new_class + + @classmethod + def _create_orderable_field_method(cls, field, sort_field): + """ + This class method is a factory for creating dynamic methods that will be attached + to the ModelAdmin subclass. + It is used to customize how fk fields are ordered. + + In essence, this function will more or less generate code that looks like this, + for a given tuple defined in orderable_fk_fields: + + ``` + def get_sortable_requested_domain(self, obj): + return obj.requested_domain + # Allows column order sorting + get_sortable_requested_domain.admin_order_field = "requested_domain__name" + # Sets column's header name + get_sortable_requested_domain.short_description = "requested domain" + ``` + + Or for fields with multiple order_fields: + + ``` + def get_sortable_submitter(self, obj): + return obj.submitter + # Allows column order sorting + get_sortable_submitter.admin_order_field = ["submitter__first_name", "submitter__last_name"] + # Sets column's header + get_sortable_submitter.short_description = "submitter" + ``` + + Parameters: + cls: The class that this method is being called on. In the context of this mixin, + it would be the ModelAdmin subclass. + field: A string representing the name of the attribute that + the dynamic method will fetch from the model instance. + sort_field: A string or list of strings representing the + field(s) to sort by (ex: "name" or "creator") + + Returns: + method: The dynamically created method. + + The dynamically created method has the following attributes: + __name__: A string representing the name of the method. This is set to "get_{field}". + admin_order_field: A string or list of strings representing the field(s) that + Django should sort by when the column is clicked in the admin interface. + short_description: A string used as the column header in the admin interface. + Will replace underscores with spaces. + """ + + def method(obj): + """ + Template method for patterning. + + Returns (example): + ``` + def get_submitter(self, obj): + return obj.submitter + ``` + """ + attr = getattr(obj, field) + return attr + + # Set the function name. For instance, if the field is "domain", + # then this will generate a function called "get_sort_domain". + # This is done rather than just setting the name to the attribute to avoid + # naming conflicts. + method.__name__ = cls.custom_sort_name_prefix + field + + # Check if a list is passed in, or just a string. + if isinstance(sort_field, list): + sort_list = [] + for sort_field_item in sort_field: + order_field_string = f"{field}__{sort_field_item}" + sort_list.append(order_field_string) + # If its a list, return an array of fields to sort on. + # For instance, ["creator__first_name", "creator__last_name"] + method.admin_order_field = sort_list + else: + # If its not a list, just return a string + method.admin_order_field = f"{field}__{sort_field}" + + # Infer the column name in a similar manner to how Django does + method.short_description = field.replace("_", " ") + return method + + class PermissionsLoginMixin(PermissionRequiredMixin): """Mixin that redirects to login page if not logged in, otherwise 403.""" diff --git a/src/zap.conf b/src/zap.conf index e7dc980b0..7a1e5c96d 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -67,6 +67,7 @@ 10038 OUTOFSCOPE http://app:8080/dns/nameservers 10038 OUTOFSCOPE http://app:8080/dns/dnssec 10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata +10038 OUTOFSCOPE http://app:8080/org-name-address # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers