diff --git a/docs/developer/database-access.md b/docs/developer/database-access.md index 859ef2fd6..f77261bbb 100644 --- a/docs/developer/database-access.md +++ b/docs/developer/database-access.md @@ -56,6 +56,13 @@ cf ssh getgov-ENVIRONMENT ./manage.py dumpdata ``` +## Access certain table in the database +1. `cf connect-to-service getgov-ENVIRONMENT getgov-ENVIRONMENT-database` gets you into whichever environments database you'd like +2. `\c [table name here that starts cgaws...etc];` connects to the [cgaws...etc] table +3. `\dt` retrieves information about that table and displays it +4. Make sure the table you are looking for exists. For this example, we are looking for `django_migrations` +5. Run `SELECT * FROM django_migrations;` to see everything that's in it! + ## Dropping and re-creating the database For your sandbox environment, it might be necessary to start the database over from scratch. diff --git a/docs/developer/migration-troubleshooting.md b/docs/developer/migration-troubleshooting.md index b90c02ae3..22a02503d 100644 --- a/docs/developer/migration-troubleshooting.md +++ b/docs/developer/migration-troubleshooting.md @@ -121,3 +121,19 @@ https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1697810600723069 2. `./manage.py migrate model_name_here file_name_WITH_create` (run the last data creation migration AND ONLY THAT ONE) 3. `./manage.py migrate --fake model_name_here most_recent_file_name` (fake migrate the last migration in the migration list) 4. `./manage.py load` (rerun fixtures) + +### Scenario 9: Inconsistent Migration History +If you see `django.db.migrations.exceptions.InconsistentMigrationHistory` error, or when you run `./manage.py showmigrations` it looks like: + +[x] 0056_example_migration +[ ] 0057_other_migration +[x] 0058_some_other_migration + +1. Go to [database-access.md](../database-access.md#access-certain-table-in-the-database) to see the commands on how to access a certain table in the database. +2. In this case, we want to remove the migration "history" from the `django_migrations` table +3. Once you are in the `cgaws...` table, select the `django_migrations` table with the command `SELECT * FROM django_migrations;` +4. Find the id of the "history" you want to delete. This will be the one in the far left column. For this example, let's pretend the id is 101. +5. Run `DELETE FROM django_migrations WHERE id=101;` where 101 is an example id as seen above. +6. Go to your shell and run `./manage.py showmigrations` to make sure your migrations are now back to the right state. Most likely you will see several unapplied migrations. +7. If you still have unapplied migrations, run `./manage.py migrate`. If an error occurs saying one has already been applied, fake that particular migration `./manage.py migrate --fake model_name_here migration_number` and then run the normal `./manage.py migrate` command to then apply those migrations that come after the one that threw the error. + diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 05bfc06b6..1275fab79 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -663,6 +663,7 @@ class ContactAdmin(ListHeaderAdmin): list_display = [ "contact", "email", + "user_exists", ] # this ordering effects the ordering of results # in autocomplete_fields for user @@ -679,6 +680,13 @@ class ContactAdmin(ListHeaderAdmin): change_form_template = "django/admin/email_clipboard_change_form.html" + def user_exists(self, obj): + """Check if the Contact has a related User""" + return "Yes" if obj.user is not None else "No" + + user_exists.short_description = "Is user" # type: ignore + user_exists.admin_order_field = "user" # type: ignore + # 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. @@ -1435,12 +1443,36 @@ class DomainRequestAdmin(ListHeaderAdmin): """ Override changelist_view to set the selected value of status filter. """ + # there are two conditions which should set the default selected filter: + # 1 - there are no query parameters in the request and the request is the + # initial request for this view + # 2 - there are no query parameters in the request and the referring url is + # the change view for a domain request + should_apply_default_filter = False # use http_referer in order to distinguish between request as a link from another page # and request as a removal of all filters http_referer = request.META.get("HTTP_REFERER", "") # if there are no query parameters in the request - # and the request is the initial request for this view - if not bool(request.GET) and request.path not in http_referer: + if not bool(request.GET): + # if the request is the initial request for this view + if request.path not in http_referer: + should_apply_default_filter = True + # elif the request is a referral from changelist view or from + # domain request change view + elif request.path in http_referer: + # find the index to determine the referring url after the path + index = http_referer.find(request.path) + # Check if there is a character following the path in http_referer + next_char_index = index + len(request.path) + if index + next_char_index < len(http_referer): + next_char = http_referer[next_char_index] + + # Check if the next character is a digit, if so, this indicates + # a change view for domain request + if next_char.isdigit(): + should_apply_default_filter = True + + if should_apply_default_filter: # modify the GET of the request to set the selected filter modified_get = copy.deepcopy(request.GET) modified_get["status__in"] = "submitted,in review,action needed" diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 587b95305..b4c41ecf1 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -530,7 +530,7 @@ function hideDeletedForms() { let isDotgovDomain = document.querySelector(".dotgov-domain-form"); // The Nameservers formset features 2 required and 11 optionals if (isNameserversForm) { - cloneIndex = 2; + // cloneIndex = 2; formLabel = "Name server"; // DNSSEC: DS Data } else if (isDsDataForm) { @@ -766,3 +766,21 @@ function toggleTwoDomElements(ele1, ele2, index) { } })(); +/** + * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms + * + */ +(function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); + } + } +})(); diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 7b0ac2956..da1462bdb 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -83,25 +83,34 @@ class DomainNameserverForm(forms.Form): # after clean_fields. it is used to determine form level errors. # is_valid is typically called from view during a post cleaned_data = super().clean() + self.clean_empty_strings(cleaned_data) + server = cleaned_data.get("server", "") - # remove ANY spaces in the server field - server = server.replace(" ", "") - # lowercase the server - server = server.lower() + server = server.replace(" ", "").lower() cleaned_data["server"] = server - ip = cleaned_data.get("ip", None) - # remove ANY spaces in the ip field + + ip = cleaned_data.get("ip", "") ip = ip.replace(" ", "") cleaned_data["ip"] = ip + domain = cleaned_data.get("domain", "") ip_list = self.extract_ip_list(ip) - # validate if the form has a server or an ip + # Capture the server_value + server_value = self.cleaned_data.get("server") + + # Validate if the form has a server or an ip if (ip and ip_list) or server: self.validate_nameserver_ip_combo(domain, server, ip_list) + # Re-set the server value: + # add_error which is called on validate_nameserver_ip_combo will clean-up (delete) any invalid data. + # We need that data because we need to know the total server entries (even if invalid) in the formset + # clean method where we determine whether a blank first and/or second entry should throw a required error. + self.cleaned_data["server"] = server_value + return cleaned_data def clean_empty_strings(self, cleaned_data): @@ -149,6 +158,19 @@ class BaseNameserverFormset(forms.BaseFormSet): """ Check for duplicate entries in the formset. """ + + # Check if there are at least two valid servers + valid_servers_count = sum( + 1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip() + ) + if valid_servers_count >= 2: + # If there are, remove the "At least two name servers are required" error from each form + # This will allow for successful submissions when the first or second entries are blanked + # but there are enough entries total + for form in self.forms: + if form.errors.get("server") == ["At least two name servers are required."]: + form.errors.pop("server") + if any(self.errors): # Don't bother validating the formset unless each form is valid on its own return @@ -156,10 +178,13 @@ class BaseNameserverFormset(forms.BaseFormSet): data = [] duplicates = [] - for form in self.forms: + for index, form in enumerate(self.forms): if form.cleaned_data: value = form.cleaned_data["server"] - if value in data: + # We need to make sure not to trigger the duplicate error in case the first and second nameservers + # are empty. If there are enough records in the formset, that error is an unecessary blocker. + # If there aren't, the required error will block the submit. + if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1): form.add_error( "server", NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value), diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index a0b0e774f..07dc08f8a 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1152,6 +1152,18 @@ class MockEppLib(TestCase): ], ) + infoDomainFourHosts = fakedEppObject( + "fournameserversDomain.gov", + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), + contacts=[], + hosts=[ + "ns1.my-nameserver-1.com", + "ns1.my-nameserver-2.com", + "ns1.cats-are-superior3.com", + "ns1.explosive-chicken-nuggets.com", + ], + ) + infoDomainNoHost = fakedEppObject( "my-nameserver.gov", cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), @@ -1452,7 +1464,9 @@ class MockEppLib(TestCase): ) def mockInfoDomainCommands(self, _request, cleaned): - request_name = getattr(_request, "name", None) + request_name = getattr(_request, "name", None).lower() + + print(request_name) # Define a dictionary to map request names to data and extension values request_mappings = { @@ -1474,7 +1488,8 @@ class MockEppLib(TestCase): "nameserverwithip.gov": (self.infoDomainHasIP, None), "namerserversubdomain.gov": (self.infoDomainCheckHostIPCombo, None), "freeman.gov": (self.InfoDomainWithContacts, None), - "threenameserversDomain.gov": (self.infoDomainThreeHosts, None), + "threenameserversdomain.gov": (self.infoDomainThreeHosts, None), + "fournameserversdomain.gov": (self.infoDomainFourHosts, None), "defaultsecurity.gov": (self.InfoDomainWithDefaultSecurityContact, None), "adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None), "defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None), diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 064c5efdb..3a5ce7e7b 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -5,7 +5,7 @@ from django.conf import settings from django.urls import reverse from django.contrib.auth import get_user_model -from .common import MockSESClient, create_user # type: ignore +from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -71,11 +71,14 @@ class TestWithDomainPermissions(TestWithUser): # that inherit this setUp self.domain_dnssec_none, _ = Domain.objects.get_or_create(name="dnssec-none.gov") + self.domain_with_four_nameservers, _ = Domain.objects.get_or_create(name="fournameserversDomain.gov") + self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_multdsdata) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dnssec_none) + DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_four_nameservers) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_ip) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver) DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold) @@ -98,6 +101,11 @@ class TestWithDomainPermissions(TestWithUser): domain=self.domain_dnssec_none, role=UserDomainRole.Roles.MANAGER, ) + UserDomainRole.objects.get_or_create( + user=self.user, + domain=self.domain_with_four_nameservers, + role=UserDomainRole.Roles.MANAGER, + ) UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain_with_ip, @@ -727,7 +735,7 @@ class TestDomainManagers(TestDomainOverview): self.assertContains(home_page, self.domain.name) -class TestDomainNameservers(TestDomainOverview): +class TestDomainNameservers(TestDomainOverview, MockEppLib): def test_domain_nameservers(self): """Can load domain's nameservers page.""" page = self.client.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) @@ -974,6 +982,117 @@ class TestDomainNameservers(TestDomainOverview): page = result.follow() self.assertContains(page, "The name servers for this domain have been updated") + def test_domain_nameservers_can_blank_out_first_or_second_one_if_enough_entries(self): + """Nameserver form submits successfully with 2 valid inputs, even if the first or + second entries are blanked out. + + Uses self.app WebTest because we need to interact with forms. + """ + + nameserver1 = "" + nameserver2 = "ns2.igorville.gov" + nameserver3 = "ns3.igorville.gov" + valid_ip = "" + valid_ip_2 = "128.0.0.2" + valid_ip_3 = "128.0.0.3" + nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + nameservers_page.form["form-0-server"] = nameserver1 + nameservers_page.form["form-0-ip"] = valid_ip + nameservers_page.form["form-1-server"] = nameserver2 + nameservers_page.form["form-1-ip"] = valid_ip_2 + nameservers_page.form["form-2-server"] = nameserver3 + nameservers_page.form["form-2-ip"] = valid_ip_3 + with less_console_noise(): # swallow log warning message + result = nameservers_page.form.submit() + + # form submission was a successful post, response should be a 302 + self.assertEqual(result.status_code, 302) + self.assertEqual( + result["Location"], + reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}), + ) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + nameservers_page = result.follow() + self.assertContains(nameservers_page, "The name servers for this domain have been updated") + + nameserver1 = "ns1.igorville.gov" + nameserver2 = "" + nameserver3 = "ns3.igorville.gov" + valid_ip = "128.0.0.1" + valid_ip_2 = "" + valid_ip_3 = "128.0.0.3" + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + nameservers_page.form["form-0-server"] = nameserver1 + nameservers_page.form["form-0-ip"] = valid_ip + nameservers_page.form["form-1-server"] = nameserver2 + nameservers_page.form["form-1-ip"] = valid_ip_2 + nameservers_page.form["form-2-server"] = nameserver3 + nameservers_page.form["form-2-ip"] = valid_ip_3 + with less_console_noise(): # swallow log warning message + result = nameservers_page.form.submit() + + # form submission was a successful post, response should be a 302 + self.assertEqual(result.status_code, 302) + self.assertEqual( + result["Location"], + reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}), + ) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + nameservers_page = result.follow() + self.assertContains(nameservers_page, "The name servers for this domain have been updated") + + def test_domain_nameservers_can_blank_out_first_and_second_one_if_enough_entries(self): + """Nameserver form submits successfully with 2 valid inputs, even if the first and + second entries are blanked out. + + Uses self.app WebTest because we need to interact with forms. + """ + + # We need to start with a domain with 4 nameservers otherwise the formset in the test environment + # will only have 3 forms + nameserver1 = "" + nameserver2 = "" + nameserver3 = "ns3.igorville.gov" + nameserver4 = "ns4.igorville.gov" + valid_ip = "" + valid_ip_2 = "" + valid_ip_3 = "" + valid_ip_4 = "" + nameservers_page = self.app.get( + reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id}) + ) + + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Minimal check to ensure the form is loaded correctly + self.assertEqual(nameservers_page.form["form-0-server"].value, "ns1.my-nameserver-1.com") + self.assertEqual(nameservers_page.form["form-3-server"].value, "ns1.explosive-chicken-nuggets.com") + + nameservers_page.form["form-0-server"] = nameserver1 + nameservers_page.form["form-0-ip"] = valid_ip + nameservers_page.form["form-1-server"] = nameserver2 + nameservers_page.form["form-1-ip"] = valid_ip_2 + nameservers_page.form["form-2-server"] = nameserver3 + nameservers_page.form["form-2-ip"] = valid_ip_3 + nameservers_page.form["form-3-server"] = nameserver4 + nameservers_page.form["form-3-ip"] = valid_ip_4 + with less_console_noise(): # swallow log warning message + result = nameservers_page.form.submit() + + # form submission was a successful post, response should be a 302 + self.assertEqual(result.status_code, 302) + self.assertEqual( + result["Location"], + reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id}), + ) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + nameservers_page = result.follow() + self.assertContains(nameservers_page, "The name servers for this domain have been updated") + def test_domain_nameservers_form_invalid(self): """Nameserver form does not submit with invalid data.