mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-16 17:47:02 +02:00
Merge main
This commit is contained in:
commit
276587c45d
31 changed files with 548 additions and 64 deletions
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
11
src/registrar/assets/img/registrar/dotgov_review_magnify.svg
Normal file
11
src/registrar/assets/img/registrar/dotgov_review_magnify.svg
Normal 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 |
|
@ -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 ---- */
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}."
|
||||
|
|
49
src/requirements.txt
Normal file
49
src/requirements.txt
Normal 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
|
|
@ -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