mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 04:28:39 +02:00
Merge pull request #490 from cisagov/nmb/user-invitations
Invite new users to manage domains
This commit is contained in:
commit
f82cb06ca7
23 changed files with 445 additions and 55 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
51
src/registrar/migrations/0016_domaininvitation.py
Normal file
51
src/registrar/migrations/0016_domaininvitation.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
|
|
73
src/registrar/models/domain_invitation.py
Normal file
73
src/registrar/models/domain_invitation.py
Normal 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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 #}
|
||||
|
|
6
src/registrar/templates/emails/domain_invitation.txt
Normal file
6
src/registrar/templates/emails/domain_invitation.txt
Normal 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.
|
|
@ -0,0 +1 @@
|
|||
You are invited to manage {{ domain.name }} on get.gov
|
|
@ -0,0 +1 @@
|
|||
Thank you for applying for a .gov domain
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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 *
|
||||
|
|
|
@ -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}."
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue