Merge branch 'main' into dk/1073-admin-multi-selects

This commit is contained in:
David Kennedy 2023-12-05 05:00:00 -05:00
commit 60da0fcd5a
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
23 changed files with 274 additions and 48 deletions

View file

@ -47,9 +47,8 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
env:
DEPLOY_NOW: thanks
ENVIRONMENT: ${{ needs.variables.outputs.environment }}
CF_USERNAME: CF_${{ needs.variables.outputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ needs.variables.outputs.environment }}_PASSWORD
@ -58,7 +57,7 @@ jobs:
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
push_arguments: "-f ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml"
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [variables, deploy]

View file

@ -30,12 +30,10 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
env:
DEPLOY_NOW: thanks
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_STABLE_USERNAME }}
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
cf_org: cisa-dotgov
cf_space: stable
push_arguments: "-f ops/manifests/manifest-stable.yaml"
cf_manifest: "ops/manifests/manifest-stable.yaml"

View file

@ -30,12 +30,10 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
env:
DEPLOY_NOW: thanks
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-dotgov
cf_space: staging
push_arguments: "-f ops/manifests/manifest-staging.yaml"
cf_manifest: "ops/manifests/manifest-staging.yaml"

View file

@ -38,10 +38,10 @@ jobs:
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"

View file

@ -38,28 +38,28 @@ jobs:
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Delete existing data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
- name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
- name: Load fake data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"

View file

@ -8,6 +8,7 @@ from django.test import RequestFactory
from ..views import available, check_domain_available
from .common import less_console_noise
from registrar.tests.common import MockEppLib
from registrar.utility.errors import GenericError, GenericErrorCodes
from unittest.mock import call
from epplibwrapper import (
@ -100,16 +101,25 @@ class AvailableViewTest(MockEppLib):
response = available(request, domain="igorville")
self.assertTrue(json.loads(response.content)["available"])
def test_error_handling(self):
"""Calling with bad strings raises an error."""
def test_bad_string_handling(self):
"""Calling with bad strings returns unavailable."""
bad_string = "blah!;"
request = self.factory.get(API_BASE_PATH + bad_string)
request.user = self.user
response = available(request, domain=bad_string)
self.assertFalse(json.loads(response.content)["available"])
# domain set to raise error returns false for availability
error_domain_available = available(request, "errordomain.gov")
self.assertFalse(json.loads(error_domain_available.content)["available"])
def test_error_handling(self):
"""Error thrown while calling availabilityAPI returns error."""
request = self.factory.get(API_BASE_PATH + "errordomain.gov")
request.user = self.user
# domain set to raise error returns false for availability and error message
error_domain_response = available(request, domain="errordomain.gov")
self.assertFalse(json.loads(error_domain_response.content)["available"])
self.assertEqual(
GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
json.loads(error_domain_response.content)["message"],
)
class AvailableAPITest(MockEppLib):

View file

@ -5,6 +5,7 @@ from django.http import JsonResponse
from django.utils.safestring import mark_safe
from registrar.templatetags.url_helpers import public_site_url
from registrar.utility.errors import GenericError, GenericErrorCodes
import requests
@ -30,7 +31,7 @@ DOMAIN_API_MESSAGES = {
),
"invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).",
"success": "That domain is available!",
"error": "Error finding domain availability.",
"error": GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
}
@ -63,17 +64,14 @@ def check_domain_available(domain):
The given domain is lowercased to match against the domains list. If the
given domain doesn't end with .gov, ".gov" is added when looking for
a match.
a match. If check fails, throws a RegistryError.
"""
Domain = apps.get_model("registrar.Domain")
try:
if domain.endswith(".gov"):
return Domain.available(domain)
else:
# domain search string doesn't end with .gov, add it on here
return Domain.available(domain + ".gov")
except Exception:
return False
@require_http_methods(["GET"])

View file

@ -333,6 +333,14 @@ class WebsiteAdmin(ListHeaderAdmin):
class UserDomainRoleAdmin(ListHeaderAdmin):
"""Custom user domain role admin class."""
class Meta:
"""Contains meta information about this class"""
model = models.UserDomainRole
fields = "__all__"
_meta = Meta()
# Columns
list_display = [
"user",
@ -344,10 +352,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin):
search_fields = [
"user__first_name",
"user__last_name",
"user__email",
"domain__name",
"role",
]
search_help_text = "Search by user, domain, or role."
search_help_text = "Search by firstname, lastname, email, domain, or role."
autocomplete_fields = ["user", "domain"]

View file

@ -399,6 +399,8 @@ class AlternativeDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated
@ -484,6 +486,8 @@ class DotGovDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated

View file

@ -176,6 +176,7 @@ class Command(BaseCommand):
"clienthold": TransitionDomain.StatusChoices.ON_HOLD,
"created": TransitionDomain.StatusChoices.READY,
"ok": TransitionDomain.StatusChoices.READY,
"unknown": TransitionDomain.StatusChoices.UNKNOWN,
}
mapped_status = status_maps.get(status_to_map)
return mapped_status

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2023-12-01 17:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0047_transitiondomain_address_line_transitiondomain_city_and_more"),
]
operations = [
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",
),
),
]

View file

@ -5,6 +5,7 @@ from .utility.time_stamped_model import TimeStampedModel
class StatusChoices(models.TextChoices):
READY = "ready", "Ready"
ON_HOLD = "on hold", "On Hold"
UNKNOWN = "unknown", "Unknown"
class TransitionDomain(TimeStampedModel):

View file

@ -2,6 +2,7 @@ import re
from api.views import check_domain_available
from registrar.utility import errors
from epplibwrapper.errors import RegistryError
class DomainHelper:
@ -29,10 +30,7 @@ class DomainHelper:
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:
if domain == "" and not blank_ok:
raise errors.BlankValueError()
if domain.endswith(".gov"):
domain = domain[:-4]
@ -40,8 +38,11 @@ class DomainHelper:
raise errors.ExtraDotsError()
if not DomainHelper.string_could_be_domain(domain + ".gov"):
raise ValueError()
try:
if not check_domain_available(domain):
raise errors.DomainUnavailableError()
except RegistryError as err:
raise errors.RegistrySystemError() from err
return domain
@classmethod

View file

@ -22,6 +22,14 @@
{% include "includes/form_messages.html" %}
{% endblock %}
{% if pending_requests_message %}
<div class="usa-alert usa-alert--info margin-bottom-3">
<div class="usa-alert__body">
{{ pending_requests_message }}
</div>
</div>
{% endif %}
{% block form_errors %}
{% comment %}
to make sense of this loop, consider that
@ -66,6 +74,13 @@
value="next"
class="usa-button"
>Save and continue</button>
{% elif pending_requests_exist %}
<button
type="submit"
name="submit_button"
value="save_and_return"
class="usa-button usa-button--outline"
>Save and return to manage your domains</button>
{% else %}
<button
type="submit"

View file

@ -40,9 +40,9 @@
>
{% else %}
<div id="enable-dnssec">
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
</div>
</div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a>

View file

@ -15,7 +15,7 @@
<p>Add a name server record by entering the address (e.g., ns1.nameserver.com) in the name server fields below. You must add at least two name servers (13 max).</p>
<div class="usa-alert usa-alert--slim usa-alert--info">
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is “example.gov” and your name server is “ns1.example.gov,” then an IP address is required). Multiple IP addresses must be separated with commas.</p>
<p class="margin-bottom-0">This step is uncommon unless you self-host your DNS or use custom addresses for your nameserver.</p>

View file

@ -2,4 +2,4 @@ Anomaly.gov|muahaha|
TestDomain.gov|ok|
FakeWebsite1.gov|serverHold|
FakeWebsite2.gov|Hold|
FakeWebsite3.gov|ok|
FakeWebsite3.gov|unknown|

View file

@ -13,6 +13,7 @@ from registrar.admin import (
MyUserAdmin,
AuditedAdmin,
ContactAdmin,
UserDomainRoleAdmin,
)
from registrar.models import (
Domain,
@ -21,6 +22,7 @@ from registrar.models import (
User,
DomainInvitation,
)
from registrar.models.user_domain_role import UserDomainRole
from .common import (
completed_application,
generic_domain_object,
@ -886,6 +888,86 @@ class DomainInvitationAdminTest(TestCase):
self.assertContains(response, retrieved_html, count=1)
class UserDomainRoleAdminTest(TestCase):
def setUp(self):
"""Setup environment for a mock admin user"""
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=UserDomainRoleAdmin, admin_site=None)
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles"""
User.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
def test_email_not_in_search(self):
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
Should return no results for an invalid email."""
# Have to get creative to get past linter
p = "adminpass"
self.client.login(username="superuser", password=p)
fake_user = User.objects.create(
username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com"
)
fake_domain = Domain.objects.create(name="test123")
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
# Make the request using the Client class
# which handles CSRF
# Follow=True handles the redirect
response = self.client.get(
"/admin/registrar/userdomainrole/",
{
"q": "testmail@igorville.com",
},
follow=True,
)
# Assert that the query is added to the extra_context
self.assertIn("search_query", response.context)
# Assert the content of filters and search_query
search_query = response.context["search_query"]
self.assertEqual(search_query, "testmail@igorville.com")
# We only need to check for the end of the HTML string
self.assertNotContains(response, "Stewart Jones AntarcticPolarBears@example.com</a></th>")
def test_email_in_search(self):
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
Should return results for an valid email."""
# Have to get creative to get past linter
p = "adminpass"
self.client.login(username="superuser", password=p)
fake_user = User.objects.create(
username="dummyuser", first_name="Joe", last_name="Jones", email="AntarcticPolarBears@example.com"
)
fake_domain = Domain.objects.create(name="fake")
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
# Make the request using the Client class
# which handles CSRF
# Follow=True handles the redirect
response = self.client.get(
"/admin/registrar/userdomainrole/",
{
"q": "AntarcticPolarBears@example.com",
},
follow=True,
)
# Assert that the query is added to the extra_context
self.assertIn("search_query", response.context)
search_query = response.context["search_query"]
self.assertEqual(search_query, "AntarcticPolarBears@example.com")
# We only need to check for the end of the HTML string
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
class ListHeaderAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()

View file

@ -193,6 +193,17 @@ class TestOrganizationMigration(TestCase):
self.assertEqual(transition, expected_transition_domain)
def test_transition_domain_status_unknown(self):
"""
Test that a domain in unknown status can be loaded
""" # noqa - E501 (harder to read)
# == First, parse all existing data == #
self.run_load_domains()
self.run_transfer_domains()
domain_object = Domain.objects.get(name="fakewebsite3.gov")
self.assertEqual(domain_object.state, Domain.State.UNKNOWN)
def test_load_organization_data_domain_information(self):
"""
This test verifies the functionality of the load_organization_data method.

View file

@ -144,6 +144,18 @@ class DomainApplicationTests(TestWithUser, WebTest):
result = page.form.submit()
self.assertIn("What kind of U.S.-based government organization do you represent?", result)
def test_application_multiple_applications_exist(self):
"""Test that an info message appears when user has multiple applications already"""
# create and submit an application
application = completed_application(user=self.user)
application.submit()
application.save()
# now, attempt to create another one
with less_console_noise():
page = self.app.get("/register/").follow()
self.assertContains(page, "You cannot submit this request yet")
@boto3_mocking.patching
def test_application_form_submission(self):
"""

View file

@ -13,6 +13,10 @@ class DomainUnavailableError(ValueError):
pass
class RegistrySystemError(ValueError):
pass
class ActionNotAllowed(Exception):
"""User accessed an action that is not
allowed by the current state"""
@ -42,7 +46,7 @@ class GenericError(Exception):
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
Were experiencing a system connection error. Please wait a few minutes
and try again. If you continue to receive this error after a few tries,
contact help@get.gov
contact help@get.gov.
""",
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
}
@ -56,6 +60,10 @@ contact help@get.gov
def __str__(self):
return f"{self.message}"
@classmethod
def get_error_message(self, code=None):
return self._error_mapping.get(code)
class NameserverErrorCodes(IntEnum):
"""Used in the NameserverError class for

View file

@ -3,6 +3,7 @@ import logging
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.contrib import messages
@ -218,6 +219,23 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms()
# if pending requests exist and user does not have approved domains,
# present message that domain application cannot be submitted
pending_requests = self.pending_requests()
if len(pending_requests) > 0:
message_header = "You cannot submit this request yet"
message_content = (
f"<h4 class='usa-alert__heading'>{message_header}</h4> "
"<p class='margin-bottom-0'>New domain requests cannot be submitted until we have finished "
f"reviewing your pending request: <strong>{pending_requests[0].requested_domain}</strong>. "
"You can continue to fill out this request and save it as a draft to be submitted later. "
f"<a class='usa-link' href='{reverse('home')}'>View your pending requests.</a></p>"
)
context["pending_requests_message"] = mark_safe(message_content) # nosec
context["pending_requests_exist"] = len(pending_requests) > 0
return render(request, self.template_name, context)
def get_all_forms(self, **kwargs) -> list:
@ -266,6 +284,37 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
return instantiated
def pending_requests(self):
"""return an array of pending requests if user has pending requests
and no approved requests"""
if self.approved_applications_exist() or self.approved_domains_exist():
return []
else:
return self.pending_applications()
def approved_applications_exist(self):
"""Checks if user is creator of applications with APPROVED status"""
approved_application_count = DomainApplication.objects.filter(
creator=self.request.user, status=DomainApplication.APPROVED
).count()
return approved_application_count > 0
def approved_domains_exist(self):
"""Checks if user has permissions on approved domains
This additional check is necessary to account for domains which were migrated
and do not have an application"""
return self.request.user.permissions.count() > 0
def pending_applications(self):
"""Returns a List of user's applications with one of the following states:
SUBMITTED, IN_REVIEW, ACTION_NEEDED"""
# if the current application has ACTION_NEEDED status, this check should not be performed
if self.application.status == DomainApplication.ACTION_NEEDED:
return []
check_statuses = [DomainApplication.SUBMITTED, DomainApplication.IN_REVIEW, DomainApplication.ACTION_NEEDED]
return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
def get_context_data(self):
"""Define context for access on all wizard pages."""
return {
@ -328,6 +377,10 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if button == "save":
messages.success(request, "Your progress has been saved!")
return self.goto(self.steps.current)
# if user opted to save progress and return,
# return them to the home page
if button == "save_and_return":
return HttpResponseRedirect(reverse("home"))
# otherwise, proceed as normal
return self.goto_next_step()

View file

@ -64,7 +64,9 @@
10038 OUTOFSCOPE http://app:8080/withdrawconfirmed
10038 OUTOFSCOPE http://app:8080/dns
10038 OUTOFSCOPE http://app:8080/dnssec
10038 OUTOFSCOPE http://app:8080/dns/nameservers
10038 OUTOFSCOPE http://app:8080/dns/dnssec
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers