diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 974eb1995..72ba29db4 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -290,6 +290,13 @@ class CustomLogEntryAdmin(LogEntryAdmin): # Return the field value without a link return f"{obj.content_type} - {obj.object_repr}" + # We name the custom prop 'created_at' because linter + # is not allowing a short_description attr on it + # This gets around the linter limitation, for now. + @admin.display(description=_("Created at")) + def created(self, obj): + return obj.timestamp + search_help_text = "Search by resource, changes, or user." change_form_template = "admin/change_form_no_submit.html" @@ -478,7 +485,7 @@ class MyUserAdmin(BaseUserAdmin): list_display = ( "username", - "email", + "overridden_email_field", "first_name", "last_name", # Group is a custom property defined within this file, @@ -487,6 +494,18 @@ class MyUserAdmin(BaseUserAdmin): "status", ) + # Renames inherited AbstractUser label 'email_address to 'email' + def formfield_for_dbfield(self, dbfield, **kwargs): + field = super().formfield_for_dbfield(dbfield, **kwargs) + if dbfield.name == "email": + field.label = "Email" + return field + + # Renames inherited AbstractUser column name 'email_address to 'email' + @admin.display(description=_("Email")) + def overridden_email_field(self, obj): + return obj.email + fieldsets = ( ( None, @@ -561,6 +580,7 @@ class MyUserAdmin(BaseUserAdmin): # this ordering effects the ordering of results # in autocomplete_fields for user ordering = ["first_name", "last_name", "email"] + search_help_text = "Search by first name, last name, or email." change_form_template = "django/admin/email_clipboard_change_form.html" @@ -651,7 +671,7 @@ class MyHostAdmin(AuditedAdmin): """Custom host admin class to use our inlines.""" search_fields = ["name", "domain__name"] - search_help_text = "Search by domain or hostname." + search_help_text = "Search by domain or host name." inlines = [HostIPInline] @@ -659,9 +679,9 @@ class ContactAdmin(ListHeaderAdmin): """Custom contact admin class to add search.""" search_fields = ["email", "first_name", "last_name"] - search_help_text = "Search by firstname, lastname or email." + search_help_text = "Search by first name, last name or email." list_display = [ - "contact", + "name", "email", "user_exists", ] @@ -690,7 +710,7 @@ class ContactAdmin(ListHeaderAdmin): # We name the custom prop 'contact' because linter # is not allowing a short_description attr on it # This gets around the linter limitation, for now. - def contact(self, obj: models.Contact): + def name(self, obj: models.Contact): """Duplicate the contact _str_""" if obj.first_name or obj.last_name: return obj.get_formatted_name() @@ -701,7 +721,7 @@ class ContactAdmin(ListHeaderAdmin): else: return "" - contact.admin_order_field = "first_name" # type: ignore + name.admin_order_field = "first_name" # type: ignore # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ @@ -859,7 +879,7 @@ class UserDomainRoleAdmin(ListHeaderAdmin): "domain__name", "role", ] - search_help_text = "Search by firstname, lastname, email, domain, or role." + search_help_text = "Search by first name, last name, email, or domain." autocomplete_fields = ["user", "domain"] @@ -959,7 +979,9 @@ class DomainInformationAdmin(ListHeaderAdmin): "classes": ["collapse"], "fields": [ "federal_type", - "federal_agency", + # "updated_federal_agency", + # Above field commented out so it won't display + "federal_agency", # TODO: remove later "tribe_name", "federally_recognized_tribe", "state_recognized_tribe", @@ -1208,7 +1230,9 @@ class DomainRequestAdmin(ListHeaderAdmin): "classes": ["collapse"], "fields": [ "federal_type", - "federal_agency", + # "updated_federal_agency", + # Above field commented out so it won't display + "federal_agency", # TODO: remove later "tribe_name", "federally_recognized_tribe", "state_recognized_tribe", @@ -1665,6 +1689,7 @@ class DomainAdmin(ListHeaderAdmin): city.admin_order_field = "domain_info__city" # type: ignore + @admin.display(description=_("State / territory")) def state_territory(self, obj): return obj.domain_info.state_territory if obj.domain_info else None @@ -1979,6 +2004,11 @@ class DraftDomainAdmin(ListHeaderAdmin): # this ordering effects the ordering of results # in autocomplete_fields for user ordering = ["name"] + list_display = ["name"] + + @admin.display(description=_("Requested domain")) + def name(self, obj): + return obj.name def get_model_perms(self, request): """ @@ -2057,13 +2087,36 @@ class FederalAgencyAdmin(ListHeaderAdmin): ordering = ["agency"] +class UserGroupAdmin(AuditedAdmin): + """Overwrite the generated UserGroup admin class""" + + list_display = ["user_group"] + + fieldsets = ((None, {"fields": ("name", "permissions")}),) + + def formfield_for_dbfield(self, dbfield, **kwargs): + field = super().formfield_for_dbfield(dbfield, **kwargs) + if dbfield.name == "name": + field.label = "Group name" + if dbfield.name == "permissions": + field.label = "User permissions" + return field + + # We name the custom prop 'Group' because linter + # is not allowing a short_description attr on it + # This gets around the linter limitation, for now. + @admin.display(description=_("Group")) + def user_group(self, obj): + return obj.name + + admin.site.unregister(LogEntry) # Unregister the default registration admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(models.User, MyUserAdmin) # Unregister the built-in Group model admin.site.unregister(Group) # Register UserGroup -admin.site.register(models.UserGroup) +admin.site.register(models.UserGroup, UserGroupAdmin) admin.site.register(models.UserDomainRole, UserDomainRoleAdmin) admin.site.register(models.Contact, ContactAdmin) admin.site.register(models.DomainInvitation, DomainInvitationAdmin) diff --git a/src/registrar/migrations/0085_alter_contact_first_name_alter_contact_last_name_and_more.py b/src/registrar/migrations/0085_alter_contact_first_name_alter_contact_last_name_and_more.py new file mode 100644 index 000000000..a0365c284 --- /dev/null +++ b/src/registrar/migrations/0085_alter_contact_first_name_alter_contact_last_name_and_more.py @@ -0,0 +1,382 @@ +# Generated by Django 4.2.10 on 2024-04-18 18:01 + +import django.core.validators +from django.db import migrations, models +import django_fsm +import registrar.models.utility.domain_field + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0084_create_groups_v11"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="first_name", + field=models.CharField(blank=True, db_index=True, null=True, verbose_name="first name"), + ), + migrations.AlterField( + model_name="contact", + name="last_name", + field=models.CharField(blank=True, db_index=True, null=True, verbose_name="last name"), + ), + migrations.AlterField( + model_name="contact", + name="title", + field=models.CharField(blank=True, null=True, verbose_name="title / role"), + ), + migrations.AlterField( + model_name="domain", + name="deleted", + field=models.DateField(editable=False, help_text="Deleted at date", null=True, verbose_name="deleted on"), + ), + migrations.AlterField( + 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, + verbose_name="first ready on", + ), + ), + 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, + verbose_name="domain", + ), + ), + migrations.AlterField( + model_name="domain", + name="state", + field=django_fsm.FSMField( + choices=[ + ("unknown", "Unknown"), + ("dns needed", "Dns needed"), + ("ready", "Ready"), + ("on hold", "On hold"), + ("deleted", "Deleted"), + ], + default="unknown", + help_text="Very basic info about the lifecycle of this domain object", + max_length=21, + protected=True, + verbose_name="domain state", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="address_line1", + field=models.CharField(blank=True, help_text="Street address", null=True, verbose_name="address line 1"), + ), + migrations.AlterField( + model_name="domaininformation", + name="address_line2", + field=models.CharField( + blank=True, help_text="Street address line 2 (optional)", null=True, verbose_name="address line 2" + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="is_election_board", + field=models.BooleanField( + blank=True, + help_text="Is your organization an election office?", + null=True, + verbose_name="election office", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="state_territory", + field=models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + help_text="State, territory, or military post", + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="urbanization", + field=models.CharField( + blank=True, + help_text="Urbanization (required for Puerto Rico only)", + null=True, + verbose_name="urbanization", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="zipcode", + field=models.CharField( + blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code" + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="is_election_board", + field=models.BooleanField( + blank=True, + help_text="Is your organization an election office?", + null=True, + verbose_name="election office", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="state_territory", + field=models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + help_text="State, territory, or military post", + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="submission_date", + field=models.DateField( + blank=True, default=None, help_text="Date submitted", null=True, verbose_name="submitted at" + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="zipcode", + field=models.CharField( + blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code" + ), + ), + migrations.AlterField( + model_name="draftdomain", + name="name", + field=models.CharField( + default=None, help_text="Fully qualified domain name", max_length=253, verbose_name="requested domain" + ), + ), + migrations.AlterField( + model_name="host", + name="name", + field=models.CharField( + default=None, help_text="Fully qualified domain name", max_length=253, verbose_name="host name" + ), + ), + migrations.AlterField( + model_name="hostip", + name="address", + field=models.CharField( + default=None, + help_text="IP address", + max_length=46, + validators=[django.core.validators.validate_ipv46_address], + verbose_name="IP address", + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="domain_name", + field=models.CharField(blank=True, null=True, verbose_name="domain"), + ), + migrations.AlterField( + model_name="transitiondomain", + name="first_name", + field=models.CharField( + blank=True, db_index=True, help_text="First name / given name", null=True, verbose_name="first name" + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="processed", + field=models.BooleanField( + default=True, + help_text="Indicates whether this TransitionDomain was already processed", + verbose_name="processed", + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="state_territory", + field=models.CharField( + blank=True, + help_text="State, territory, or military post", + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="status", + field=models.CharField( + blank=True, + choices=[("ready", "Ready"), ("on hold", "On hold"), ("unknown", "Unknown")], + default="ready", + help_text="domain status during the transfer", + max_length=255, + verbose_name="status", + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="title", + field=models.CharField(blank=True, help_text="Title", null=True, verbose_name="title / role"), + ), + migrations.AlterField( + model_name="transitiondomain", + name="username", + field=models.CharField(help_text="Username - this will be an email address", verbose_name="username"), + ), + migrations.AlterField( + model_name="transitiondomain", + name="zipcode", + field=models.CharField( + blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code" + ), + ), + migrations.AlterField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("restricted", "restricted")], + default=None, + max_length=10, + null=True, + verbose_name="user status", + ), + ), + ] diff --git a/src/registrar/migrations/0086_domaininformation_updated_federal_agency_and_more.py b/src/registrar/migrations/0086_domaininformation_updated_federal_agency_and_more.py new file mode 100644 index 000000000..52f5955d0 --- /dev/null +++ b/src/registrar/migrations/0086_domaininformation_updated_federal_agency_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.10 on 2024-04-18 22:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0085_alter_contact_first_name_alter_contact_last_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domaininformation", + name="updated_federal_agency", + field=models.ForeignKey( + blank=True, + help_text="Associated federal agency", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="registrar.federalagency", + ), + ), + migrations.AddField( + model_name="domainrequest", + name="updated_federal_agency", + field=models.ForeignKey( + blank=True, + help_text="Associated federal agency", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="registrar.federalagency", + ), + ), + ] diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index bf35b0143..9deb22641 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -18,7 +18,7 @@ class Contact(TimeStampedModel): first_name = models.CharField( null=True, blank=True, - verbose_name="first name / given name", + verbose_name="first name", db_index=True, ) middle_name = models.CharField( @@ -28,13 +28,13 @@ class Contact(TimeStampedModel): last_name = models.CharField( null=True, blank=True, - verbose_name="last name / family name", + verbose_name="last name", db_index=True, ) title = models.CharField( null=True, blank=True, - verbose_name="title or role in your organization", + verbose_name="title / role", ) email = models.EmailField( null=True, diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 079fce3bc..0d0d8020d 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -992,6 +992,7 @@ class Domain(TimeStampedModel, DomainHelper): blank=False, default=None, # prevent saving without a value unique=True, + verbose_name="domain", help_text="Fully qualified domain name", ) @@ -1000,6 +1001,7 @@ class Domain(TimeStampedModel, DomainHelper): choices=State.choices, default=State.UNKNOWN, protected=True, # cannot change state directly, particularly in Django admin + verbose_name="domain state", help_text="Very basic info about the lifecycle of this domain object", ) @@ -1017,12 +1019,14 @@ class Domain(TimeStampedModel, DomainHelper): deleted = DateField( null=True, editable=False, + verbose_name="deleted on", help_text="Deleted at date", ) first_ready = DateField( null=True, editable=False, + verbose_name="first ready on", help_text="The last time this domain moved into the READY state", ) @@ -1685,6 +1689,59 @@ class Domain(TimeStampedModel, DomainHelper): else: logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e)) + def _fix_unknown_state(self, cleaned): + """ + _fix_unknown_state: Calls _add_missing_contacts_if_unknown + to add contacts in as needed (or return an error). Otherwise + if we are able to add contacts and the state is out of UNKNOWN + and (and should be into DNS_NEEDED), we double check the + current state and # of nameservers and update the state from there + """ + try: + self._add_missing_contacts_if_unknown(cleaned) + + except Exception as e: + logger.error( + "%s couldn't _add_missing_contacts_if_unknown, error was %s." + "Domain will still be in UNKNOWN state." % (self.name, e) + ) + if len(self.nameservers) >= 2 and (self.state != self.State.READY): + self.ready() + self.save() + + @transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED) + def _add_missing_contacts_if_unknown(self, cleaned): + """ + _add_missing_contacts_if_unknown: Add contacts (SECURITY, TECHNICAL, and/or ADMINISTRATIVE) + if they are missing, AND switch the state to DNS_NEEDED from UNKNOWN (if it + is in an UNKNOWN state, that is an error state) + Note: The transition state change happens at the end of the function + """ + + missingAdmin = True + missingSecurity = True + missingTech = True + + if len(cleaned.get("_contacts")) < 3: + for contact in cleaned.get("_contacts"): + if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: + missingAdmin = False + if contact.type == PublicContact.ContactTypeChoices.SECURITY: + missingSecurity = False + if contact.type == PublicContact.ContactTypeChoices.TECHNICAL: + missingTech = False + + # We are only creating if it doesn't exist so we don't overwrite + if missingAdmin: + administrative_contact = self.get_default_administrative_contact() + administrative_contact.save() + if missingSecurity: + security_contact = self.get_default_security_contact() + security_contact.save() + if missingTech: + technical_contact = self.get_default_technical_contact() + technical_contact.save() + def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False): """Contact registry for info about a domain.""" try: @@ -1692,6 +1749,9 @@ 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 self.state == self.State.UNKNOWN: + self._fix_unknown_state(cleaned) if fetch_hosts: self._update_hosts_and_ips_in_db(cleaned) if fetch_contacts: diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 7d71915ba..d6fee3f79 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -29,8 +29,18 @@ class DomainInformation(TimeStampedModel): BranchChoices = DomainRequest.BranchChoices + # TODO for #1975: Delete this after we run the new migration AGENCY_CHOICES = DomainRequest.AGENCY_CHOICES + updated_federal_agency = models.ForeignKey( + "registrar.FederalAgency", + on_delete=models.PROTECT, + help_text="Associated federal agency", + unique=False, + blank=True, + null=True, + ) + # This is the domain request user who created this domain request. The contact # information that they gave is in the `submitter` field creator = models.ForeignKey( @@ -62,6 +72,7 @@ class DomainInformation(TimeStampedModel): is_election_board = models.BooleanField( null=True, blank=True, + verbose_name="election office", help_text="Is your organization an election office?", ) @@ -108,6 +119,7 @@ class DomainInformation(TimeStampedModel): is_election_board = models.BooleanField( null=True, blank=True, + verbose_name="election office", help_text="Is your organization an election office?", ) @@ -121,13 +133,13 @@ class DomainInformation(TimeStampedModel): null=True, blank=True, help_text="Street address", - verbose_name="Street address", + verbose_name="address line 1", ) address_line2 = models.CharField( null=True, blank=True, help_text="Street address line 2 (optional)", - verbose_name="Street address line 2 (optional)", + verbose_name="address line 2", ) city = models.CharField( null=True, @@ -139,21 +151,22 @@ class DomainInformation(TimeStampedModel): choices=StateTerritoryChoices.choices, null=True, blank=True, + verbose_name="state / territory", help_text="State, territory, or military post", - verbose_name="State, territory, or military post", ) zipcode = models.CharField( max_length=10, null=True, blank=True, help_text="Zip code", + verbose_name="zip code", db_index=True, ) urbanization = models.CharField( null=True, blank=True, help_text="Urbanization (required for Puerto Rico only)", - verbose_name="Urbanization (required for Puerto Rico only)", + verbose_name="urbanization", ) about_your_organization = models.TextField( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 6da51d485..fb7221c80 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -449,6 +449,15 @@ class DomainRequest(TimeStampedModel): blank=True, ) + updated_federal_agency = models.ForeignKey( + "registrar.FederalAgency", + on_delete=models.PROTECT, + help_text="Associated federal agency", + unique=False, + blank=True, + null=True, + ) + # This is the domain request user who created this domain request. The contact # information that they gave is in the `submitter` field creator = models.ForeignKey( @@ -478,6 +487,7 @@ class DomainRequest(TimeStampedModel): is_election_board = models.BooleanField( null=True, blank=True, + verbose_name="election office", help_text="Is your organization an election office?", ) @@ -550,12 +560,14 @@ class DomainRequest(TimeStampedModel): choices=StateTerritoryChoices.choices, null=True, blank=True, + verbose_name="state / territory", help_text="State, territory, or military post", ) zipcode = models.CharField( max_length=10, null=True, blank=True, + verbose_name="zip code", help_text="Zip code", db_index=True, ) @@ -683,6 +695,7 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, default=None, + verbose_name="submitted at", help_text="Date submitted", ) diff --git a/src/registrar/models/draft_domain.py b/src/registrar/models/draft_domain.py index fc70a18f3..16fb1de33 100644 --- a/src/registrar/models/draft_domain.py +++ b/src/registrar/models/draft_domain.py @@ -18,5 +18,6 @@ class DraftDomain(TimeStampedModel, DomainHelper): max_length=253, blank=False, default=None, # prevent saving without a value + verbose_name="requested domain", help_text="Fully qualified domain name", ) diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py index 3b966832f..2fb4b980b 100644 --- a/src/registrar/models/host.py +++ b/src/registrar/models/host.py @@ -21,6 +21,7 @@ class Host(TimeStampedModel): blank=False, default=None, # prevent saving without a value unique=False, + verbose_name="host name", help_text="Fully qualified domain name", ) diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py index 777d14430..216ad9eca 100644 --- a/src/registrar/models/host_ip.py +++ b/src/registrar/models/host_ip.py @@ -20,6 +20,7 @@ class HostIP(TimeStampedModel): blank=False, default=None, # prevent saving without a value validators=[validate_ipv46_address], + verbose_name="IP address", help_text="IP address", ) diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py index eafbeda00..2dafd6da4 100644 --- a/src/registrar/models/transition_domain.py +++ b/src/registrar/models/transition_domain.py @@ -20,13 +20,13 @@ class TransitionDomain(TimeStampedModel): username = models.CharField( null=False, blank=False, - verbose_name="Username", + verbose_name="username", help_text="Username - this will be an email address", ) domain_name = models.CharField( null=True, blank=True, - verbose_name="Domain name", + verbose_name="domain", ) status = models.CharField( max_length=255, @@ -34,7 +34,7 @@ class TransitionDomain(TimeStampedModel): blank=True, default=StatusChoices.READY, choices=StatusChoices.choices, - verbose_name="Status", + verbose_name="status", help_text="domain status during the transfer", ) email_sent = models.BooleanField( @@ -46,7 +46,7 @@ class TransitionDomain(TimeStampedModel): processed = models.BooleanField( null=False, default=True, - verbose_name="Processed", + verbose_name="processed", help_text="Indicates whether this TransitionDomain was already processed", ) generic_org_type = models.CharField( @@ -83,8 +83,8 @@ class TransitionDomain(TimeStampedModel): first_name = models.CharField( null=True, blank=True, - help_text="First name", - verbose_name="first name / given name", + help_text="First name / given name", + verbose_name="first name", db_index=True, ) middle_name = models.CharField( @@ -100,6 +100,7 @@ class TransitionDomain(TimeStampedModel): title = models.CharField( null=True, blank=True, + verbose_name="title / role", help_text="Title", ) email = models.EmailField( @@ -126,12 +127,14 @@ class TransitionDomain(TimeStampedModel): max_length=2, null=True, blank=True, + verbose_name="state / territory", help_text="State, territory, or military post", ) zipcode = models.CharField( max_length=10, null=True, blank=True, + verbose_name="zip code", help_text="Zip code", db_index=True, ) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 2688ef57f..cf027e70c 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -33,6 +33,7 @@ class User(AbstractUser): default=None, # Set the default value to None null=True, # Allow the field to be null blank=True, # Allow the field to be blank + verbose_name="user status", ) domains = models.ManyToManyField( diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 97e620813..82fa4c061 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -545,7 +545,6 @@ class MockDb(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_5, _ = Domain.objects.get_or_create( name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1)) ) @@ -979,7 +978,20 @@ class MockEppLib(TestCase): mockDataInfoDomain = fakedEppObject( "fakePw", cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), - contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], + contacts=[ + common.DomainContact( + contact="securityContact", + type=PublicContact.ContactTypeChoices.SECURITY, + ), + common.DomainContact( + contact="technicalContact", + type=PublicContact.ContactTypeChoices.TECHNICAL, + ), + common.DomainContact( + contact="adminContact", + type=PublicContact.ContactTypeChoices.ADMINISTRATIVE, + ), + ], hosts=["fake.host.com"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), @@ -1049,10 +1061,13 @@ class MockEppLib(TestCase): ex_date=date(2023, 11, 15), ) mockDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData( - "123", "123@mail.gov", datetime(2023, 5, 25, 19, 45, 35), "lastPw" + id="123", email="123@mail.gov", cr_date=datetime(2023, 5, 25, 19, 45, 35), pw="lastPw" + ) + mockDataSecurityContact = mockDataInfoDomain.dummyInfoContactResultData( + id="securityContact", email="security@mail.gov", cr_date=datetime(2023, 5, 25, 19, 45, 35), pw="lastPw" ) InfoDomainWithContacts = fakedEppObject( - "fakepw", + "fakePw", cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( @@ -1074,6 +1089,7 @@ class MockEppLib(TestCase): common.Status(state="inactive", description="", lang="en"), ], registrant="regContact", + ex_date=date(2023, 11, 15), ) InfoDomainWithDefaultSecurityContact = fakedEppObject( @@ -1500,6 +1516,8 @@ class MockEppLib(TestCase): "meow.gov": (self.mockDataInfoDomainSubdomainAndIPAddress, None), "fakemeow.gov": (self.mockDataInfoDomainNotSubdomainNoIP, None), "subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None), + "ddomain3.gov": (self.InfoDomainWithContacts, None), + "igorville.gov": (self.InfoDomainWithContacts, None), } # Retrieve the corresponding values from the dictionary diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 3293fc47c..61e2a255f 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -25,11 +25,13 @@ from registrar.models import ( Domain, DomainRequest, DomainInformation, + DraftDomain, User, DomainInvitation, Contact, + PublicContact, + Host, Website, - DraftDomain, ) from registrar.models.user_domain_role import UserDomainRole from registrar.models.verified_by_staff import VerifiedByStaff @@ -690,6 +692,8 @@ class TestDomainAdmin(MockEppLib, WebTest): def tearDown(self): super().tearDown() + PublicContact.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() @@ -1858,6 +1862,8 @@ class TestDomainRequestAdmin(MockEppLib): "updated_at", "status", "rejection_reason", + "updated_federal_agency", + # TODO: once approved, we'll have to remove above from test "creator", "investigator", "generic_org_type", diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 8887aae1f..abad6f57e 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -107,9 +107,9 @@ class TestDomainCache(MockEppLib): common.DomainContact(contact="123", type="security"), ] expectedContactsDict = { - PublicContact.ContactTypeChoices.ADMINISTRATIVE: None, - PublicContact.ContactTypeChoices.SECURITY: "123", - PublicContact.ContactTypeChoices.TECHNICAL: None, + PublicContact.ContactTypeChoices.ADMINISTRATIVE: "adminContact", + PublicContact.ContactTypeChoices.SECURITY: "securityContact", + PublicContact.ContactTypeChoices.TECHNICAL: "technicalContact", } expectedHostsDict = { "name": self.mockDataInfoDomain.hosts[0], @@ -129,6 +129,7 @@ class TestDomainCache(MockEppLib): # The contact list should not contain what is sent by the registry by default, # as _fetch_cache will transform the type to PublicContact self.assertNotEqual(domain._cache["contacts"], expectedUnfurledContactsList) + self.assertEqual(domain._cache["contacts"], expectedContactsDict) # get and check hosts is set correctly @@ -203,19 +204,20 @@ class TestDomainCache(MockEppLib): def test_map_epp_contact_to_public_contact(self): # Tests that the mapper is working how we expect with less_console_noise(): - domain, _ = Domain.objects.get_or_create(name="registry.gov") + domain, _ = Domain.objects.get_or_create(name="registry.gov", state=Domain.State.DNS_NEEDED) security = PublicContact.ContactTypeChoices.SECURITY mapped = domain.map_epp_contact_to_public_contact( - self.mockDataInfoContact, - self.mockDataInfoContact.id, + self.mockDataSecurityContact, + self.mockDataSecurityContact.id, security, ) + # id, registry_id, and contact are the same thing expected_contact = PublicContact( domain=domain, contact_type=security, - registry_id="123", - email="123@mail.gov", + registry_id="securityContact", + email="security@mail.gov", voice="+1.8882820870", fax="+1-212-9876543", pw="lastPw", @@ -232,7 +234,6 @@ class TestDomainCache(MockEppLib): # two duplicate objects. We would expect # these not to have the same state. expected_contact._state = mapped._state - # Mapped object is what we expect self.assertEqual(mapped.__dict__, expected_contact.__dict__) @@ -243,9 +244,9 @@ class TestDomainCache(MockEppLib): registry_id=domain.security_contact.registry_id, contact_type=security, ).get() + # DB Object is the same as the mapped object self.assertEqual(db_object, in_db) - domain.security_contact = in_db # Trigger the getter _ = domain.security_contact @@ -309,6 +310,40 @@ class TestDomainCache(MockEppLib): ) self.assertEqual(context.exception.code, desired_error) + def test_fix_unknown_to_ready_state(self): + """ + Scenario: A error occurred and the domain's state is in UNKONWN + which shouldn't happen. The biz logic and test is to make sure + we resolve that UNKNOWN state to READY because it has 2 nameservers. + Note: + * Default state when you do get_or_create is UNKNOWN + * justnameserver.com has 2 nameservers which is why we are using it + * justnameserver.com also has all 3 contacts hence 0 count + """ + with less_console_noise(): + domain, _ = Domain.objects.get_or_create(name="justnameserver.com") + # trigger the getter + _ = domain.nameservers + self.assertEqual(domain.state, Domain.State.READY) + self.assertEqual(PublicContact.objects.filter(domain=domain.id).count(), 0) + + def test_fix_unknown_to_dns_needed_state(self): + """ + Scenario: A error occurred and the domain's state is in UNKONWN + which shouldn't happen. The biz logic and test is to make sure + we resolve that UNKNOWN state to DNS_NEEDED because it has 1 nameserver. + Note: + * Default state when you do get_or_create is UNKNOWN + * defaulttechnical.gov has 1 nameservers which is why we are using it + * defaulttechnical.gov already has a security contact (1) hence 2 count + """ + with less_console_noise(): + domain, _ = Domain.objects.get_or_create(name="defaulttechnical.gov") + # trigger the getter + _ = domain.nameservers + self.assertEqual(domain.state, Domain.State.DNS_NEEDED) + self.assertEqual(PublicContact.objects.filter(domain=domain.id).count(), 2) + class TestDomainCreation(MockEppLib): """Rule: An approved domain request must result in a domain""" @@ -346,7 +381,7 @@ class TestDomainCreation(MockEppLib): Given that no domain object exists in the registry When a property is accessed Then Domain sends `commands.CreateDomain` to the registry - And `domain.state` is set to `UNKNOWN` + And `domain.state` is set to `DNS_NEEDED` And `domain.is_active()` returns False """ with less_console_noise(): @@ -375,7 +410,7 @@ class TestDomainCreation(MockEppLib): any_order=False, # Ensure calls are in the specified order ) - self.assertEqual(domain.state, Domain.State.UNKNOWN) + self.assertEqual(domain.state, Domain.State.DNS_NEEDED) self.assertEqual(domain.is_active(), False) @skip("assertion broken with mock addition") @@ -400,6 +435,7 @@ class TestDomainCreation(MockEppLib): DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() PublicContact.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() User.objects.all().delete() DraftDomain.objects.all().delete() @@ -485,6 +521,7 @@ class TestDomainStatuses(MockEppLib): def tearDown(self) -> None: PublicContact.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -624,6 +661,7 @@ class TestRegistrantContacts(MockEppLib): self.domain._invalidate_cache() self.domain_contact._invalidate_cache() PublicContact.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() def test_no_security_email(self): @@ -998,10 +1036,10 @@ class TestRegistrantContacts(MockEppLib): And the field `disclose` is set to true for DF.EMAIL """ with less_console_noise(): - domain, _ = Domain.objects.get_or_create(name="igorville.gov") + domain, _ = Domain.objects.get_or_create(name="igorville.gov", state=Domain.State.DNS_NEEDED) expectedSecContact = PublicContact.get_default_security() expectedSecContact.domain = domain - expectedSecContact.email = "123@mail.gov" + expectedSecContact.email = "security@mail.gov" domain.security_contact = expectedSecContact expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=True) self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True) @@ -1847,6 +1885,8 @@ class TestRegistrantDNSSEC(MockEppLib): self.domain, _ = Domain.objects.get_or_create(name="fake.gov") def tearDown(self): + PublicContact.objects.all().delete() + Host.objects.all().delete() Domain.objects.all().delete() super().tearDown() @@ -1904,6 +1944,7 @@ class TestRegistrantDNSSEC(MockEppLib): ), cleaned=True, ), + call(commands.InfoHost(name="fake.host.com"), cleaned=True), call( commands.UpdateDomain( name="dnssec-dsdata.gov", @@ -1976,6 +2017,13 @@ class TestRegistrantDNSSEC(MockEppLib): ), cleaned=True, ), + call( + commands.InfoDomain( + name="dnssec-dsdata.gov", + ), + cleaned=True, + ), + call(commands.InfoHost(name="fake.host.com"), cleaned=True), call( commands.UpdateDomain( name="dnssec-dsdata.gov", @@ -2129,6 +2177,7 @@ class TestRegistrantDNSSEC(MockEppLib): ), cleaned=True, ), + call(commands.InfoHost(name="fake.host.com"), cleaned=True), call( commands.UpdateDomain( name="dnssec-dsdata.gov", diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index be66cb876..d918dda92 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -263,7 +263,7 @@ class ExportDataTest(MockDb, MockEppLib): "adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n" "adomain2.gov,Interstate,(blank),Dns needed\n" "cdomain11.govFederal-ExecutiveWorldWarICentennialCommissionReady\n" - "ddomain3.gov,Federal,Armed Forces Retirement Home,123@mail.gov,On hold,2023-05-25\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,security@mail.gov,On hold,2023-11-15\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,(blank),Ready\n" "zdomain12.govInterstateReady\n" ) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 3a5ce7e7b..fc391f8b5 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -243,7 +243,7 @@ class TestDomainDetail(TestDomainOverview): self.assertContains(home_page, "DNS needed") def test_unknown_domain_does_not_show_as_expired_on_detail_page(self): - """An UNKNOWN domain does not show as expired on the detail page. + """An UNKNOWN domain should not exist on the detail_page anymore. It shows as 'DNS needed'""" # At the time of this test's writing, there are 6 UNKNOWN domains inherited # from constructors. Let's reset. @@ -262,9 +262,9 @@ class TestDomainDetail(TestDomainOverview): igorville = Domain.objects.get(name="igorville.gov") self.assertEquals(igorville.state, Domain.State.UNKNOWN) detail_page = home_page.click("Manage", index=0) - self.assertNotContains(detail_page, "Expired") + self.assertContains(detail_page, "Expired") - self.assertContains(detail_page, "DNS needed") + self.assertNotContains(detail_page, "DNS needed") def test_domain_detail_blocked_for_ineligible_user(self): """We could easily duplicate this test for all domain management