mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-27 13:06:30 +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)
|
user, created = UserModel.objects.update_or_create(**args)
|
||||||
if created:
|
if created:
|
||||||
user = self.configure_user(user, **kwargs)
|
user = self.configure_user(user, **kwargs)
|
||||||
|
# run a newly created user's callback for a first-time login
|
||||||
|
user.first_login()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user = UserModel.objects.get_by_natural_key(username)
|
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.User, MyUserAdmin)
|
||||||
|
admin.site.register(models.UserDomainRole, AuditedAdmin)
|
||||||
admin.site.register(models.Contact, AuditedAdmin)
|
admin.site.register(models.Contact, AuditedAdmin)
|
||||||
|
admin.site.register(models.DomainInvitation, AuditedAdmin)
|
||||||
admin.site.register(models.DomainApplication, AuditedAdmin)
|
admin.site.register(models.DomainApplication, AuditedAdmin)
|
||||||
admin.site.register(models.Domain, AuditedAdmin)
|
admin.site.register(models.Domain, AuditedAdmin)
|
||||||
admin.site.register(models.Host, MyHostAdmin)
|
admin.site.register(models.Host, MyHostAdmin)
|
||||||
|
|
|
@ -68,6 +68,11 @@ urlpatterns = [
|
||||||
views.DomainAddUserView.as_view(),
|
views.DomainAddUserView.as_view(),
|
||||||
name="domain-users-add",
|
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",
|
"first_name": "Logan",
|
||||||
"last_name": "",
|
"last_name": "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"username": "2ffe71b0-cea4-4097-8fb6-7a35b901dd70",
|
||||||
|
"first_name": "Neil",
|
||||||
|
"last_name": "Martinsen-Burrell",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -2,20 +2,9 @@
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from registrar.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class DomainAddUserForm(forms.Form):
|
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")
|
||||||
|
|
||||||
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 .domain import Domain
|
||||||
from .host_ip import HostIP
|
from .host_ip import HostIP
|
||||||
from .host import Host
|
from .host import Host
|
||||||
|
from .domain_invitation import DomainInvitation
|
||||||
from .nameserver import Nameserver
|
from .nameserver import Nameserver
|
||||||
from .user_domain_role import UserDomainRole
|
from .user_domain_role import UserDomainRole
|
||||||
from .public_contact import PublicContact
|
from .public_contact import PublicContact
|
||||||
|
@ -15,6 +16,7 @@ __all__ = [
|
||||||
"Contact",
|
"Contact",
|
||||||
"DomainApplication",
|
"DomainApplication",
|
||||||
"Domain",
|
"Domain",
|
||||||
|
"DomainInvitation",
|
||||||
"HostIP",
|
"HostIP",
|
||||||
"Host",
|
"Host",
|
||||||
"Nameserver",
|
"Nameserver",
|
||||||
|
@ -27,6 +29,7 @@ __all__ = [
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
auditlog.register(DomainApplication)
|
auditlog.register(DomainApplication)
|
||||||
auditlog.register(Domain)
|
auditlog.register(Domain)
|
||||||
|
auditlog.register(DomainInvitation)
|
||||||
auditlog.register(HostIP)
|
auditlog.register(HostIP)
|
||||||
auditlog.register(Host)
|
auditlog.register(Host)
|
||||||
auditlog.register(Nameserver)
|
auditlog.register(Nameserver)
|
||||||
|
|
|
@ -281,3 +281,6 @@ class Domain(TimeStampedModel):
|
||||||
|
|
||||||
# ManyToManyField on User creates a "users" member for all of the
|
# ManyToManyField on User creates a "users" member for all of the
|
||||||
# users who have some role on this domain
|
# 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:
|
try:
|
||||||
send_templated_email(
|
send_templated_email(
|
||||||
"emails/submission_confirmation.txt",
|
"emails/submission_confirmation.txt",
|
||||||
|
"emails/submission_confirmation_subject.txt",
|
||||||
self.submitter.email,
|
self.submitter.email,
|
||||||
context={"id": self.id, "domain_name": self.requested_domain.name},
|
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.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from .domain_invitation import DomainInvitation
|
||||||
|
|
||||||
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
"""
|
"""
|
||||||
A custom user model that performs identically to the default user model
|
A custom user model that performs identically to the default user model
|
||||||
|
@ -31,3 +38,23 @@ class User(AbstractUser):
|
||||||
return self.email
|
return self.email
|
||||||
else:
|
else:
|
||||||
return self.username
|
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 title %}Add another user{% endblock %}
|
||||||
|
|
||||||
{% block domain_content %}
|
{% 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>
|
<h1>Add another user</h1>
|
||||||
|
|
||||||
<p>You can add another user to help manage your domain. They will need to sign
|
<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">
|
<div class="grid-col-9">
|
||||||
<main id="main-content" class="grid-container">
|
<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">
|
<a href="{% url 'home' %}" class="breadcrumb__back">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
||||||
|
@ -38,6 +29,17 @@
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</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 %}
|
{% block domain_content %}
|
||||||
|
|
||||||
<h1 class="break-word">{{ domain.name }}</h1>
|
<h1 class="break-word">{{ domain.name }}</h1>
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for permission in domain.permissions.all %}
|
{% for permission in domain.permissions.all %}
|
||||||
<tr>
|
<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 }}
|
{{ permission.user.email }}
|
||||||
</th>
|
</th>
|
||||||
<td data-label="Role">{{ permission.role|title }}</td>
|
<td data-label="Role">{{ permission.role|title }}</td>
|
||||||
|
@ -38,4 +38,34 @@
|
||||||
</svg><span class="margin-left-05">Add another user</span>
|
</svg><span class="margin-left-05">Add another user</span>
|
||||||
</a>
|
</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 #}
|
{% 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,
|
User,
|
||||||
Website,
|
Website,
|
||||||
Domain,
|
Domain,
|
||||||
|
DomainInvitation,
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
)
|
)
|
||||||
from unittest import skip
|
from unittest import skip
|
||||||
|
@ -164,6 +165,47 @@ class TestPermissions(TestCase):
|
||||||
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
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.")
|
@skip("Not implemented yet.")
|
||||||
class TestDomainApplicationLifeCycle(TestCase):
|
class TestDomainApplicationLifeCycle(TestCase):
|
||||||
def test_application_approval(self):
|
def test_application_approval(self):
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from unittest import skip
|
from unittest import skip
|
||||||
|
from unittest.mock import MagicMock, ANY
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
@ -9,7 +10,15 @@ from django_webtest import WebTest # type: ignore
|
||||||
import boto3_mocking # 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 registrar.views.application import ApplicationWizard, Step
|
||||||
|
|
||||||
from .common import less_console_noise
|
from .common import less_console_noise
|
||||||
|
@ -1128,3 +1137,87 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
success_page = success_result.follow()
|
success_page = success_result.follow()
|
||||||
self.assertContains(success_page, "mayor@igorville.gov")
|
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
|
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.
|
"""Send an email built from a template to one email address.
|
||||||
|
|
||||||
template_name is relative to the same template context as Django's HTML
|
template_name and subject_template_name are relative to the same template
|
||||||
templates. context gives additional information that the template may use.
|
context as Django's HTML templates. context gives additional information
|
||||||
|
that the template may use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = get_template(template_name)
|
template = get_template(template_name)
|
||||||
email_body = template.render(context=context)
|
email_body = template.render(context=context)
|
||||||
|
|
||||||
|
subject_template = get_template(subject_template_name)
|
||||||
|
subject = subject_template.render(context=context)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ses_client = boto3.client(
|
ses_client = boto3.client(
|
||||||
"sesv2",
|
"sesv2",
|
||||||
|
@ -40,7 +46,7 @@ def send_templated_email(template_name: str, to_address: str, context={}):
|
||||||
Destination={"ToAddresses": [to_address]},
|
Destination={"ToAddresses": [to_address]},
|
||||||
Content={
|
Content={
|
||||||
"Simple": {
|
"Simple": {
|
||||||
"Subject": {"Data": "Thank you for applying for a .gov domain"},
|
"Subject": {"Data": subject},
|
||||||
"Body": {"Text": {"Data": email_body}},
|
"Body": {"Text": {"Data": email_body}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
from .application import *
|
from .application import *
|
||||||
from .domain import DomainView, DomainUsersView, DomainAddUserView
|
from .domain import (
|
||||||
|
DomainView,
|
||||||
|
DomainUsersView,
|
||||||
|
DomainAddUserView,
|
||||||
|
DomainInvitationDeleteView,
|
||||||
|
)
|
||||||
from .health import *
|
from .health import *
|
||||||
from .index import *
|
from .index import *
|
||||||
from .whoami import *
|
from .whoami import *
|
||||||
|
|
|
@ -1,18 +1,25 @@
|
||||||
"""View for a single Domain."""
|
"""View for a single Domain."""
|
||||||
|
|
||||||
from django import forms
|
import logging
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic import DetailView
|
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
|
from .utility import DomainPermission
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DomainView(DomainPermission, DetailView):
|
class DomainView(DomainPermission, DetailView):
|
||||||
|
|
||||||
"""Domain detail overview page."""
|
"""Domain detail overview page."""
|
||||||
|
@ -31,22 +38,6 @@ class DomainUsersView(DomainPermission, DetailView):
|
||||||
context_object_name = "domain"
|
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):
|
class DomainAddUserView(DomainPermission, FormMixin, DetailView):
|
||||||
|
|
||||||
"""Inside of a domain's user management, a form for adding users.
|
"""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()
|
self.object = self.get_object()
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
# there is a valid email address in the form
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
else:
|
else:
|
||||||
return self.form_invalid(form)
|
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):
|
def form_valid(self, form):
|
||||||
"""Add the specified user on this domain."""
|
"""Add the specified user on this domain."""
|
||||||
requested_email = form.cleaned_data["email"]
|
requested_email = form.cleaned_data["email"]
|
||||||
# look up a user with that email
|
# look up a user with that email
|
||||||
# they should exist because we checked in clean_email
|
try:
|
||||||
requested_user = User.objects.get(email=requested_email)
|
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:
|
try:
|
||||||
UserDomainRole.objects.create(
|
UserDomainRole.objects.create(
|
||||||
|
@ -87,3 +125,14 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
|
||||||
|
|
||||||
messages.success(self.request, f"Added user {requested_email}.")
|
messages.success(self.request, f"Added user {requested_email}.")
|
||||||
return redirect(self.get_success_url())
|
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/(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/delete
|
||||||
# This URL always returns 404, so include it as well.
|
# This URL always returns 404, so include it as well.
|
||||||
10038 OUTOFSCOPE http://app:8080/todo
|
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
|
# 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