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 %} -

- - Back to user management - -

Add another user

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 %} +
+
+ {{ message }} +
+
+ {% endfor %} + {% endif %} + {% block domain_content %}

{{ domain.name }}

diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index d242b9c68..f842cff6c 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -18,7 +18,7 @@ {% for permission in domain.permissions.all %} - + {{ permission.user.email }} {{ permission.role|title }} @@ -38,4 +38,34 @@ Add another user + {% if domain.invitations.exists %} +

Invitations

+ + + + + + + + + + + + {% for invitation in domain.invitations.all %} + + + + + + + {% endfor %} + +
Domain invitations
EmailDate createdStatusAction
+ {{ invitation.email }} + {{ invitation.created_at|date }} {{ invitation.status|title }}
+ {% csrf_token %} +
+
+ {% endif %} + {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/emails/domain_invitation.txt b/src/registrar/templates/emails/domain_invitation.txt new file mode 100644 index 000000000..8bfb53933 --- /dev/null +++ b/src/registrar/templates/emails/domain_invitation.txt @@ -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. diff --git a/src/registrar/templates/emails/domain_invitation_subject.txt b/src/registrar/templates/emails/domain_invitation_subject.txt new file mode 100644 index 000000000..60db880de --- /dev/null +++ b/src/registrar/templates/emails/domain_invitation_subject.txt @@ -0,0 +1 @@ +You are invited to manage {{ domain.name }} on get.gov diff --git a/src/registrar/templates/emails/submission_confirmation_subject.txt b/src/registrar/templates/emails/submission_confirmation_subject.txt new file mode 100644 index 000000000..34b958b3a --- /dev/null +++ b/src/registrar/templates/emails/submission_confirmation_subject.txt @@ -0,0 +1 @@ +Thank you for applying for a .gov domain diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 69c80c52d..42b8803c3 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -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): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b044e5b33..7f3dc6429 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -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) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index e6de4c330..6491008cf 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -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}}, }, }, diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index e1ae2cc32..0776f70fe 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -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 * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 150efab81..5c199066f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -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}." diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 000000000..62220bc36 --- /dev/null +++ b/src/requirements.txt @@ -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 diff --git a/src/zap.conf b/src/zap.conf index ba0ef6a89..ad01388a7 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -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