Create a DraftDomain model for requested domains

This commit is contained in:
Seamus Johnston 2023-05-25 13:53:02 -05:00
parent 93427ad072
commit 7a3e1bcb2c
No known key found for this signature in database
GPG key ID: 2F21225985069105
15 changed files with 272 additions and 124 deletions

View file

@ -37,7 +37,7 @@ def _domains():
Fetch a file from DOMAIN_FILE_URL, parse the CSV for the domain,
lowercase everything and return the list.
"""
Domain = apps.get_model("registrar.Domain")
DraftDomain = apps.get_model("registrar.DraftDomain")
# 5 second timeout
file_contents = requests.get(DOMAIN_FILE_URL, timeout=5).text
domains = set()
@ -46,7 +46,7 @@ def _domains():
# get the domain before the first comma
domain = line.split(",", 1)[0]
# sanity-check the string we got from the file here
if Domain.string_could_be_domain(domain):
if DraftDomain.string_could_be_domain(domain):
# lowercase everything when we put it in domains
domains.add(domain.lower())
return domains
@ -75,12 +75,12 @@ def available(request, domain=""):
Response is a JSON dictionary with the key "available" and value true or
false.
"""
Domain = apps.get_model("registrar.Domain")
DraftDomain = apps.get_model("registrar.DraftDomain")
# validate that the given domain could be a domain name and fail early if
# not.
if not (
Domain.string_could_be_domain(domain)
or Domain.string_could_be_domain(domain + ".gov")
DraftDomain.string_could_be_domain(domain)
or DraftDomain.string_could_be_domain(domain + ".gov")
):
return JsonResponse(
{"available": False, "message": DOMAIN_API_MESSAGES["invalid"]}

View file

@ -5,7 +5,7 @@ from faker import Faker
from registrar.models import (
User,
DomainApplication,
Domain,
DraftDomain,
Contact,
Website,
)
@ -216,11 +216,13 @@ class DomainApplicationFixture:
if not da.requested_domain:
if "requested_domain" in app and app["requested_domain"] is not None:
da.requested_domain, _ = Domain.objects.get_or_create(
da.requested_domain, _ = DraftDomain.objects.get_or_create(
name=app["requested_domain"]
)
else:
da.requested_domain = Domain.objects.create(name=cls.fake_dot_gov())
da.requested_domain = DraftDomain.objects.create(
name=cls.fake_dot_gov()
)
@classmethod
def _set_many_to_many_relations(cls, da: DomainApplication, app: dict):

View file

@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe
from api.views import DOMAIN_API_MESSAGES
from registrar.models import Contact, DomainApplication, Domain
from registrar.models import Contact, DomainApplication, DraftDomain, Domain
from registrar.utility import errors
logger = logging.getLogger(__name__)
@ -453,7 +453,7 @@ class AlternativeDomainForm(RegistrarForm):
"""Validation code for domain names."""
try:
requested = self.cleaned_data.get("alternative_domain", None)
validated = Domain.validate(requested, blank_ok=True)
validated = DraftDomain.validate(requested, blank_ok=True)
except errors.ExtraDotsError:
raise forms.ValidationError(
DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots"
@ -498,7 +498,7 @@ class BaseAlternativeDomainFormSet(RegistrarFormSet):
@classmethod
def on_fetch(cls, query):
return [{"alternative_domain": domain.sld} for domain in query]
return [{"alternative_domain": Domain.sld(domain.name)} for domain in query]
@classmethod
def from_database(cls, obj):
@ -524,7 +524,7 @@ class DotGovDomainForm(RegistrarForm):
requested_domain.name = f"{domain}.gov"
requested_domain.save()
else:
requested_domain = Domain.objects.create(name=f"{domain}.gov")
requested_domain = DraftDomain.objects.create(name=f"{domain}.gov")
obj.requested_domain = requested_domain
obj.save()
@ -535,14 +535,14 @@ class DotGovDomainForm(RegistrarForm):
values = {}
requested_domain = getattr(obj, "requested_domain", None)
if requested_domain is not None:
values["requested_domain"] = requested_domain.sld
values["requested_domain"] = Domain.sld(requested_domain.name)
return values
def clean_requested_domain(self):
"""Validation code for domain names."""
try:
requested = self.cleaned_data.get("requested_domain", None)
validated = Domain.validate(requested)
validated = DraftDomain.validate(requested)
except errors.BlankValueError:
raise forms.ValidationError(
DOMAIN_API_MESSAGES["required"], code="required"

View file

@ -54,16 +54,6 @@ class Command(BaseCommand):
domains = []
for row in reader:
name = row["Name"].lower() # we typically use lowercase domains
# Ensure that there is a `Domain` object for each domain name in
# this file and that it is active. There is a uniqueness
# constraint for active Domain objects, so we are going to account
# for that here with this check so that our later bulk_create
# should succeed
if Domain.objects.filter(name=name, is_active=True).exists():
# don't do anything, this domain is here and active
continue
else:
domains.append(Domain(name=name, is_active=True))
domains.append(Domain(name=name))
logger.info("Creating %d new domains", len(domains))
Domain.objects.bulk_create(domains)

View file

@ -0,0 +1,66 @@
# Generated by Django 4.2.1 on 2023-05-26 13:14
from django.db import migrations, models
import django.db.models.deletion
import registrar.models.utility.domain_helper
class Migration(migrations.Migration):
dependencies = [
("registrar", "0021_publiccontact_domain_publiccontact_registry_id_and_more"),
]
operations = [
migrations.CreateModel(
name="DraftDomain",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
),
),
],
options={
"abstract": False,
},
bases=(models.Model, registrar.models.utility.domain_helper.DomainHelper),
),
migrations.AddField(
model_name="domainapplication",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="The approved domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_application",
to="registrar.domain",
),
),
migrations.AlterField(
model_name="domainapplication",
name="requested_domain",
field=models.OneToOneField(
blank=True,
help_text="The requested domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_application",
to="registrar.draftdomain",
),
),
]

View file

@ -4,6 +4,7 @@ from .contact import Contact
from .domain_application import DomainApplication
from .domain_information import DomainInformation
from .domain import Domain
from .draft_domain import DraftDomain
from .host_ip import HostIP
from .host import Host
from .domain_invitation import DomainInvitation
@ -18,6 +19,7 @@ __all__ = [
"DomainApplication",
"DomainInformation",
"Domain",
"DraftDomain",
"DomainInvitation",
"HostIP",
"Host",
@ -31,6 +33,7 @@ __all__ = [
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(DraftDomain)
auditlog.register(DomainInvitation)
auditlog.register(HostIP)
auditlog.register(Host)

View file

@ -400,10 +400,19 @@ class DomainApplication(TimeStampedModel):
related_name="current+",
)
requested_domain = models.OneToOneField(
approved_domain = models.OneToOneField(
"Domain",
null=True,
blank=True,
help_text="The approved domain",
related_name="domain_application",
on_delete=models.PROTECT,
)
requested_domain = models.OneToOneField(
"DraftDomain",
null=True,
blank=True,
help_text="The requested domain",
related_name="domain_application",
on_delete=models.PROTECT,
@ -499,8 +508,8 @@ class DomainApplication(TimeStampedModel):
if self.requested_domain is None:
raise ValueError("Requested domain is missing.")
Domain = apps.get_model("registrar.Domain")
if not Domain.string_could_be_domain(self.requested_domain.name):
DraftDomain = apps.get_model("registrar.DraftDomain")
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
# When an application is submitted, we need to send a confirmation email
@ -516,13 +525,16 @@ class DomainApplication(TimeStampedModel):
application into an admin on that domain.
"""
# create the domain if it doesn't exist
# create the domain
Domain = apps.get_model("registrar.Domain")
created_domain, _ = Domain.objects.get_or_create(name=self.requested_domain)
if Domain.objects.filter(name=self.requested_domain.name).exists():
raise ValueError("Cannot approve. Requested domain is already in use.")
created_domain = Domain.objects.create(name=self.requested_domain.name)
self.approved_domain = created_domain
# copy the information from domainapplication into domaininformation
DomainInformation = apps.get_model("registrar.DomainInformation")
DomainInformation.create_from_da(self)
DomainInformation.create_from_da(self, domain=created_domain)
# create the permission for the user
UserDomainRole = apps.get_model("registrar.UserDomainRole")

View file

@ -211,24 +211,24 @@ class DomainInformation(TimeStampedModel):
return ""
@classmethod
def create_from_da(cls, domain_application):
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")
da_id = da_dict.pop("id", None)
# 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")
da_dict.pop("current_websites")
da_dict.pop("investigator")
da_dict.pop("alternative_domains")
# use the requested_domain to create information for this domain
da_dict["domain"] = da_dict.pop("requested_domain")
other_contacts = da_dict.pop("other_contacts")
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)
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
@ -237,6 +237,8 @@ class DomainInformation(TimeStampedModel):
# Process the remaining "many to many" stuff
domain_info.other_contacts.add(*other_contacts)
if domain:
domain_info.domain = domain
domain_info.save()
return domain_info

View file

@ -0,0 +1,22 @@
import logging
from django.db import models
from .utility.domain_helper import DomainHelper
from .utility.time_stamped_model import TimeStampedModel
logger = logging.getLogger(__name__)
class DraftDomain(TimeStampedModel, DomainHelper):
"""Store domain names which registrants have requested."""
def __str__(self) -> str:
return self.name
name = models.CharField(
max_length=253,
blank=False,
default=None, # prevent saving without a value
help_text="Fully qualified domain name",
)

View file

@ -0,0 +1,64 @@
import re
from api.views import in_domains
from registrar.utility import errors
class DomainHelper:
"""Utility functions and constants for domain names."""
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
# a domain name is alphanumeric or hyphen, has at least 2 dots, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
HOST_REGEX = re.compile(r"^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.){2,}([A-Za-z]){2,6}$")
# a domain can be no longer than 253 characters in total
MAX_LENGTH = 253
@classmethod
def string_could_be_domain(cls, domain: str | None) -> bool:
"""Return True if the string could be a domain name, otherwise False."""
if not isinstance(domain, str):
return False
return bool(cls.DOMAIN_REGEX.match(domain))
@classmethod
def validate(cls, domain: str | None, blank_ok=False) -> str:
"""Attempt to determine if a domain name could be requested."""
if domain is None:
raise errors.BlankValueError()
if not isinstance(domain, str):
raise ValueError("Domain name must be a string")
domain = domain.lower().strip()
if domain == "":
if blank_ok:
return domain
else:
raise errors.BlankValueError()
if domain.endswith(".gov"):
domain = domain[:-4]
if "." in domain:
raise errors.ExtraDotsError()
if not DomainHelper.string_could_be_domain(domain + ".gov"):
raise ValueError()
if in_domains(domain):
raise errors.DomainUnavailableError()
return domain
@classmethod
def sld(cls, domain: str):
"""
Get the second level domain. Example: `gsa.gov` -> `gsa`.
If no TLD is present, returns the original string.
"""
return domain.split(".")[0]
@classmethod
def tld(cls, domain: str):
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
parts = domain.rsplit(".")
return parts[-1] if len(parts) > 1 else ""

View file

@ -1,5 +1,3 @@
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
@ -18,35 +16,5 @@ class Website(TimeStampedModel):
help_text="",
)
@property
def sld(self):
"""Get or set the second level domain string."""
return self.website.split(".")[0]
@sld.setter
def sld(self, value: str):
Domain = apps.get_model("registrar.Domain")
parts = self.website.split(".")
tld = parts[1] if len(parts) > 1 else ""
if Domain.string_could_be_domain(f"{value}.{tld}"):
self.website = f"{value}.{tld}"
else:
raise ValidationError("%s is not a valid second level domain" % value)
@property
def tld(self):
"""Get or set the top level domain string."""
parts = self.website.split(".")
return parts[1] if len(parts) > 1 else ""
@tld.setter
def tld(self, value: str):
Domain = apps.get_model("registrar.Domain")
sld = self.website.split(".")[0]
if Domain.string_could_be_domain(f"{sld}.{value}"):
self.website = f"{sld}.{value}"
else:
raise ValidationError("%s is not a valid top level domain" % value)
def __str__(self) -> str:
return str(self.website)

View file

@ -5,7 +5,7 @@ from unittest.mock import MagicMock
from django.contrib.auth import get_user_model
from django.test import TestCase
from registrar.models import Contact, Domain, Website, DomainApplication
from registrar.models import Contact, DraftDomain, Website, DomainApplication
import boto3_mocking # type: ignore
@ -28,7 +28,7 @@ class TestEmails(TestCase):
email="testy@town.com",
phone="(555) 555 5555",
)
domain, _ = Domain.objects.get_or_create(name="city.gov")
domain, _ = DraftDomain.objects.get_or_create(name="city.gov")
alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create(

View file

@ -8,6 +8,7 @@ from registrar.models import (
User,
Website,
Domain,
DraftDomain,
DomainInvitation,
UserDomainRole,
)
@ -40,7 +41,7 @@ class TestDomainApplication(TestCase):
contact = Contact.objects.create()
com_website, _ = Website.objects.get_or_create(website="igorville.com")
gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
investigator=user,
@ -100,7 +101,7 @@ class TestDomainApplication(TestCase):
def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create()
site = Domain.objects.create(name="igorville.gov")
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user, requested_domain=site
)
@ -113,7 +114,7 @@ class TestDomainApplication(TestCase):
"""Create an application and submit it and see if email was sent."""
user, _ = User.objects.get_or_create()
contact = Contact.objects.create(email="test@test.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
requested_domain=domain,
@ -135,62 +136,22 @@ class TestDomainApplication(TestCase):
)
class TestDomain(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain."""
with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create()
def test_minimal_create(self):
"""Can create with just a name."""
domain = Domain.objects.create(name="igorville.gov")
self.assertEqual(domain.is_active, False)
@skip("cannot activate a domain without mock registry")
def test_get_status(self):
"""Returns proper status based on `is_active`."""
domain = Domain.objects.create(name="igorville.gov")
domain.save()
self.assertEqual(None, domain.status)
domain.activate()
domain.save()
self.assertIn("ok", domain.status)
def test_fsm_activate_fail_unique(self):
"""Can't activate domain if name is not unique."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
d2, _ = Domain.objects.get_or_create(name="igorville.gov")
d1.activate()
d1.save()
with self.assertRaises(ValueError):
d2.activate()
def test_fsm_activate_fail_unapproved(self):
"""Can't activate domain if application isn't approved."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
d1.domain_application = application
d1.save()
with self.assertRaises(ValueError):
d1.activate()
class TestPermissions(TestCase):
"""Test the User-Domain-Role connection."""
def test_approval_creates_role(self):
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(
creator=user, requested_domain=domain
creator=user, requested_domain=draft_domain
)
# skip using the submit method
application.status = DomainApplication.SUBMITTED
application.approve()
# should be a role for this user
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
@ -199,16 +160,17 @@ class TestDomainInfo(TestCase):
"""Test creation of Domain Information when approved."""
def test_approval_creates_info(self):
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(
creator=user, requested_domain=domain
creator=user, requested_domain=draft_domain
)
# skip using the submit method
application.status = DomainApplication.SUBMITTED
application.approve()
# should be an information present for this domain
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(DomainInformation.objects.get(domain=domain))

View file

@ -0,0 +1,54 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from registrar.models import (
DomainApplication,
User,
Domain,
)
from unittest import skip
class TestDomain(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain."""
with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create()
def test_minimal_create(self):
"""Can create with just a name."""
Domain.objects.create(name="igorville.gov")
# this assertion will not work -- for now, the fact that the
# above command didn't error out is proof enough
# self.assertEquals(domain.state, Domain.State.DRAFTED)
@skip("cannot activate a domain without mock registry")
def test_get_status(self):
"""Returns proper status based on `state`."""
domain = Domain.objects.create(name="igorville.gov")
domain.save()
self.assertEqual(None, domain.status)
domain.activate()
domain.save()
self.assertIn("ok", domain.status)
@skip("cannot activate a domain without mock registry")
def test_fsm_activate_fail_unique(self):
"""Can't activate domain if name is not unique."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
d2, _ = Domain.objects.get_or_create(name="igorville.gov")
d1.activate()
d1.save()
with self.assertRaises(ValueError):
d2.activate()
@skip("cannot activate a domain without mock registry")
def test_fsm_activate_fail_unapproved(self):
"""Can't activate domain if application isn't approved."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
d1.domain_application = application
d1.save()
with self.assertRaises(ValueError):
d1.activate()

View file

@ -13,6 +13,7 @@ import boto3_mocking # type: ignore
from registrar.models import (
DomainApplication,
Domain,
DraftDomain,
DomainInvitation,
Contact,
Website,
@ -75,7 +76,7 @@ class LoggedInTests(TestWithUser):
def test_home_lists_domain_applications(self):
response = self.client.get("/")
self.assertNotContains(response, "igorville.gov")
site = Domain.objects.create(name="igorville.gov")
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site
)
@ -1035,6 +1036,8 @@ class TestWithDomainPermissions(TestWithUser):
def tearDown(self):
try:
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
self.domain.delete()
self.role.delete()
except ValueError: # pass if already deleted
@ -1347,7 +1350,7 @@ class TestApplicationStatus(TestWithUser, WebTest):
email="testy@town.com",
phone="(555) 555 5555",
)
domain, _ = Domain.objects.get_or_create(name="citystatus.gov")
domain, _ = DraftDomain.objects.get_or_create(name="citystatus.gov")
alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create(