diff --git a/docs/operations/runbooks/update_python_dependencies.md b/docs/operations/runbooks/update_python_dependencies.md
index 984b22407..16475d3db 100644
--- a/docs/operations/runbooks/update_python_dependencies.md
+++ b/docs/operations/runbooks/update_python_dependencies.md
@@ -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.
\ No newline at end of file
+ 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.
\ No newline at end of file
diff --git a/src/Dockerfile b/src/Dockerfile
index e4cc5230e..00832166c 100644
--- a/src/Dockerfile
+++ b/src/Dockerfile
@@ -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
diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py
index ac65c89d4..08ca47d2e 100644
--- a/src/djangooidc/backends.py
+++ b/src/djangooidc/backends.py
@@ -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)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index b2d291ce5..30c8e8b89 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -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)
diff --git a/src/registrar/assets/img/registrar/dotgov_review_magnify.svg b/src/registrar/assets/img/registrar/dotgov_review_magnify.svg
new file mode 100644
index 000000000..7560ebaf9
--- /dev/null
+++ b/src/registrar/assets/img/registrar/dotgov_review_magnify.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss
index 45ab8b008..c77159332 100644
--- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss
+++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss
@@ -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 ---- */
diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss
index ee514518a..ba076d845 100644
--- a/src/registrar/assets/sass/_theme/_uswds-theme.scss
+++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss
@@ -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,
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 0d0ec89f5..8674348c8 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -68,6 +68,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
+ path(
+ "invitation//delete",
+ views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
+ name="invitation-delete",
+ ),
]
diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py
index 50c24df5a..dca110527 100644
--- a/src/registrar/fixtures.py
+++ b/src/registrar/fixtures.py
@@ -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
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index 6a2229961..3d5941eed 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -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
diff --git a/src/registrar/migrations/0016_domaininvitation.py b/src/registrar/migrations/0016_domaininvitation.py
new file mode 100644
index 000000000..f7756ef1d
--- /dev/null
+++ b/src/registrar/migrations/0016_domaininvitation.py
@@ -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,
+ },
+ ),
+ ]
diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py
index 969915969..0fcfeca40 100644
--- a/src/registrar/models/__init__.py
+++ b/src/registrar/models/__init__.py
@@ -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)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 6697e2e64..09b0fd211 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -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
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index 08343e863..526e39798 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -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},
)
diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py
new file mode 100644
index 000000000..7cc2a5432
--- /dev/null
+++ b/src/registrar/models/domain_invitation.py
@@ -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
+ )
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 97f5753b3..4cd8b6c90 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -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
+ )
diff --git a/src/registrar/templates/application_form.html b/src/registrar/templates/application_form.html
index fd86e9b7d..ef2c330a1 100644
--- a/src/registrar/templates/application_form.html
+++ b/src/registrar/templates/application_form.html
@@ -41,7 +41,9 @@
{% endfor %}
{% endblock %}
+{% block form_page_title %}
{{form_titles|get_item:steps.current}}
+{% endblock %}
{% block form_instructions %}
{% endblock %}
@@ -67,7 +69,7 @@
{% else %}
{% endif %}
diff --git a/src/registrar/templates/application_review.html b/src/registrar/templates/application_review.html
index 809de33f0..b9ac97871 100644
--- a/src/registrar/templates/application_review.html
+++ b/src/registrar/templates/application_review.html
@@ -5,6 +5,18 @@
{# there are no required fields on this page so don't show this #}
{% endblock %}
+{% block form_page_title %}
+
+
+
Review and submit your domain request
+
+{% endblock %}
+
{% block form_fields %}
{% for step in steps.all|slice:":-1" %}
diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html
index 1a1c45aa3..87b141570 100644
--- a/src/registrar/templates/domain_add_user.html
+++ b/src/registrar/templates/domain_add_user.html
@@ -4,13 +4,6 @@
{% block title %}Add another user{% endblock %}
{% block domain_content %}
-
You can add another user to help manage your domain. They will need to sign
diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html
index 5a6bf75ae..494491190 100644
--- a/src/registrar/templates/domain_base.html
+++ b/src/registrar/templates/domain_base.html
@@ -20,15 +20,6 @@
- {% if messages %}
- {% for message in messages %}
-
-
- {{ message }}
-
-
- {% endfor %}
- {% endif %}
+ {# messages block is under the back breadcrumb link #}
+ {% if messages %}
+ {% for message in messages %}
+