diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 89958a887..c677554de 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -28,8 +28,7 @@ information to make connections between registry users and the domains that they manage. The registrar stores very few fields about a domain except for its name, so it could be straightforward to import the exported list of domains from Verisign's `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that -that table stores a flag for active or inactive, so every domain in the file -can be imported into our system as `is_active=True`. +that table stores a flag for active or inactive. An example Django management command that can load the delimited text file from the daily escrow is in diff --git a/src/registrar/migrations/0023_remove_domain_unique_domain_name_in_registry_and_more.py b/src/registrar/migrations/0023_remove_domain_unique_domain_name_in_registry_and_more.py new file mode 100644 index 000000000..816c32be8 --- /dev/null +++ b/src/registrar/migrations/0023_remove_domain_unique_domain_name_in_registry_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.1 on 2023-05-26 19:21 + +from django.db import migrations, models +import registrar.models.utility.domain_field + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0022_draftdomain_domainapplication_approved_domain_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="domain", + name="unique_domain_name_in_registry", + ), + migrations.RemoveField( + model_name="domain", + name="is_active", + ), + migrations.AddField( + model_name="domain", + name="state", + field=models.CharField( + choices=[ + ("created", "Created"), + ("deleted", "Deleted"), + ("unknown", "Unknown"), + ], + default="unknown", + help_text="Very basic info about the lifecycle of this domain object", + max_length=21, + ), + ), + migrations.AlterField( + model_name="domain", + name="name", + field=registrar.models.utility.domain_field.DomainField( + default=None, + help_text="Fully qualified domain name", + max_length=253, + unique=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 22aa41751..a657a52eb 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1,42 +1,44 @@ import logging -import re -from typing import List +from datetime import date -from django.apps import apps -from django.core.exceptions import ValidationError from django.db import models -from django_fsm import FSMField, transition # type: ignore -from api.views import in_domains -from registrar.utility import errors +from epplibwrapper import ( + CLIENT as registry, + commands, +) +from .utility.domain_field import DomainField +from .utility.domain_helper import DomainHelper from .utility.time_stamped_model import TimeStampedModel +from .public_contact import PublicContact + logger = logging.getLogger(__name__) -class Domain(TimeStampedModel): +class Domain(TimeStampedModel, DomainHelper): """ Manage the lifecycle of domain names. The registry is the source of truth for this data and this model exists: 1. To tie ownership information in the registrar to - DNS entries in the registry; and - 2. To allow a new registrant to draft DNS entries before their - application is approved - """ + DNS entries in the registry - class Meta: - constraints = [ - # draft domains may share the same name, but - # once approved, they must be globally unique - models.UniqueConstraint( - fields=["name"], - condition=models.Q(is_active=True), - name="unique_domain_name_in_registry", - ), - ] + ~~~ HOW TO USE THIS CLASS ~~~ + + A) You can create a Domain object with just a name. `Domain(name="something.gov")`. + B) Saving the Domain object will not contact the registry, as it may be useful + to have Domain objects in an `UNKNOWN` pre-created state. + C) Domain properties are lazily loaded. Accessing `my_domain.expiration_date` will + contact the registry, if a cached copy does not exist. + D) Domain creation is lazy. If `my_domain.expiration_date` finds that `my_domain` + does not exist in the registry, it will ask the registry to create it. + F) Created is _not_ the same as active aka live on the internet. + G) Activation is controlled by the registry. It will happen automatically when the + domain meets the required checks. + """ class Status(models.TextChoices): """ @@ -91,221 +93,193 @@ class Domain(TimeStampedModel): PENDING_TRANSFER = "pendingTransfer" PENDING_UPDATE = "pendingUpdate" - # 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}(? 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)) + # the normal state of a domain object -- may or may not be active! + CREATED = "created" - @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 Domain.string_could_be_domain(domain + ".gov"): - raise ValueError() - if in_domains(domain): - raise errors.DomainUnavailableError() - return domain + # previously existed but has been deleted from the registry + DELETED = "deleted" + + # the state is indeterminate + UNKNOWN = "unknown" @classmethod def available(cls, domain: str) -> bool: - """Check if a domain is available. + """Check if a domain is available.""" + if not cls.string_could_be_domain(domain): + raise ValueError("Not a valid domain: %s" % str(domain)) + req = commands.CheckDomain([domain]) + return registry.send(req).res_data[0].avail - Not implemented. Returns a dummy value for testing.""" - return False # domain_check(domain) + @classmethod + def registered(cls, domain: str) -> bool: + """Check if a domain is _not_ available.""" + return not cls.available(domain) + + @property + def contacts(self) -> dict[str, str]: + """ + Get a dictionary of registry IDs for the contacts for this domain. + + IDs are provided as strings, e.g. + + {"registrant": "jd1234", "admin": "sh8013",...} + """ + raise NotImplementedError() + + @property + def creation_date(self) -> date: + """Get the `cr_date` element from the registry.""" + raise NotImplementedError() + + @property + def last_transferred_date(self) -> date: + """Get the `tr_date` element from the registry.""" + raise NotImplementedError() + + @property + def last_updated_date(self) -> date: + """Get the `up_date` element from the registry.""" + raise NotImplementedError() + + @property + def expiration_date(self) -> date: + """Get or set the `ex_date` element from the registry.""" + raise NotImplementedError() + + @expiration_date.setter # type: ignore + def expiration_date(self, ex_date: date): + raise NotImplementedError() + + @property + def password(self) -> str: + """Get the `auth_info.pw` element from the registry. Not a real password.""" + raise NotImplementedError() + + @property + def nameservers(self) -> list[tuple[str]]: + """ + Get or set a complete list of nameservers for this domain. + + Hosts are provided as a list of tuples, e.g. + + [("ns1.example.com",), ("ns1.example.gov", "0.0.0.0")] + + Subordinate hosts (something.your-domain.gov) MUST have IP addresses, + while non-subordinate hosts MUST NOT. + """ + # TODO: call EPP to get this info instead of returning fake data. + return [ + ("ns1.example.com",), + ("ns2.example.com",), + ("ns3.example.com",), + ] + + @nameservers.setter # type: ignore + def nameservers(self, hosts: list[tuple[str]]): + # TODO: call EPP to set this info. + pass + + @property + def statuses(self) -> list[str]: + """ + Get or set the domain `status` elements from the registry. + + A domain's status indicates various properties. See Domain.Status. + """ + # implementation note: the Status object from EPP stores the string in + # a dataclass property `state`, not to be confused with the `state` field here + raise NotImplementedError() + + @statuses.setter # type: ignore + def statuses(self, statuses: list[str]): + # TODO: there are a long list of rules in the RFC about which statuses + # can be combined; check that here and raise errors for invalid combinations - + # some statuses cannot be set by the client at all + raise NotImplementedError() + + @property + def registrant_contact(self) -> PublicContact: + """Get or set the registrant for this domain.""" + raise NotImplementedError() + + @registrant_contact.setter # type: ignore + def registrant_contact(self, contact: PublicContact): + raise NotImplementedError() + + @property + def administrative_contact(self) -> PublicContact: + """Get or set the admin contact for this domain.""" + raise NotImplementedError() + + @administrative_contact.setter # type: ignore + def administrative_contact(self, contact: PublicContact): + raise NotImplementedError() + + @property + def security_contact(self) -> PublicContact: + """Get or set the security contact for this domain.""" + # TODO: replace this with a real implementation + contact = PublicContact.get_default_security() + contact.domain = self + contact.email = "mayor@igorville.gov" + return contact + + @security_contact.setter # type: ignore + def security_contact(self, contact: PublicContact): + # TODO: replace this with a real implementation + pass + + @property + def technical_contact(self) -> PublicContact: + """Get or set the tech contact for this domain.""" + raise NotImplementedError() + + @technical_contact.setter # type: ignore + def technical_contact(self, contact: PublicContact): + raise NotImplementedError() + + def is_active(self) -> bool: + """Is the domain live on the inter webs?""" + # TODO: implement a check -- should be performant so it can be called for + # any number of domains on a status page + # this is NOT as simple as checking if Domain.Status.OK is in self.statuses + return False def transfer(self): """Going somewhere. Not implemented.""" - pass + raise NotImplementedError() def renew(self): """Time to renew. Not implemented.""" - pass + raise NotImplementedError() - def _get_property(self, property): - """Get some info about a domain.""" - if not self.is_active: - return None - if not hasattr(self, "info"): - try: - # get info from registry - self.info = {} # domain_info(self.name) - except Exception as e: - logger.error(e) - # TODO: back off error handling - return None - if hasattr(self, "info"): - if property in self.info: - return self.info[property] - else: - raise KeyError( - "Requested key %s was not found in registry data." % str(property) - ) - else: - # TODO: return an error if registry cannot be contacted - return None + def place_client_hold(self): + """This domain should not be active.""" + raise NotImplementedError() - @transition(field="is_active", source="*", target=True) - def activate(self): - """This domain should be made live.""" - DomainApplication = apps.get_model("registrar.DomainApplication") - if hasattr(self, "domain_application"): - if self.domain_application.status != DomainApplication.APPROVED: - raise ValueError("Cannot activate. Application must be approved.") - if Domain.objects.filter(name=self.name, is_active=True).exists(): - raise ValueError("Cannot activate. Domain name is already in use.") - # TODO: depending on the details of our registry integration - # we will either contact the registry and deploy the domain - # in this function OR we will verify that it has already been - # activated and reject this state transition if it has not - pass - - @transition(field="is_active", source="*", target=False) - def deactivate(self): - """This domain should not be live.""" - # there are security concerns to having this function exist - # within the codebase; discuss these with the project lead - # if there is a feature request to implement this - raise Exception("Cannot revoke, contact registry.") - - @property - def sld(self): - """Get or set the second level domain string.""" - return self.name.split(".")[0] - - @sld.setter - def sld(self, value: str): - parts = self.name.split(".") - tld = parts[1] if len(parts) > 1 else "" - if Domain.string_could_be_domain(f"{value}.{tld}"): - self.name = 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.name.split(".") - return parts[1] if len(parts) > 1 else "" - - @tld.setter - def tld(self, value: str): - sld = self.name.split(".")[0] - if Domain.string_could_be_domain(f"{sld}.{value}"): - self.name = f"{sld}.{value}" - else: - raise ValidationError("%s is not a valid top level domain" % value) + def remove_client_hold(self): + """This domain is okay to be active.""" + raise NotImplementedError() def __str__(self) -> str: return self.name - def nameservers(self) -> List[str]: - """A list of the nameservers for this domain. - - TODO: call EPP to get this info instead of returning fake data. - """ - return [ - # reserved example domain - "ns1.example.com", - "ns2.example.com", - "ns3.example.com", - ] - - def set_nameservers(self, new_nameservers: List[str]): - """Set the nameservers for this domain.""" - # TODO: call EPP to set these values in the registry instead of doing - # nothing. - logger.warn("TODO: Fake setting nameservers to %s", new_nameservers) - - def security_email(self) -> str: - """Get the security email for this domain. - - TODO: call EPP to get this info instead of returning fake data. - """ - return "mayor@igorville.gov" - - def set_security_email(self, new_security_email: str): - """Set the security email for this domain.""" - # TODO: call EPP to set these values in the registry instead of doing - # nothing. - logger.warn("TODO: Fake setting security email to %s", new_security_email) - - @property - def roid(self): - return self._get_property("roid") - - @property - def status(self): - return self._get_property("status") - - @property - def registrant(self): - return self._get_property("registrant") - - @property - def sponsor(self): - return self._get_property("sponsor") - - @property - def creator(self): - return self._get_property("creator") - - @property - def creation_date(self): - return self._get_property("creation_date") - - @property - def updator(self): - return self._get_property("updator") - - @property - def last_update_date(self): - return self._get_property("last_update_date") - - @property - def expiration_date(self): - return self._get_property("expiration_date") - - @property - def last_transfer_date(self): - return self._get_property("last_transfer_date") - - name = models.CharField( + name = DomainField( max_length=253, blank=False, default=None, # prevent saving without a value + unique=True, help_text="Fully qualified domain name", ) - # we use `is_active` rather than `domain_application.status` - # because domains may exist without associated applications - is_active = FSMField( - choices=[ - (True, "Yes"), - (False, "No"), - ], - default=False, - # TODO: how to edit models in Django admin if protected = True - protected=False, - help_text="Domain is live in the registry", + state = models.CharField( + max_length=21, + choices=State.choices, + default=State.UNKNOWN, + help_text="Very basic info about the lifecycle of this domain object", ) # ForeignKey on UserDomainRole creates a "permissions" member for diff --git a/src/registrar/models/public_contact.py b/src/registrar/models/public_contact.py index f273b9ef1..cfed96205 100644 --- a/src/registrar/models/public_contact.py +++ b/src/registrar/models/public_contact.py @@ -32,7 +32,7 @@ class PublicContact(TimeStampedModel): if hasattr(self, "domain"): match self.contact_type: case PublicContact.ContactTypeChoices.REGISTRANT: - self.domain.registrant = self + self.domain.registrant_contact = self case PublicContact.ContactTypeChoices.ADMINISTRATIVE: self.domain.administrative_contact = self case PublicContact.ContactTypeChoices.TECHNICAL: diff --git a/src/registrar/models/utility/domain_field.py b/src/registrar/models/utility/domain_field.py new file mode 100644 index 000000000..297793723 --- /dev/null +++ b/src/registrar/models/utility/domain_field.py @@ -0,0 +1,13 @@ +from django.db import models + + +class DomainField(models.CharField): + """Subclass of CharField to enforce domain name specific requirements.""" + + def to_python(self, value): + """Convert to lowercase during deserialization and during form `clean`.""" + if value is None: + return value + if isinstance(value, str): + return value.lower() + return str(value).lower() diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b5962c398..e2a9f8224 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -41,7 +41,7 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView): def get_initial(self): """The initial value for the form (which is a formset here).""" domain = self.get_object() - return [{"server": server} for server in domain.nameservers()] + return [{"server": name} for name, *ip in domain.nameservers] def get_success_url(self): """Redirect to the overview page for the domain.""" @@ -82,12 +82,13 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView): nameservers = [] for form in formset: try: - nameservers.append(form.cleaned_data["server"]) + as_tuple = (form.cleaned_data["server"],) + nameservers.append(as_tuple) except KeyError: # no server information in this field, skip it pass domain = self.get_object() - domain.set_nameservers(nameservers) + domain.nameservers = nameservers messages.success( self.request, "The name servers for this domain have been updated." @@ -109,7 +110,7 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView): """The initial value for the form.""" domain = self.get_object() initial = super().get_initial() - initial["security_email"] = domain.security_email() + initial["security_email"] = domain.security_contact.email return initial def get_success_url(self): @@ -132,7 +133,9 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView): # Set the security email from the form new_email = form.cleaned_data.get("security_email", "") domain = self.get_object() - domain.set_security_email(new_email) + contact = domain.security_contact + contact.email = new_email + contact.save() messages.success( self.request, "The security email for this domain have been updated."