mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-19 10:59:21 +02:00
Merge branch 'main' into za/1128-email-bouncebacks
This commit is contained in:
commit
695015b94f
35 changed files with 1418 additions and 157 deletions
6
.github/SECURITY.md
vendored
6
.github/SECURITY.md
vendored
|
@ -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/).
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/<id>/edit/",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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"},
|
||||
),
|
||||
]
|
15
src/registrar/migrations/0059_delete_nameserver.py
Normal file
15
src/registrar/migrations/0059_delete_nameserver.py
Normal file
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -245,4 +245,4 @@ class DomainInformation(TimeStampedModel):
|
|||
return domain_info
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Domain Information"
|
||||
verbose_name_plural = "Domain information"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
33
src/registrar/templates/admin/index.html
Normal file
33
src/registrar/templates/admin/index.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{% extends "admin/index.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
|
||||
<div class="custom-content module">
|
||||
<h2>Reports</h2>
|
||||
<h3>Domain growth report</h3>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<div class="display-flex flex-align-baseline flex-justify margin-y-1">
|
||||
<div>
|
||||
<label for="start">Start date:</label>
|
||||
<input type="date" id="start" name="start" value="2018-07-22" min="2018-01-01" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end">End date:</label>
|
||||
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
|
||||
</div>
|
||||
|
||||
<button id="exportLink" data-export-url="{% url 'admin_export_data' %}" type="button" class="button">Export</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -6,7 +6,9 @@
|
|||
Who is the authorizing official for your organization?
|
||||
</h2>
|
||||
|
||||
<p>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.</p>
|
||||
{% if not is_federal %}
|
||||
<p>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.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="ao_example">
|
||||
{% include "includes/ao_example.html" %}
|
||||
|
|
|
@ -47,6 +47,15 @@
|
|||
{% if value|length == 1 %}
|
||||
{% if users %}
|
||||
<p class="margin-top-0">{{ value.0.user.email }} </p>
|
||||
{% 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 %}
|
||||
<p class="margin-top-0">{{ value | first }} </p>
|
||||
{% endif %}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,,,,
|
|
|
@ -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,,,,,
|
|
|
@ -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."""
|
||||
|
|
42
src/registrar/tests/test_admin_views.py
Normal file
42
src/registrar/tests/test_admin_views.py
Normal file
|
@ -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"])
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
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 domainInfo.authorizing_official:
|
||||
first_name = domainInfo.authorizing_official.first_name or ""
|
||||
last_name = domainInfo.authorizing_official.last_name or ""
|
||||
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": 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,
|
||||
"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": 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,
|
||||
"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)
|
||||
|
|
26
src/registrar/views/admin_views.py
Normal file
26
src/registrar/views/admin_views.py
Normal file
|
@ -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
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue