Merge branch 'main' into nmb/available-domain

This commit is contained in:
Neil Martinsen-Burrell 2022-10-31 11:06:13 -05:00
commit 5d9a469ebd
No known key found for this signature in database
GPG key ID: 6A3C818CC10D0184
18 changed files with 383 additions and 24 deletions

View file

@ -2,6 +2,7 @@
"urls": [
"http://app:8080/",
"http://app:8080/health/",
"http://app:8080/whoami/"
"http://app:8080/whoami/",
"http://app:8080/register/"
]
}

View file

@ -1,4 +1,6 @@
FROM python:3
FROM python:3.10
# Python 3.11 introduces a bug in oic package, fyi - Oct 31, 2022
RUN apt-get update && apt-get install -y postgresql-client

View file

@ -15,6 +15,8 @@ oic = "*"
pyjwkest = "*"
psycopg2-binary = "*"
whitenoise = "*"
django-formtools = "*"
django-widget-tweaks = "*"
cachetools = "*"
requests = "*"
@ -27,4 +29,5 @@ flake8 = "*"
mypy = "*"
types-requests = "*"
django-stubs = "*"
django-webtest = "*"
types-cachetools = "*"

72
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "c26e60781d5da15edf636481c9a18506899f824283e8da44e0edf357d29a62cc"
"sha256": "1b7689dae771eaeee047ef75ed1da344ebc9d40fbb9ade689e9dba885e20ec59"
},
"pipfile-spec": 6,
"requires": {},
@ -214,6 +214,22 @@
"index": "pypi",
"version": "==3.7"
},
"django-formtools": {
"hashes": [
"sha256:deb932be55b1d9419e37dc4d65dfbfeb8d307b71c8c11fd52f159aba5fc0deed",
"sha256:f5f32f62ec8192cd1bc55bd929ca7dff5a5f2addf9027db95a5906ecfaa64836"
],
"index": "pypi",
"version": "==2.4"
},
"django-widget-tweaks": {
"hashes": [
"sha256:9bfc5c705684754a83cc81da328b39ad1b80f32bd0f4340e2a810cbab4b0c00e",
"sha256:fe6b17d5d595c63331f300917980db2afcf71f240ab9341b954aea8f45d25b9a"
],
"index": "pypi",
"version": "==1.4.12"
},
"environs": {
"extras": [
"django"
@ -236,7 +252,7 @@
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'",
"version": "==0.18.2"
},
"gunicorn": {
@ -502,7 +518,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.16.0"
},
"sqlparse": {
@ -555,6 +571,14 @@
"index": "pypi",
"version": "==1.7.4"
},
"beautifulsoup4": {
"hashes": [
"sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30",
"sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"
],
"markers": "python_version >= '3.6'",
"version": "==4.11.1"
},
"black": {
"hashes": [
"sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7",
@ -630,6 +654,14 @@
"markers": "python_version >= '3.6'",
"version": "==0.5.0"
},
"django-webtest": {
"hashes": [
"sha256:c8c32041791cdae468e443097c432c67cf17cad339e1ab88b01a6c4841ee4c74",
"sha256:ef075e98b38fe3836dc533c2924d3e37c6bb3483008c40567115518a0303b1af"
],
"index": "pypi",
"version": "==1.9.10"
},
"flake8": {
"hashes": [
"sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db",
@ -798,7 +830,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.16.0"
},
"smmap": {
@ -809,6 +841,14 @@
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"soupsieve": {
"hashes": [
"sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759",
"sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"
],
"markers": "python_version >= '3.6'",
"version": "==2.3.2.post1"
},
"sqlparse": {
"hashes": [
"sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34",
@ -877,6 +917,30 @@
],
"markers": "python_version >= '3.7'",
"version": "==4.4.0"
},
"waitress": {
"hashes": [
"sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a",
"sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.2"
},
"webob": {
"hashes": [
"sha256:73aae30359291c14fa3b956f8b5ca31960e420c28c1bec002547fb04928cf89b",
"sha256:b64ef5141be559cfade448f044fa45c2260351edcb6a8ef6b7e00c7dcef0c323"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.8.7"
},
"webtest": {
"hashes": [
"sha256:2a001a9efa40d2a7e5d9cd8d1527c75f41814eb6afce2c3d207402547b1e5ead",
"sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb"
],
"markers": "python_version >= '3.6' and python_version < '4'",
"version": "==3.0.0"
}
}
}

View file

@ -84,6 +84,8 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
# application used for integrating with Login.gov
"djangooidc",
# library to simplify form templating
"widget_tweaks",
# let's be sure to install our own application!
"registrar",
# Our internal API application
@ -146,7 +148,6 @@ STATICFILES_DIRS = [
BASE_DIR / "assets",
]
# TODO: decide on template engine and document in ADR
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
@ -168,11 +169,16 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"registrar.context_processors.language_code",
"registrar.context_processors.canonical_path",
"registrar.context_processors.is_demo_site",
],
},
},
]
# IS_DEMO_SITE controls whether or not we show our big red "TEST SITE" banner
# underneath the "this is a real government website" banner.
IS_DEMO_SITE = True
# endregion
# region: Database----------------------------------------------------------###

View file

@ -10,8 +10,14 @@ from django.urls import include, path
from django.views.generic import RedirectView
from registrar.views import health, index, profile, whoami
from registrar.forms import ApplicationWizard
from api.views import available
APPLICATION_URL_NAME = "application_step"
application_wizard = ApplicationWizard.as_view(
url_name=APPLICATION_URL_NAME, done_step_name="finished"
)
urlpatterns = [
path("", index.index, name="home"),
path("whoami/", whoami.whoami, name="whoami"),
@ -19,6 +25,8 @@ urlpatterns = [
path("health/", health.health),
path("edit_profile/", profile.edit_profile, name="edit-profile"),
path("openid/", include("djangooidc.urls")),
path("register/", application_wizard, name="application"),
path("register/<step>/", application_wizard, name=APPLICATION_URL_NAME),
path("available/<domain>", available, name="available"),
]

View file

@ -21,3 +21,13 @@ def canonical_path(request):
template itself, so we do it here and pass the information on.
"""
return {"CANONICAL_PATH": request.build_absolute_uri(request.path)}
def is_demo_site(request):
"""Add a boolean if this is a demo site.
To be able to render or not our "demo site" banner, we need a context
variable for the template that indicates if this banner should or
should not appear.
"""
return {"IS_DEMO_SITE": settings.IS_DEMO_SITE}

View file

@ -0,0 +1,4 @@
from .edit_profile import EditProfileForm
from .application_wizard import ApplicationWizard
__all__ = ["EditProfileForm", "ApplicationWizard"]

View file

@ -0,0 +1,100 @@
"""Forms Wizard for creating a new domain application."""
import logging
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from formtools.wizard.views import NamedUrlSessionWizardView # type: ignore
logger = logging.getLogger(__name__)
class OrganizationForm(forms.Form):
organization_type = forms.ChoiceField(
required=True,
choices=[
("Federal", "Federal: a federal agency"),
("Interstate", "Interstate: an organization of two or more states"),
(
"State_or_Territory",
(
"State or Territory: One of the 50 U.S. states, the District of "
"Columbia, American Samoa, Guam, Northern Mariana Islands, "
"Puerto Rico, or the U.S. Virgin Islands"
),
),
(
"Tribal",
(
"Tribal: a tribal government recognized by the federal or "
"state government"
),
),
("County", "County: a county, parish, or borough"),
("City", "City: a city, town, township, village, etc."),
(
"Special_District",
"Special District: an independent organization within a single state",
),
],
widget=forms.RadioSelect,
)
class ContactForm(forms.Form):
organization_name = forms.CharField(label="Organization Name")
street_address = forms.CharField(label="Street address")
# List of forms in our wizard. Each entry is a tuple of a name and a form
# subclass
FORMS = [
("organization", OrganizationForm),
("contact", ContactForm),
]
# Dict to match up the right template with the right step. Keys here must
# match the first elements of the tuples in FORMS
TEMPLATES = {
"organization": "application_organization.html",
"contact": "application_contact.html",
}
# We need to pass our page titles as context to the templates, indexed
# by the step names
TITLES = {
"organization": "About your organization",
"contact": "Your organization's contact information",
}
class ApplicationWizard(LoginRequiredMixin, NamedUrlSessionWizardView):
"""Multi-page form ("wizard") for new domain applications.
This sets up a sequence of forms that gather information for new
domain applications. Each form in the sequence has its own URL and
the progress through the form is stored in the Django session (thus
"NamedUrlSessionWizardView").
"""
form_list = FORMS
def get_template_names(self):
"""Template for the current step.
The return is a singleton list.
"""
return [TEMPLATES[self.steps.current]]
def get_context_data(self, form, **kwargs):
"""Add title information to the context for all steps."""
context = super().get_context_data(form=form, **kwargs)
context["form_titles"] = TITLES
return context
def done(self, form_list, **kwargs):
logger.info("Application form submitted.")

View file

@ -1,6 +1,6 @@
from django import forms
from .models import UserProfile
from ..models import UserProfile
class EditProfileForm(forms.ModelForm):

View file

@ -0,0 +1,35 @@
<!-- Test page -->
{% extends 'application_form.html' %}
{% load widget_tweaks %}
{% block title %}Apply for a .gov domain - Your organization's contact information{% endblock %}
{% block form_content %}
<h1>Your organization's contact information</h1>
<h2>What is the name and mailing address of your organization?</h2>
<p id="instructions">Enter the name of the organization your represent. Your organization might be part
of a larger entity. If so, enter information about your part of the larger entity.</p>
<p>All fields are required unless they are marked optional.</p>
<form class="usa-form usa-form--large" method="post">
{{ wizard.management_form }}
{% csrf_token %}
{{ wizard.form.organization_name|add_label_class:"usa-label" }}
{{ wizard.form.organization_name|add_class:"usa-input"|attr:"aria-describedby:instructions" }}
<fieldset class="usa-fieldset">
{{ wizard.form.street_address|add_label_class:"usa-label" }}
{{ wizard.form.street_address|add_class:"usa-input" }}
</fieldset>
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" class="usa-button usa-button--base" value="{{ wizard.steps.prev }}">Previous</button>
{% endif %}
<button type="submit" class="usa-button">Submit</button>
</form>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="grid-row">
<div class="grid-col-3">
{% include 'application_sidebar.html' %}
</div>
<div class="grid-col-9">
{% block form_content %}{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,25 @@
<!-- Test page -->
{% extends 'application_form.html' %}
{% load widget_tweaks %}
{% block title %}Apply for a .gov domain - About your organization{% endblock %}
{% block form_content %}
<h1>About your organization</h1>
<form class="usa-form usa-form--large" method="post">
{{ wizard.management_form }}
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend>
<h2> What kind of government organization do you represent?</h2>
</legend>
{{ wizard.form.organization_type|add_class:"usa-radio" }}
</fieldset>
<button type="submit" class="usa-button">Next</button>
</form>
{% endblock %}

View file

@ -0,0 +1,18 @@
<div class="tablet:grid-col-4 margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="Form steps,">
<ul class="usa-sidenav">
{% for this_step in wizard.steps.all %}
<li class="usa-sidenav__item">
{% if forloop.counter <= wizard.steps.step1 %}
<a href="{% url wizard.url_name step=this_step %}"
{% if this_step == wizard.steps.current %}class="usa-current"{% endif%}>
{{ form_titles|get_item:this_step }}
</a>
{% else %}
{{ form_titles|get_item:this_step }}
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
</div>

View file

@ -47,7 +47,7 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
<section class="usa-banner" aria-label="Official government website">
<section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion">
<header class="usa-banner__header">
<div class="usa-banner__inner">
@ -58,17 +58,15 @@
<p class="usa-banner__header-text">
An official website of the United States government
</p>
<p class="usa-banner__header-action" aria-hidden="true">
Heres how you know
</p>
<p class="usa-banner__header-action">Heres how you know</p>
</div>
<button class="usa-accordion__button usa-banner__button" aria-expanded="false"
aria-controls="gov-banner-default-default">
aria-controls="gov-banner-default">
<span class="usa-banner__button-text">Heres how you know</span>
</button>
</div>
</header>
<div class="usa-banner__content usa-accordion__content" id="gov-banner-default-default">
<div class="usa-banner__content usa-accordion__content" id="gov-banner-default">
<div class="grid-row grid-gap-lg">
<div class="usa-banner__guidance tablet:grid-col-6">
<img class="usa-banner__icon usa-media-block__img" src="{% static 'img/icon-dot-gov.svg' %}" role="img"
@ -90,9 +88,9 @@
<strong>lock</strong> (
<span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" width="52" height="64"
viewBox="0 0 52 64" class="usa-banner__lock-image" role="img"
aria-labelledby="banner-lock-title-default banner-lock-description-default" focusable="false">
<title id="banner-lock-title-default">Lock</title>
<desc id="banner-lock-description-default">A locked padlock</desc>
aria-labelledby="banner-lock-description" focusable="false">
<title id="banner-lock-title">Lock</title>
<desc id="banner-lock-description">Locked padlock icon</desc>
<path fill="#000000" fill-rule="evenodd"
d="M26 0c10.493 0 19 8.507 19 19v9h3a4 4 0 0 1 4 4v28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V32a4 4 0 0 1 4-4h3v-9C7 8.507 15.507 0 26 0zm0 8c-5.979 0-10.843 4.77-10.996 10.712L15 19v9h22v-9c0-6.075-4.925-11-11-11z" />
</svg> </span>) or <strong>https://</strong> means youve safely connected to
@ -105,10 +103,25 @@
</div>
</div>
</section>
{% if IS_DEMO_SITE %}
<section
class="usa-site-alert usa-site-alert--emergency usa-site-alert--no-icon"
aria-label="Site alert"
>
<div class="usa-alert">
<div class="usa-alert__body">
<p class="usa-alert__text">
<strong>TEST SITE</strong> - Do not use real personal information. Demo purposes only.
</p>
</div>
</div>
</section>
{% endif %}
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
{% block banner %}
<header class="usa-header usa-header-basic" role="navigation">
<header class="usa-header usa-header-basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% block logo %}
@ -120,10 +133,10 @@
</em>
</div>
{% endblock %}
<button class="usa-menu-btn">Menu</button>
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav>
<nav class="usa-nav" aria-label="Primary navigation,">
<button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button>
@ -142,7 +155,6 @@
</div>
</header>
{% endblock banner %}
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
<div id="wrapper">
{% block messages %}
{% if messages %}
@ -158,7 +170,7 @@
{% block section_nav %}{% endblock %}
<main id="main-content">
<main id="main-content" class="grid-container">
{% block hero %}{% endblock %}
{% block content %}{% endblock %}
</main>

View file

@ -0,0 +1,8 @@
"""Custom template tags to make our lives easier."""
from django.template.defaulttags import register
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)

View file

@ -14,7 +14,7 @@ class MockUserLogin:
args = {
UserModel.USERNAME_FIELD: username,
}
user = UserModel.objects.get_or_create(**args)
user, _ = UserModel.objects.get_or_create(**args)
user.is_staff = True
user.is_superuser = True
user.save()

View file

@ -1,6 +1,9 @@
from django.test import Client, TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django_webtest import WebTest # type: ignore
class TestViews(TestCase):
def setUp(self):
@ -22,8 +25,14 @@ class TestViews(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn("?next=/whoami/", response.headers["Location"])
def test_application_form_not_logged_in(self):
"""Application form not accessible without a logged-in user."""
response = self.client.get("/register/")
self.assertEqual(response.status_code, 302)
self.assertIn("/login?next=/register/", response.headers["Location"])
class LoggedInTests(TestCase):
class TestWithUser(TestCase):
def setUp(self):
username = "test_user"
first_name = "First"
@ -32,6 +41,14 @@ class LoggedInTests(TestCase):
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
def tearDown(self):
self.user.delete()
class LoggedInTests(TestWithUser):
def setUp(self):
super().setUp()
self.client.force_login(self.user)
def test_whoami_page(self):
@ -44,3 +61,36 @@ class LoggedInTests(TestCase):
def test_edit_profile(self):
response = self.client.get("/edit_profile/")
self.assertContains(response, "Display Name")
def test_application_form_view(self):
response = self.client.get("/register/", follow=True)
self.assertContains(response, "About your organization")
class FormTests(TestWithUser, WebTest):
"""Webtests for forms to test filling and submitting."""
# Doesn't work with CSRF checking
# hypothesis is that CSRF_USE_SESSIONS is incompatible with WebTest
csrf_checks = False
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
def test_application_form_empty_submit(self):
# 302 redirect to the first form
page = self.app.get(reverse("application")).follow()
# submitting should get back the same page if the required field is empty
result = page.form.submit()
self.assertIn("About your organization", result)
def test_application_form_organization(self):
# 302 redirect to the first form
page = self.app.get(reverse("application")).follow()
form = page.form
form["organization-organization_type"] = "Federal"
result = page.form.submit().follow()
# Got the next form page
self.assertIn("contact information", result)