Merge pull request #575 from cisagov/nmb/nameservers

Form to change nameservers for approved domains
This commit is contained in:
Neil MartinsenBurrell 2023-05-12 13:26:09 -05:00 committed by GitHub
commit b319f8c429
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 266 additions and 5 deletions

View file

@ -230,4 +230,34 @@ function handleValidationClick(e) {
})(); })();
/**
* An IIFE that attaches a click handler for our dynamic nameservers form
*
* Only does something on a single page, but it should be fast enough to run
* it everywhere.
*/
(function prepareForms() {
let serverForm = document.querySelectorAll(".server-form")
let container = document.querySelector("#form-container")
let addButton = document.querySelector("#add-form")
let totalForms = document.querySelector("#id_form-TOTAL_FORMS")
let formNum = serverForm.length-1
addButton.addEventListener('click', addForm)
function addForm(e){
let newForm = serverForm[2].cloneNode(true)
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g')
let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g')
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g')
formNum++
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`)
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`)
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`)
container.insertBefore(newForm, addButton)
newForm.querySelector("input").value = ""
totalForms.setAttribute('value', `${formNum+1}`)
}
})();

View file

@ -78,6 +78,11 @@ urlpatterns = [
), ),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"), path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"), path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:pk>/nameservers",
views.DomainNameserversView.as_view(),
name="domain-nameservers",
),
path( path(
"domain/<int:pk>/users/add", "domain/<int:pk>/users/add",
views.DomainAddUserView.as_view(), views.DomainAddUserView.as_view(),

View file

@ -1,2 +1,2 @@
from .application_wizard import * from .application_wizard import *
from .domain import DomainAddUserForm from .domain import DomainAddUserForm, NameserverFormset

View file

@ -1,6 +1,7 @@
"""Forms for domain management.""" """Forms for domain management."""
from django import forms from django import forms
from django.forms import formset_factory
class DomainAddUserForm(forms.Form): class DomainAddUserForm(forms.Form):
@ -8,3 +9,16 @@ class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain.""" """Form for adding a user to a domain."""
email = forms.EmailField(label="Email") email = forms.EmailField(label="Email")
class DomainNameserverForm(forms.Form):
"""Form for changing nameservers."""
server = forms.CharField(label="Name server")
NameserverFormset = formset_factory(
DomainNameserverForm,
extra=1,
)

View file

@ -1,6 +1,8 @@
import logging import logging
import re import re
from typing import List
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -215,6 +217,24 @@ class Domain(TimeStampedModel):
def __str__(self) -> str: def __str__(self) -> str:
return self.name 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)
@property @property
def roid(self): def roid(self):
return self._get_property("roid") return self._get_property("roid")

View file

@ -4,5 +4,6 @@
{# hint: spacing in the class string matters #} {# hint: spacing in the class string matters #}
class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}" class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}"
{% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %} {% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% if sublabel_text %}aria-describedby="{{ widget.attrs.id }}__sublabel"{% endif %}
{% include "django/forms/widgets/attrs.html" %} {% include "django/forms/widgets/attrs.html" %}
/> />

View file

@ -0,0 +1,52 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Domain name servers | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1>Domain name servers</h1>
<p>Before your domain can be used we'll need information about your domain
name servers.</p>
<p><a class="usa-link" href="{% url "todo" %}">Get help with domain servers.</a></p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<div class="server-form">
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% if forloop.counter <= 2 %}
{% with attr_required=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% input_with_errors form.server %}
{% endif %}
{% endwith %}
</div>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span>
</button>
<button
type="submit"
class="usa-button"
>Save</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -13,7 +13,7 @@
</li> </li>
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'todo' as url %} {% url 'domain-nameservers' pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}
> >

View file

@ -28,6 +28,10 @@ error messages, if necessary.
{% include "django/forms/label.html" %} {% include "django/forms/label.html" %}
{% endif %} {% endif %}
{% if sublabel_text %}
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1">{{ sublabel_text }}</p>
{% endif %}
{% if field.errors %} {% if field.errors %}
<div id="{{ widget.attrs.id }}__error-message"> <div id="{{ widget.attrs.id }}__error-message">
{% for error in field.errors %} {% for error in field.errors %}
@ -71,4 +75,4 @@ error messages, if necessary.
</span> </span>
</div> </div>
{% endif %} {% endif %}

View file

@ -6,3 +6,9 @@ from django.template.defaulttags import register
@register.filter @register.filter
def get_item(dictionary, key): def get_item(dictionary, key):
return dictionary.get(key) return dictionary.get(key)
@register.filter
def concat(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)

View file

@ -1058,6 +1058,11 @@ class TestDomainPermissions(TestWithDomainPermissions):
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
def test_no_domain_role(self): def test_no_domain_role(self):
"""Logged in but no role gets 403 Forbidden.""" """Logged in but no role gets 403 Forbidden."""
self.client.force_login(self.user) self.client.force_login(self.user)
@ -1079,6 +1084,12 @@ class TestDomainPermissions(TestWithDomainPermissions):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
class TestDomainDetail(TestWithDomainPermissions, WebTest): class TestDomainDetail(TestWithDomainPermissions, WebTest):
def setUp(self): def setUp(self):
@ -1222,6 +1233,55 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
home_page = self.app.get(reverse("home")) home_page = self.app.get(reverse("home"))
self.assertContains(home_page, self.domain.name) self.assertContains(home_page, self.domain.name)
def test_domain_nameservers(self):
"""Can load domain's nameservers page."""
page = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Domain name servers")
def test_domain_nameservers_form(self):
"""Can change domain's nameservers.
Uses self.app WebTest because we need to interact with forms.
"""
nameservers_page = self.app.get(
reverse("domain-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)
with less_console_noise(): # swallow log warning message
result = nameservers_page.form.submit()
# form submission was a post, response should be a redirect
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-nameservers", kwargs={"pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
self.assertContains(page, "The name servers for this domain have been updated")
def test_domain_nameservers_form_invalid(self):
"""Can change domain's nameservers.
Uses self.app WebTest because we need to interact with forms.
"""
nameservers_page = self.app.get(
reverse("domain-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)
# first two nameservers are required, so if we empty one out we should
# get a form error
nameservers_page.form["form-0-server"] = ""
with less_console_noise(): # swallow logged warning message
result = nameservers_page.form.submit()
# form submission was a post with an error, response should be a 200
# error text appears twice, once at the top of the page, once around
# the field.
self.assertContains(result, "This field is required", count=2, status_code=200)
class TestApplicationStatus(TestWithUser, WebTest): class TestApplicationStatus(TestWithUser, WebTest):
def setUp(self): def setUp(self):

View file

@ -1,6 +1,7 @@
from .application import * from .application import *
from .domain import ( from .domain import (
DomainView, DomainView,
DomainNameserversView,
DomainUsersView, DomainUsersView,
DomainAddUserView, DomainAddUserView,
DomainInvitationDeleteView, DomainInvitationDeleteView,

View file

@ -12,7 +12,7 @@ from django.views.generic.edit import DeleteView, FormMixin
from registrar.models import Domain, DomainInvitation, User, UserDomainRole from registrar.models import Domain, DomainInvitation, User, UserDomainRole
from ..forms import DomainAddUserForm from ..forms import DomainAddUserForm, NameserverFormset
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission from .utility import DomainPermission
@ -29,6 +29,73 @@ class DomainView(DomainPermission, DetailView):
context_object_name = "domain" context_object_name = "domain"
class DomainNameserversView(DomainPermission, FormMixin, DetailView):
"""Domain nameserver editing view."""
model = Domain
template_name = "domain_nameservers.html"
context_object_name = "domain"
form_class = NameserverFormset
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()]
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-nameservers", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
"""Adjust context from FormMixin for formsets."""
context = super().get_context_data(**kwargs)
# use "formset" instead of "form" for the key
context["formset"] = context.pop("form")
return context
def get_form(self, **kwargs):
"""Override the labels and required fields every time we get a formset."""
formset = super().get_form(**kwargs)
for i, form in enumerate(formset):
form.fields["server"].label += f" {i+1}"
if i < 2:
form.fields["server"].required = True
else:
form.fields["server"].required = False
return formset
def post(self, request, *args, **kwargs):
"""Formset submission posts to this view."""
self.object = self.get_object()
formset = self.get_form()
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
# Set the nameservers from the formset
nameservers = []
for form in formset:
try:
nameservers.append(form.cleaned_data["server"])
except KeyError:
# no server information in this field, skip it
pass
domain = self.get_object()
domain.set_nameservers(nameservers)
messages.success(
self.request, "The name servers for this domain have been updated"
)
# superclass has the redirect
return super().form_valid(formset)
class DomainUsersView(DomainPermission, DetailView): class DomainUsersView(DomainPermission, DetailView):
"""User management page in the domain details.""" """User management page in the domain details."""

View file

@ -51,6 +51,7 @@
10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/) 10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/)
10038 OUTOFSCOPE http://app:8080/users 10038 OUTOFSCOPE http://app:8080/users
10038 OUTOFSCOPE http://app:8080/users/add 10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/nameservers
10038 OUTOFSCOPE http://app:8080/delete 10038 OUTOFSCOPE http://app:8080/delete
10038 OUTOFSCOPE http://app:8080/withdraw 10038 OUTOFSCOPE http://app:8080/withdraw
10038 OUTOFSCOPE http://app:8080/withdrawconfirmed 10038 OUTOFSCOPE http://app:8080/withdrawconfirmed