Merge pull request #1690 from cisagov/za/1271-admin-add-notes

(On getgov-ab) Ticket #1271: Add notes field for analysts on Domain and DomainApplication
This commit is contained in:
zandercymatics 2024-02-02 08:37:36 -07:00 committed by GitHub
commit 91cf57052f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 155 additions and 34 deletions

View file

@ -626,7 +626,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
search_help_text = "Search by domain."
fieldsets = [
(None, {"fields": ["creator", "domain_application"]}),
(None, {"fields": ["creator", "domain_application", "notes"]}),
(
"Type of organization",
{
@ -793,7 +793,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
# Detail view
form = DomainApplicationAdminForm
fieldsets = [
(None, {"fields": ["status", "investigator", "creator", "approved_domain"]}),
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
(
"Type of organization",
{
@ -1047,6 +1047,13 @@ class DomainAdmin(ListHeaderAdmin):
"deleted",
]
fieldsets = (
(
None,
{"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]},
),
)
# this ordering effects the ordering of results
# in autocomplete_fields for domain
ordering = ["name"]

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2024-01-26 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0067_create_groups_v07"),
]
operations = [
migrations.AddField(
model_name="domainapplication",
name="notes",
field=models.TextField(blank=True, help_text="Notes about this request", null=True),
),
migrations.AddField(
model_name="domaininformation",
name="notes",
field=models.TextField(blank=True, help_text="Notes about the request", null=True),
),
]

View file

@ -558,6 +558,12 @@ class DomainApplication(TimeStampedModel):
help_text="Date submitted",
)
notes = models.TextField(
null=True,
blank=True,
help_text="Notes about this request",
)
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:
@ -707,7 +713,7 @@ class DomainApplication(TimeStampedModel):
# copy the information from domainapplication into domaininformation
DomainInformation = apps.get_model("registrar.DomainInformation")
DomainInformation.create_from_da(self, domain=created_domain)
DomainInformation.create_from_da(domain_application=self, domain=created_domain)
# create the permission for the user
UserDomainRole = apps.get_model("registrar.UserDomainRole")

View file

@ -1,4 +1,7 @@
from __future__ import annotations
from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper
from .domain_application import DomainApplication
from .utility.time_stamped_model import TimeStampedModel
@ -202,6 +205,12 @@ class DomainInformation(TimeStampedModel):
help_text="Acknowledged .gov acceptable use policy",
)
notes = models.TextField(
null=True,
blank=True,
help_text="Notes about the request",
)
def __str__(self):
try:
if self.domain and self.domain.name:
@ -212,37 +221,63 @@ class DomainInformation(TimeStampedModel):
return ""
@classmethod
def create_from_da(cls, domain_application, domain=None):
"""Takes in a DomainApplication dict and converts it into DomainInformation"""
da_dict = domain_application.to_dict()
# remove the id so one can be assinged on creation
da_id = da_dict.pop("id", None)
def create_from_da(cls, domain_application: DomainApplication, domain=None):
"""Takes in a DomainApplication and converts it into DomainInformation"""
# Throw an error if we get None - we can't create something from nothing
if domain_application is None:
raise ValueError("The provided DomainApplication is None")
# Throw an error if the da doesn't have an id
if not hasattr(domain_application, "id"):
raise ValueError("The provided DomainApplication has no id")
# check if we have a record that corresponds with the domain
# application, if so short circuit the create
domain_info = cls.objects.filter(domain_application__id=da_id).first()
if domain_info:
return domain_info
# the following information below is not needed in the domain information:
da_dict.pop("status", None)
da_dict.pop("current_websites", None)
da_dict.pop("investigator", None)
da_dict.pop("alternative_domains", None)
da_dict.pop("requested_domain", None)
da_dict.pop("approved_domain", None)
da_dict.pop("submission_date", None)
other_contacts = da_dict.pop("other_contacts", [])
domain_info = cls(**da_dict)
domain_info.domain_application = domain_application
# Save so the object now have PK
# (needed to process the manytomany below before, first)
domain_info.save()
existing_domain_info = cls.objects.filter(domain_application__id=domain_application.id).first()
if existing_domain_info:
return existing_domain_info
# Process the remaining "many to many" stuff
domain_info.other_contacts.add(*other_contacts)
# Get the fields that exist on both DomainApplication and DomainInformation
common_fields = DomainHelper.get_common_fields(DomainApplication, DomainInformation)
# Get a list of all many_to_many relations on DomainInformation (needs to be saved differently)
info_many_to_many_fields = DomainInformation._get_many_to_many_fields()
# Create a dictionary with only the common fields, and create a DomainInformation from it
da_dict = {}
da_many_to_many_dict = {}
for field in common_fields:
# If the field isn't many_to_many, populate the da_dict.
# If it is, populate da_many_to_many_dict as we need to save this later.
if hasattr(domain_application, field):
if field not in info_many_to_many_fields:
da_dict[field] = getattr(domain_application, field)
else:
da_many_to_many_dict[field] = getattr(domain_application, field).all()
# Create a placeholder DomainInformation object
domain_info = DomainInformation(**da_dict)
# Add the domain_application and domain fields
domain_info.domain_application = domain_application
if domain:
domain_info.domain = domain
domain_info.save()
# Save the instance and set the many-to-many fields.
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
domain_info.save()
for field, value in da_many_to_many_dict.items():
getattr(domain_info, field).set(value)
return domain_info
@staticmethod
def _get_many_to_many_fields():
"""Returns a set of each field.name that has the many to many relation"""
return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore
class Meta:
verbose_name_plural = "Domain information"

View file

@ -1,5 +1,6 @@
import re
from typing import Type
from django.db import models
from django import forms
from django.http import JsonResponse
@ -29,7 +30,6 @@ class DomainHelper:
@classmethod
def validate(cls, domain: str, blank_ok=False) -> str:
"""Attempt to determine if a domain name could be requested."""
# Split into pieces for the linter
domain = cls._validate_domain_string(domain, blank_ok)
@ -161,3 +161,29 @@ class DomainHelper:
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
parts = domain.rsplit(".")
return parts[-1] if len(parts) > 1 else ""
@staticmethod
def get_common_fields(model_1: Type[models.Model], model_2: Type[models.Model]):
"""
Returns a set of field names that two Django models have in common, excluding the 'id' field.
Args:
model_1 (Type[models.Model]): The first Django model class.
model_2 (Type[models.Model]): The second Django model class.
Returns:
Set[str]: A set of field names that both models share.
Example:
If model_1 has fields {"id", "name", "color"} and model_2 has fields {"id", "color"},
the function will return {"color"}.
"""
# Get a list of the existing fields on model_1 and model_2
model_1_fields = set(field.name for field in model_1._meta.get_fields() if field != "id")
model_2_fields = set(field.name for field in model_2._meta.get_fields() if field != "id")
# Get the fields that exist on both DomainApplication and DomainInformation
common_fields = model_1_fields & model_2_fields
return common_fields

View file

@ -624,6 +624,7 @@ class TestDomainApplicationAdmin(MockEppLib):
"anything_else",
"is_policy_acknowledged",
"submission_date",
"notes",
"current_websites",
"other_contacts",
"alternative_domains",

View file

@ -548,9 +548,9 @@ class TestPermissions(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
class TestDomainInfo(TestCase):
class TestDomainInformation(TestCase):
"""Test creation of Domain Information when approved."""
"""Test the DomainInformation model, when approved or otherwise"""
def setUp(self):
super().setUp()
@ -559,12 +559,18 @@ class TestDomainInfo(TestCase):
def tearDown(self):
super().tearDown()
self.mock_client.EMAILS_SENT.clear()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
User.objects.all().delete()
DraftDomain.objects.all().delete()
@boto3_mocking.patching
def test_approval_creates_info(self):
self.maxDiff = None
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain, notes="test notes")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
@ -574,7 +580,25 @@ class TestDomainInfo(TestCase):
# should be an information present for this domain
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(DomainInformation.objects.get(domain=domain))
domain_information = DomainInformation.objects.filter(domain=domain)
self.assertTrue(domain_information.exists())
# Test that both objects are what we expect
current_domain_information = domain_information.get().__dict__
expected_domain_information = DomainInformation(
creator=user,
domain=domain,
notes="test notes",
domain_application=application,
).__dict__
# Test the two records for consistency
self.assertEqual(self.clean_dict(current_domain_information), self.clean_dict(expected_domain_information))
def clean_dict(self, dict_obj):
"""Cleans dynamic fields in a dictionary"""
bad_fields = ["_state", "created_at", "id", "updated_at"]
return {k: v for k, v in dict_obj.items() if k not in bad_fields}
class TestInvitations(TestCase):