mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-20 08:24:05 +02:00
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:
commit
91cf57052f
7 changed files with 155 additions and 34 deletions
|
@ -626,7 +626,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
search_help_text = "Search by domain."
|
search_help_text = "Search by domain."
|
||||||
|
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["creator", "domain_application"]}),
|
(None, {"fields": ["creator", "domain_application", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -793,7 +793,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
# Detail view
|
# Detail view
|
||||||
form = DomainApplicationAdminForm
|
form = DomainApplicationAdminForm
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain"]}),
|
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -1047,6 +1047,13 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
"deleted",
|
"deleted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# this ordering effects the ordering of results
|
# this ordering effects the ordering of results
|
||||||
# in autocomplete_fields for domain
|
# in autocomplete_fields for domain
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -558,6 +558,12 @@ class DomainApplication(TimeStampedModel):
|
||||||
help_text="Date submitted",
|
help_text="Date submitted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes about this request",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
try:
|
try:
|
||||||
if self.requested_domain and self.requested_domain.name:
|
if self.requested_domain and self.requested_domain.name:
|
||||||
|
@ -707,7 +713,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
# copy the information from domainapplication into domaininformation
|
# copy the information from domainapplication into domaininformation
|
||||||
DomainInformation = apps.get_model("registrar.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
|
# create the permission for the user
|
||||||
UserDomainRole = apps.get_model("registrar.UserDomainRole")
|
UserDomainRole = apps.get_model("registrar.UserDomainRole")
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from registrar.models.utility.domain_helper import DomainHelper
|
||||||
from .domain_application import DomainApplication
|
from .domain_application import DomainApplication
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
@ -202,6 +205,12 @@ class DomainInformation(TimeStampedModel):
|
||||||
help_text="Acknowledged .gov acceptable use policy",
|
help_text="Acknowledged .gov acceptable use policy",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes about the request",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
try:
|
try:
|
||||||
if self.domain and self.domain.name:
|
if self.domain and self.domain.name:
|
||||||
|
@ -212,37 +221,63 @@ class DomainInformation(TimeStampedModel):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_da(cls, domain_application, domain=None):
|
def create_from_da(cls, domain_application: DomainApplication, domain=None):
|
||||||
"""Takes in a DomainApplication dict and converts it into DomainInformation"""
|
"""Takes in a DomainApplication and converts it into DomainInformation"""
|
||||||
da_dict = domain_application.to_dict()
|
|
||||||
# remove the id so one can be assinged on creation
|
# Throw an error if we get None - we can't create something from nothing
|
||||||
da_id = da_dict.pop("id", None)
|
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
|
# check if we have a record that corresponds with the domain
|
||||||
# application, if so short circuit the create
|
# application, if so short circuit the create
|
||||||
domain_info = cls.objects.filter(domain_application__id=da_id).first()
|
existing_domain_info = cls.objects.filter(domain_application__id=domain_application.id).first()
|
||||||
if domain_info:
|
if existing_domain_info:
|
||||||
return domain_info
|
return existing_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()
|
|
||||||
|
|
||||||
# Process the remaining "many to many" stuff
|
# Get the fields that exist on both DomainApplication and DomainInformation
|
||||||
domain_info.other_contacts.add(*other_contacts)
|
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:
|
if domain:
|
||||||
domain_info.domain = domain
|
domain_info.domain = domain
|
||||||
|
|
||||||
|
# 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()
|
domain_info.save()
|
||||||
|
for field, value in da_many_to_many_dict.items():
|
||||||
|
getattr(domain_info, field).set(value)
|
||||||
|
|
||||||
return domain_info
|
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:
|
class Meta:
|
||||||
verbose_name_plural = "Domain information"
|
verbose_name_plural = "Domain information"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import re
|
import re
|
||||||
|
from typing import Type
|
||||||
|
from django.db import models
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
@ -29,7 +30,6 @@ class DomainHelper:
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, domain: str, blank_ok=False) -> str:
|
def validate(cls, domain: str, blank_ok=False) -> str:
|
||||||
"""Attempt to determine if a domain name could be requested."""
|
"""Attempt to determine if a domain name could be requested."""
|
||||||
|
|
||||||
# Split into pieces for the linter
|
# Split into pieces for the linter
|
||||||
domain = cls._validate_domain_string(domain, blank_ok)
|
domain = cls._validate_domain_string(domain, blank_ok)
|
||||||
|
|
||||||
|
@ -161,3 +161,29 @@ class DomainHelper:
|
||||||
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
|
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
|
||||||
parts = domain.rsplit(".")
|
parts = domain.rsplit(".")
|
||||||
return parts[-1] if len(parts) > 1 else ""
|
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
|
||||||
|
|
|
@ -624,6 +624,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
"submission_date",
|
"submission_date",
|
||||||
|
"notes",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"other_contacts",
|
"other_contacts",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
|
|
@ -548,9 +548,9 @@ class TestPermissions(TestCase):
|
||||||
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
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):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
@ -559,12 +559,18 @@ class TestDomainInfo(TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
self.mock_client.EMAILS_SENT.clear()
|
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
|
@boto3_mocking.patching
|
||||||
def test_approval_creates_info(self):
|
def test_approval_creates_info(self):
|
||||||
|
self.maxDiff = None
|
||||||
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
||||||
user, _ = User.objects.get_or_create()
|
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 boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
|
@ -574,7 +580,25 @@ class TestDomainInfo(TestCase):
|
||||||
|
|
||||||
# should be an information present for this domain
|
# should be an information present for this domain
|
||||||
domain = Domain.objects.get(name="igorville.gov")
|
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):
|
class TestInvitations(TestCase):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue