Merge main

This commit is contained in:
igorkorenfeld 2023-04-04 17:41:01 -04:00
commit 276587c45d
No known key found for this signature in database
GPG key ID: 826947A4B867F659
31 changed files with 548 additions and 64 deletions

View file

@ -1,8 +1,19 @@
# HOWTO Update Python Dependencies
========================
1. Check the [Pipfile](./src/Pipfile) for pinned dependencies and manually adjust the version numbers
1. Run `cd src`, `docker-compose up -d`, and `docker-compose exec app pipenv update` to perform the upgrade and generate a new [Pipfile.lock](./src/Pipfile.lock)
1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers
1. Run
cd src
docker-compose run app bash -c "pipenv lock && pipenv requirements > requirements.txt"
This will generate a new [Pipfile.lock](../../../src/Pipfile.lock) and create a new [requirements.txt](../../../src/requirements.txt). It will not install anything.
It is necessary to use `bash -c` because `run pipenv requirements` will not recognize that it is running non-interactively and will include garbage formatting characters.
The requirements.txt is used by Cloud.gov. It is needed to work around a bug in the CloudFoundry buildpack version of Pipenv that breaks on installing from a git repository.
1. (optional) Run `docker-compose stop` and `docker-compose build` to build a new image for local development with the updated dependencies.
The reason for de-coupling the `build` and `update` steps is to increase consistency between builds and reduce "it works on my laptop!". Therefore, `build` uses the lock file as-is; dependencies are never updated except by explicit choice.
The reason for de-coupling the `build` and `lock` steps is to increase consistency between builds--a run of `build` will always get exactly the dependencies listed in `Pipfile.lock`, nothing more, nothing less.

View file

@ -8,4 +8,4 @@ COPY Pipfile Pipfile
COPY Pipfile.lock Pipfile.lock
RUN pip install pipenv
RUN pipenv install --system --dev
RUN pipenv sync --system --dev

View file

@ -49,6 +49,8 @@ class OpenIdConnectBackend(ModelBackend):
user, created = UserModel.objects.update_or_create(**args)
if created:
user = self.configure_user(user, **kwargs)
# run a newly created user's callback for a first-time login
user.first_login()
else:
try:
user = UserModel.objects.get_by_natural_key(username)

View file

@ -51,7 +51,9 @@ class MyHostAdmin(AuditedAdmin):
admin.site.register(models.User, MyUserAdmin)
admin.site.register(models.UserDomainRole, AuditedAdmin)
admin.site.register(models.Contact, AuditedAdmin)
admin.site.register(models.DomainInvitation, AuditedAdmin)
admin.site.register(models.DomainApplication, AuditedAdmin)
admin.site.register(models.Domain, AuditedAdmin)
admin.site.register(models.Host, MyHostAdmin)

View file

@ -0,0 +1,11 @@
<svg width="87" height="86" viewBox="0 0 87 86" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="44.8006" cy="42.9707" r="32.2615" transform="rotate(-105 44.8006 42.9707)" fill="#F5F8FA"/>
<mask id="mask0_3468_36471" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="10" y="10" width="58" height="57">
<circle cx="39.1123" cy="38.4999" r="28.5" fill="#002B47"/>
</mask>
<g mask="url(#mask0_3468_36471)">
<path d="M30.6385 47.4559C32.5765 47.4559 34.0205 45.9359 34.0205 43.9599C34.0205 41.9459 32.5765 40.4259 30.6385 40.4259C28.7005 40.4259 27.2565 41.9459 27.2565 43.9599C27.2565 45.9359 28.7005 47.4559 30.6385 47.4559ZM44.5591 54.1739C50.3391 54.1739 53.9771 51.6239 53.9771 48.1219C53.9771 45.0959 51.6991 43.8379 47.5511 43.8379H44.8311C42.9611 43.8379 42.2811 43.4299 42.2811 42.5799C42.2811 42.0019 42.4511 41.6959 42.8931 41.3219C43.6411 41.5599 44.3211 41.6619 44.9331 41.6619C48.6391 41.6619 51.5631 39.8599 51.5631 35.9839C51.5631 35.0999 51.3251 34.2839 51.0191 33.7739H53.7051V30.1359H47.5511C46.8031 29.8639 45.8851 29.7279 44.9331 29.7279C41.2951 29.7279 37.9631 31.7679 37.9631 35.8139C37.9631 37.7859 39.0511 39.3839 40.2411 40.1999V40.3359C39.1531 41.0839 38.3371 42.2739 38.3371 43.4979C38.3371 44.8919 39.0171 45.7759 39.9011 46.3539V46.4899C38.3371 47.3739 37.4871 48.4959 37.4871 49.8899C37.4871 52.8819 40.6151 54.1739 44.5591 54.1739ZM44.9331 38.6359C43.6071 38.6359 42.6211 37.6839 42.6211 35.8139C42.6211 34.0119 43.6071 33.0939 44.9331 33.0939C46.2591 33.0939 47.2451 34.0459 47.2451 35.8139C47.2451 37.6839 46.2591 38.6359 44.9331 38.6359ZM45.3411 51.0459C43.0971 51.0459 41.6011 50.3659 41.6011 49.1079C41.6011 48.4959 41.9071 47.9859 42.5191 47.4419C43.0631 47.5779 43.7431 47.6459 44.8991 47.6459H46.6331C48.2991 47.6459 49.2171 47.9179 49.2171 48.9719C49.2171 50.1279 47.5851 51.0459 45.3411 51.0459ZM63.3475 47.4079C67.5975 47.4079 71.5415 44.1779 71.5415 38.5679C71.5415 32.9579 67.5975 29.7279 63.3475 29.7279C59.0635 29.7279 55.1195 32.9579 55.1195 38.5679C55.1195 44.1779 59.0635 47.4079 63.3475 47.4079ZM63.3475 43.3619C61.2735 43.3619 60.2535 41.4919 60.2535 38.5679C60.2535 35.6439 61.2735 33.7739 63.3475 33.7739C65.3875 33.7739 66.4415 35.6439 66.4415 38.5679C66.4415 41.4919 65.3875 43.3619 63.3475 43.3619ZM78.5753 46.9999H84.3553L89.8633 30.1359H85.0693L82.8593 38.0919C82.4173 39.7579 81.9753 41.5259 81.5673 43.2939H81.4313C80.9893 41.5259 80.5473 39.7579 80.1053 38.0919L77.9293 30.1359H72.8973L78.5753 46.9999Z" fill="#0078AE"/>
</g>
<circle cx="40.8529" cy="40.3051" r="26" transform="rotate(-45 40.8529 40.3051)" stroke="#002B47" stroke-width="5"/>
<path d="M58.5042 61.5901C57.5478 60.579 57.5698 58.9904 58.554 58.0062V58.0062C59.5381 57.0221 61.1268 57 62.1379 57.9564L83.9421 78.582C85.3347 79.8994 85.3654 82.1065 84.0098 83.462V83.462C82.6542 84.8176 80.4472 84.787 79.1297 83.3943L58.5042 61.5901Z" fill="#002B47"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -179,10 +179,6 @@ a.breadcrumb__back {
.review__step {
margin-top: units(3);
&:first-of-type {
margin-top: units(4);
}
}
.review__step hr {
@ -204,6 +200,18 @@ a.breadcrumb__back {
.usa-form .usa-button {
margin-top: units(3);
}
.dotgov-button--green {
background-color: color('success-dark');
&:hover {
background-color: color('success-darker');
}
&:active {
background-color: color('green-80v');
}
}
/** ---- DASHBOARD ---- */

View file

@ -81,6 +81,7 @@ in the form $setting: value,
------------------------------
## Primary color
----------------------------*/
$theme-color-primary-darkest: $dhs-blue-80,
$theme-color-primary-darker: $dhs-blue-70,
$theme-color-primary-dark: $dhs-blue-60,
$theme-color-primary: $dhs-blue,

View file

@ -68,6 +68,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
path(
"invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete",
),
]

View file

@ -39,6 +39,11 @@ class UserFixture:
"first_name": "Logan",
"last_name": "",
},
{
"username": "2ffe71b0-cea4-4097-8fb6-7a35b901dd70",
"first_name": "Neil",
"last_name": "Martinsen-Burrell",
},
]
@classmethod

View file

@ -2,20 +2,9 @@
from django import forms
from registrar.models import User
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
def clean_email(self):
requested_email = self.cleaned_data["email"]
try:
User.objects.get(email=requested_email)
except User.DoesNotExist:
# TODO: send an invitation email to a non-existent user
raise forms.ValidationError("That user does not exist in this system.")
return requested_email

View file

@ -0,0 +1,51 @@
# Generated by Django 4.1.6 on 2023-03-24 16:56
from django.db import migrations, models
import django.db.models.deletion
import django_fsm # type: ignore
class Migration(migrations.Migration):
dependencies = [
("registrar", "0015_remove_domain_owners_userdomainrole_user_domains_and_more"),
]
operations = [
migrations.CreateModel(
name="DomainInvitation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(max_length=254)),
(
"status",
django_fsm.FSMField(
choices=[("sent", "sent"), ("retrieved", "retrieved")],
default="sent",
max_length=50,
protected=True,
),
),
(
"domain",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to="registrar.domain",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -5,6 +5,7 @@ from .domain_application import DomainApplication
from .domain import Domain
from .host_ip import HostIP
from .host import Host
from .domain_invitation import DomainInvitation
from .nameserver import Nameserver
from .user_domain_role import UserDomainRole
from .public_contact import PublicContact
@ -15,6 +16,7 @@ __all__ = [
"Contact",
"DomainApplication",
"Domain",
"DomainInvitation",
"HostIP",
"Host",
"Nameserver",
@ -27,6 +29,7 @@ __all__ = [
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(DomainInvitation)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(Nameserver)

View file

@ -281,3 +281,6 @@ class Domain(TimeStampedModel):
# ManyToManyField on User creates a "users" member for all of the
# users who have some role on this domain
# ForeignKey on DomainInvitation creates an "invitations" member for
# all of the invitations that have been sent for this domain

View file

@ -476,6 +476,7 @@ class DomainApplication(TimeStampedModel):
try:
send_templated_email(
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
self.submitter.email,
context={"id": self.id, "domain_name": self.requested_domain.name},
)

View file

@ -0,0 +1,73 @@
"""People are invited by email to administer domains."""
import logging
from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition # type: ignore
from .utility.time_stamped_model import TimeStampedModel
from .user_domain_role import UserDomainRole
logger = logging.getLogger(__name__)
class DomainInvitation(TimeStampedModel):
INVITED = "invited"
RETRIEVED = "retrieved"
email = models.EmailField(
null=False,
blank=False,
)
domain = models.ForeignKey(
"registrar.Domain",
on_delete=models.CASCADE, # delete domain, then get rid of invitations
null=False,
related_name="invitations",
)
status = FSMField(
choices=[
(INVITED, INVITED),
(RETRIEVED, RETRIEVED),
],
default=INVITED,
protected=True, # can't alter state except through transition methods!
)
def __str__(self):
return f"Invitation for {self.email} on {self.domain} is {self.status}"
@transition(field="status", source=INVITED, target=RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
Raises:
RuntimeError if no matching user can be found.
"""
# get a user with this email address
User = get_user_model()
try:
user = User.objects.get(email=self.email)
except User.DoesNotExist:
# should not happen because a matching user should exist before
# we retrieve this invitation
raise RuntimeError(
"Cannot find the user to retrieve this domain invitation."
)
# and create a role for that user on this domain
_, created = UserDomainRole.objects.get_or_create(
user=user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
if not created:
# something strange happened and this role already existed when
# the invitation was retrieved. Log that this occurred.
logger.warn(
"Invitation %s was retrieved for a role that already exists.", self
)

View file

@ -1,9 +1,16 @@
import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
from .domain_invitation import DomainInvitation
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
logger = logging.getLogger(__name__)
class User(AbstractUser):
"""
A custom user model that performs identically to the default user model
@ -31,3 +38,23 @@ class User(AbstractUser):
return self.email
else:
return self.username
def first_login(self):
"""Callback when the user is authenticated for the very first time.
When a user first arrives on the site, we need to retrieve any domain
invitations that match their email address.
"""
for invitation in DomainInvitation.objects.filter(
email=self.email, status=DomainInvitation.INVITED
):
try:
invitation.retrieve()
invitation.save()
except RuntimeError:
# retrieving should not fail because of a missing user, but
# if it does fail, log the error so a new user can continue
# logging in
logger.warn(
"Failed to retrieve invitation %s", invitation, exc_info=True
)

View file

@ -41,7 +41,9 @@
{% endfor %}
{% endblock %}
{% block form_page_title %}
<h1> {{form_titles|get_item:steps.current}} </h1>
{% endblock %}
{% block form_instructions %}
{% endblock %}
@ -67,7 +69,7 @@
{% else %}
<button
type="submit"
class="usa-button"
class="usa-button usa-button--big dotgov-button--green"
>Submit your domain request</button>
{% endif %}
</div>

View file

@ -5,6 +5,18 @@
{# there are no required fields on this page so don't show this #}
{% endblock %}
{% block form_page_title %}
<span class="display-flex flex-align-start maxw-mobile-lg">
<img
class= "margin-right-105"
src="{%static 'img/registrar/dotgov_review_magnify.svg' %}"
alt=""
width="72"
/>
<h1> Review and submit your domain request </h1>
</span>
{% endblock %}
{% block form_fields %}
{% for step in steps.all|slice:":-1" %}
<section class="review__step">

View file

@ -4,13 +4,6 @@
{% block title %}Add another user{% endblock %}
{% block domain_content %}
<p><a href="{% url "domain-users" pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
Back to user management
</a>
</p>
<h1>Add another user</h1>
<p>You can add another user to help manage your domain. They will need to sign

View file

@ -20,15 +20,6 @@
<div class="grid-col-9">
<main id="main-content" class="grid-container">
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
@ -38,6 +29,17 @@
</p>
</a>
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}
{% block domain_content %}
<h1 class="break-word">{{ domain.name }}</h1>

View file

@ -18,7 +18,7 @@
<tbody>
{% for permission in domain.permissions.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Domain name">
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ permission.user.email }}
</th>
<td data-label="Role">{{ permission.role|title }}</td>
@ -38,4 +38,34 @@
</svg><span class="margin-left-05">Add another user</span>
</a>
{% if domain.invitations.exists %}
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption>
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for invitation in domain.invitations.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ invitation.email }}
</th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td>
<td><form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled" value="Cancel">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,6 @@
You have been invited to manage the domain {{ domain.name }} on get.gov,
the registrar for .gov domain names.
To accept your invitation, go to <{{ domain_url }}>.
You will need to log in with a Login.gov account using this email address.

View file

@ -0,0 +1 @@
You are invited to manage {{ domain.name }} on get.gov

View file

@ -0,0 +1 @@
Thank you for applying for a .gov domain

View file

@ -7,6 +7,7 @@ from registrar.models import (
User,
Website,
Domain,
DomainInvitation,
UserDomainRole,
)
from unittest import skip
@ -164,6 +165,47 @@ class TestPermissions(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
class TestInvitations(TestCase):
"""Test the retrieval of invitations."""
def setUp(self):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.email = "mayor@igorville.gov"
self.invitation, _ = DomainInvitation.objects.get_or_create(
email=self.email, domain=self.domain
)
self.user, _ = User.objects.get_or_create(email=self.email)
# clean out the roles each time
UserDomainRole.objects.all().delete()
def test_retrieval_creates_role(self):
self.invitation.retrieve()
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
def test_retrieve_missing_user_error(self):
# get rid of matching users
User.objects.filter(email=self.email).delete()
with self.assertRaises(RuntimeError):
self.invitation.retrieve()
def test_retrieve_existing_role_no_error(self):
# make the overlapping role
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
# this is not an error but does produce a console warning
with less_console_noise():
self.invitation.retrieve()
self.assertEqual(self.invitation.status, DomainInvitation.RETRIEVED)
def test_retrieve_on_first_login(self):
"""A new user's first_login callback retrieves their invitations."""
self.user.first_login()
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
@skip("Not implemented yet.")
class TestDomainApplicationLifeCycle(TestCase):
def test_application_approval(self):

View file

@ -1,4 +1,5 @@
from unittest import skip
from unittest.mock import MagicMock, ANY
from django.conf import settings
from django.test import Client, TestCase
@ -9,7 +10,15 @@ from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
from registrar.models import DomainApplication, Domain, Contact, Website, UserDomainRole
from registrar.models import (
DomainApplication,
Domain,
DomainInvitation,
Contact,
Website,
UserDomainRole,
User,
)
from registrar.views.application import ApplicationWizard, Step
from .common import less_console_noise
@ -1128,3 +1137,87 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
def test_domain_invitation_created(self):
"""Add user on a nonexistent email creates an invitation.
Adding a non-existent user sends an email as a side-effect, so mock
out the boto3 SES email sending here.
"""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, EMAIL)
self.assertContains(success_page, "Cancel") # link to cancel invitation
self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists())
@boto3_mocking.patching
def test_domain_invitation_email_sent(self):
"""Inviting a non-existent user sends them an email."""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
Destination={"ToAddresses": [EMAIL]},
Content=ANY,
)
def test_domain_invitation_cancel(self):
"""Posting to the delete view deletes an invitation."""
EMAIL = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=EMAIL
)
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
@boto3_mocking.patching
def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard."""
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# user was invited, create them
new_user = User.objects.create(username=EMAIL, email=EMAIL)
# log them in to `self.app`
self.app.set_user(new_user.username)
# and manually call the first login callback
new_user.first_login()
# Now load the home page and make sure our domain appears there
home_page = self.app.get(reverse("home"))
self.assertContains(home_page, self.domain.name)

View file

@ -13,16 +13,22 @@ class EmailSendingError(RuntimeError):
pass
def send_templated_email(template_name: str, to_address: str, context={}):
def send_templated_email(
template_name: str, subject_template_name: str, to_address: str, context={}
):
"""Send an email built from a template to one email address.
template_name is relative to the same template context as Django's HTML
templates. context gives additional information that the template may use.
template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information
that the template may use.
"""
template = get_template(template_name)
email_body = template.render(context=context)
subject_template = get_template(subject_template_name)
subject = subject_template.render(context=context)
try:
ses_client = boto3.client(
"sesv2",
@ -40,7 +46,7 @@ def send_templated_email(template_name: str, to_address: str, context={}):
Destination={"ToAddresses": [to_address]},
Content={
"Simple": {
"Subject": {"Data": "Thank you for applying for a .gov domain"},
"Subject": {"Data": subject},
"Body": {"Text": {"Data": email_body}},
},
},

View file

@ -1,5 +1,10 @@
from .application import *
from .domain import DomainView, DomainUsersView, DomainAddUserView
from .domain import (
DomainView,
DomainUsersView,
DomainAddUserView,
DomainInvitationDeleteView,
)
from .health import *
from .index import *
from .whoami import *

View file

@ -1,18 +1,25 @@
"""View for a single Domain."""
from django import forms
import logging
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import DetailView
from django.views.generic.edit import FormMixin
from django.views.generic.edit import DeleteView, FormMixin
from registrar.models import Domain, User, UserDomainRole
from registrar.models import Domain, DomainInvitation, User, UserDomainRole
from ..forms import DomainAddUserForm
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission
logger = logging.getLogger(__name__)
class DomainView(DomainPermission, DetailView):
"""Domain detail overview page."""
@ -31,22 +38,6 @@ class DomainUsersView(DomainPermission, DetailView):
context_object_name = "domain"
class DomainAddUserForm(DomainPermission, forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
def clean_email(self):
requested_email = self.cleaned_data["email"]
try:
User.objects.get(email=requested_email)
except User.DoesNotExist:
# TODO: send an invitation email to a non-existent user
raise forms.ValidationError("That user does not exist in this system.")
return requested_email
class DomainAddUserView(DomainPermission, FormMixin, DetailView):
"""Inside of a domain's user management, a form for adding users.
@ -66,16 +57,63 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def _domain_abs_url(self):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(
reverse("domain", kwargs={"pk": self.object.id})
)
def _make_invitation(self, email_address):
"""Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(
email=email_address, domain=self.object
)
if not created:
# that invitation already existed
messages.warning(
self.request,
f"{email_address} has already been invited to this domain.",
)
else:
# created a new invitation in the database, so send an email
try:
send_templated_email(
"emails/domain_invitation.txt",
"emails/domain_invitation_subject.txt",
to_address=email_address,
context={
"domain_url": self._domain_abs_url(),
"domain": self.object,
},
)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
logger.warn(
"Could not sent email invitation to %s for domain %s",
email_address,
self.object,
exc_info=True,
)
else:
messages.success(
self.request, f"Invited {email_address} to this domain."
)
return redirect(self.get_success_url())
def form_valid(self, form):
"""Add the specified user on this domain."""
requested_email = form.cleaned_data["email"]
# look up a user with that email
# they should exist because we checked in clean_email
requested_user = User.objects.get(email=requested_email)
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
return self._make_invitation(requested_email)
try:
UserDomainRole.objects.create(
@ -87,3 +125,14 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url())
class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView):
model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView
def get_success_url(self):
return reverse("domain-users", kwargs={"pk": self.object.domain.id})
def get_success_message(self, cleaned_data):
return f"Successfully canceled invitation for {self.object.email}."

49
src/requirements.txt Normal file
View file

@ -0,0 +1,49 @@
-i https://pypi.python.org/simple
asgiref==3.6.0 ; python_version >= '3.7'
boto3==1.26.69
botocore==1.29.69 ; python_version >= '3.7'
cachetools==5.3.0
certifi==2022.12.7 ; python_version >= '3.6'
cfenv==0.5.3
cffi==1.15.1
charset-normalizer==3.0.1 ; python_version >= '3.6'
cryptography==39.0.1 ; python_version >= '3.6'
defusedxml==0.7.1 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
dj-database-url==1.2.0
dj-email-url==1.0.6
django==4.1.6
django-allow-cidr==0.6.0
django-auditlog==2.2.2
django-cache-url==3.4.4
django-csp==3.7
django-fsm==2.8.1
django-phonenumber-field[phonenumberslite]==7.0.2
django-widget-tweaks==1.4.12
environs[django]==9.5.0
faker==17.0.0
furl==2.1.3
future==0.18.3 ; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
gunicorn==20.1.0
idna==3.4 ; python_version >= '3.5'
jmespath==1.0.1 ; python_version >= '3.7'
mako==1.2.4 ; python_version >= '3.7'
markupsafe==2.1.2 ; python_version >= '3.7'
marshmallow==3.19.0 ; python_version >= '3.7'
oic==1.5.0
orderedmultidict==1.0.1
packaging==23.0 ; python_version >= '3.7'
phonenumberslite==8.13.6
psycopg2-binary==2.9.5
pycparser==2.21
pycryptodomex==3.17
pyjwkest==1.4.2
python-dateutil==2.8.2 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
python-dotenv==0.21.1 ; python_version >= '3.7'
requests==2.28.2
s3transfer==0.6.0 ; python_version >= '3.7'
setuptools==67.2.0 ; python_version >= '3.7'
six==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.4.3 ; python_version >= '3.5'
typing-extensions==4.4.0 ; python_version >= '3.7'
urllib3==1.26.14 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
whitenoise==6.3.0

View file

@ -51,6 +51,7 @@
10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/)
10038 OUTOFSCOPE http://app:8080/users
10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/delete
# 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