diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 126be0293..a63e10fe1 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -83,6 +83,11 @@ urlpatterns = [ views.DomainNameserversView.as_view(), name="domain-nameservers", ), + path( + "domain//your-contact-information", + views.DomainYourContactInformationView.as_view(), + name="domain-your-contact-information", + ), path( "domain//security-email", views.DomainSecurityEmailView.as_view(), diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 6cfe07818..364740211 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -1,2 +1,7 @@ from .application_wizard import * -from .domain import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm +from .domain import ( + DomainAddUserForm, + NameserverFormset, + DomainSecurityEmailForm, + ContactForm, +) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 83e13b685..3668c3018 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -3,6 +3,10 @@ from django import forms from django.forms import formset_factory +from phonenumber_field.widgets import RegionalPhoneNumberWidget + +from ..models import Contact + class DomainAddUserForm(forms.Form): @@ -29,3 +33,34 @@ class DomainSecurityEmailForm(forms.Form): """Form for adding or editing a security email to a domain.""" security_email = forms.EmailField(label="Security email") + + +class ContactForm(forms.ModelForm): + + """Form for updating contacts.""" + + class Meta: + model = Contact + fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"] + widgets = { + "first_name": forms.TextInput, + "middle_name": forms.TextInput, + "last_name": forms.TextInput, + "title": forms.TextInput, + "email": forms.EmailInput, + "phone": RegionalPhoneNumberWidget, + } + + # the database fields have blank=True so ModelForm doesn't create + # required fields by default. Use this list in __init__ to mark each + # of these fields as required + required = ["first_name", "last_name", "title", "email", "phone"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # take off maxlength attribute for the phone number field + # which interferes with our input_with_errors template tag + self.fields["phone"].widget.attrs.pop("maxlength", None) + + for field_name in self.required: + self.fields[field_name].required = True diff --git a/src/registrar/migrations/0023_alter_contact_first_name_alter_contact_last_name_and_more.py b/src/registrar/migrations/0023_alter_contact_first_name_alter_contact_last_name_and_more.py new file mode 100644 index 000000000..b2259f650 --- /dev/null +++ b/src/registrar/migrations/0023_alter_contact_first_name_alter_contact_last_name_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.1 on 2023-05-31 23:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0022_draftdomain_domainapplication_approved_domain_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="first_name", + field=models.TextField( + blank=True, + db_index=True, + help_text="First name", + null=True, + verbose_name="first name / given name", + ), + ), + migrations.AlterField( + model_name="contact", + name="last_name", + field=models.TextField( + blank=True, + db_index=True, + help_text="Last name", + null=True, + verbose_name="last name / family name", + ), + ), + migrations.AlterField( + model_name="contact", + name="title", + field=models.TextField( + blank=True, + help_text="Title", + null=True, + verbose_name="title or role in your organization", + ), + ), + ] diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index d5d32a7ae..cbfde7a23 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -20,6 +20,7 @@ class Contact(TimeStampedModel): null=True, blank=True, help_text="First name", + verbose_name="first name / given name", db_index=True, ) middle_name = models.TextField( @@ -31,12 +32,14 @@ class Contact(TimeStampedModel): null=True, blank=True, help_text="Last name", + verbose_name="last name / family name", db_index=True, ) title = models.TextField( null=True, blank=True, help_text="Title", + verbose_name="title or role in your organization", ) email = models.TextField( null=True, diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index 46a87ba76..939c925cc 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -40,7 +40,7 @@
  • - {% url 'todo' as url %} + {% url 'domain-your-contact-information' pk=domain.id as url %} diff --git a/src/registrar/templates/domain_your_contact_information.html b/src/registrar/templates/domain_your_contact_information.html new file mode 100644 index 000000000..e18deeb50 --- /dev/null +++ b/src/registrar/templates/domain_your_contact_information.html @@ -0,0 +1,35 @@ +{% extends "domain_base.html" %} +{% load static field_helpers %} + +{% block title %}Domain contact information | {{ domain.name }} | {% endblock %} + +{% block domain_content %} + +

    Domain contact information

    + +

    If you’d like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here won’t affect your Login.gov account information.

    + + {% include "includes/required_fields.html" %} + +
    + {% csrf_token %} + + {% input_with_errors form.first_name %} + + {% input_with_errors form.middle_name %} + + {% input_with_errors form.last_name %} + + {% input_with_errors form.title %} + + {% input_with_errors form.email %} + + {% input_with_errors form.phone %} + + +
    + +{% endblock %} {# domain_content #} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b89575357..28465de02 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -13,6 +13,7 @@ import boto3_mocking # type: ignore from registrar.models import ( DomainApplication, Domain, + DomainInformation, DraftDomain, DomainInvitation, Contact, @@ -1030,12 +1031,16 @@ class TestWithDomainPermissions(TestWithUser): def setUp(self): super().setUp() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") + self.domain_information, _ = DomainInformation.objects.get_or_create( + creator=self.user, domain=self.domain + ) self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN ) def tearDown(self): try: + self.domain_information.delete() if hasattr(self.domain, "contacts"): self.domain.contacts.all().delete() self.domain.delete() @@ -1048,55 +1053,39 @@ class TestWithDomainPermissions(TestWithUser): class TestDomainPermissions(TestWithDomainPermissions): def test_not_logged_in(self): """Not logged in gets a redirect to Login.""" - response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) - self.assertEqual(response.status_code, 302) - - response = self.client.get( - reverse("domain-users", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 302) - - response = self.client.get( - reverse("domain-users-add", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 302) - - response = self.client.get( - reverse("domain-nameservers", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 302) - - response = self.client.get( - reverse("domain-security-email", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 302) + for view_name in [ + "domain", + "domain-users", + "domain-users-add", + "domain-nameservers", + "domain-your-contact-information", + "domain-security-email", + ]: + with self.subTest(view_name=view_name): + response = self.client.get( + reverse(view_name, kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 302) def test_no_domain_role(self): """Logged in but no role gets 403 Forbidden.""" self.client.force_login(self.user) self.role.delete() # user no longer has a role on this domain - with less_console_noise(): - response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) - self.assertEqual(response.status_code, 403) - - with less_console_noise(): - response = self.client.get( - reverse("domain-users", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 403) - - with less_console_noise(): - response = self.client.get( - reverse("domain-users-add", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 403) - - with less_console_noise(): - response = self.client.get( - reverse("domain-nameservers", kwargs={"pk": self.domain.id}) - ) - self.assertEqual(response.status_code, 403) + for view_name in [ + "domain", + "domain-users", + "domain-users-add", + "domain-nameservers", + "domain-your-contact-information", + "domain-security-email", + ]: + with self.subTest(view_name=view_name): + with less_console_noise(): + response = self.client.get( + reverse(view_name, kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 403) with less_console_noise(): response = self.client.get( @@ -1312,6 +1301,23 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest): # the field. self.assertContains(result, "This field is required", count=2, status_code=200) + def test_domain_your_contact_information(self): + """Can load domain's your contact information page.""" + page = self.client.get( + reverse("domain-your-contact-information", kwargs={"pk": self.domain.id}) + ) + self.assertContains(page, "Domain contact information") + + def test_domain_your_contact_information_content(self): + """Your contact information appears on the page.""" + self.domain_information.submitter = Contact(first_name="Testy") + self.domain_information.submitter.save() + self.domain_information.save() + page = self.app.get( + reverse("domain-your-contact-information", kwargs={"pk": self.domain.id}) + ) + self.assertContains(page, "Testy") + def test_domain_security_email(self): """Can load domain's security email page.""" page = self.client.get( diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 820d0295d..758d04d72 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -2,6 +2,7 @@ from .application import * from .domain import ( DomainView, DomainNameserversView, + DomainYourContactInformationView, DomainSecurityEmailView, DomainUsersView, DomainAddUserView, diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 361175295..d101b46b7 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -14,9 +14,18 @@ from django.shortcuts import redirect from django.urls import reverse from django.views.generic.edit import FormMixin -from registrar.models import DomainInvitation, User, UserDomainRole +from registrar.models import ( + DomainInvitation, + User, + UserDomainRole, +) -from ..forms import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm +from ..forms import ( + DomainAddUserForm, + NameserverFormset, + DomainSecurityEmailForm, + ContactForm, +) from ..utility.email import send_templated_email, EmailSendingError from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView @@ -44,7 +53,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin): return [{"server": server} for server in domain.nameservers()] def get_success_url(self): - """Redirect to the overview page for the domain.""" + """Redirect to the nameservers page for the domain.""" return reverse("domain-nameservers", kwargs={"pk": self.object.pk}) def get_context_data(self, **kwargs): @@ -96,6 +105,46 @@ class DomainNameserversView(DomainPermissionView, FormMixin): return super().form_valid(formset) +class DomainYourContactInformationView(DomainPermissionView, FormMixin): + + """Domain your contact information editing view.""" + + template_name = "domain_your_contact_information.html" + form_class = ContactForm + + def get_form_kwargs(self, *args, **kwargs): + """Add domain_info.submitter instance to make a bound form.""" + form_kwargs = super().get_form_kwargs(*args, **kwargs) + form_kwargs["instance"] = self.get_object().domain_info.submitter + return form_kwargs + + def get_success_url(self): + """Redirect to the your contact information for the domain.""" + return reverse("domain-your-contact-information", kwargs={"pk": self.object.pk}) + + def post(self, request, *args, **kwargs): + """Form submission posts to this view.""" + 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 form_valid(self, form): + """The form is valid, call setter in model.""" + + # Post to DB using values from the form + form.save() + + messages.success( + self.request, "Your contact information for this domain has been updated." + ) + # superclass has the redirect + return super().form_valid(form) + + class DomainSecurityEmailView(DomainPermissionView, FormMixin): """Domain security email editing view.""" @@ -111,11 +160,11 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin): return initial def get_success_url(self): - """Redirect to the overview page for the domain.""" + """Redirect to the security email page for the domain.""" return reverse("domain-security-email", kwargs={"pk": self.object.pk}) def post(self, request, *args, **kwargs): - """Formset submission posts to this view.""" + """Form submission posts to this view.""" self.object = self.get_object() form = self.get_form() if form.is_valid(): diff --git a/src/zap.conf b/src/zap.conf index ee92e8a1c..6a5e9bf77 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -52,6 +52,7 @@ 10038 OUTOFSCOPE http://app:8080/users 10038 OUTOFSCOPE http://app:8080/users/add 10038 OUTOFSCOPE http://app:8080/nameservers +10038 OUTOFSCOPE http://app:8080/your-contact-information 10038 OUTOFSCOPE http://app:8080/security-email 10038 OUTOFSCOPE http://app:8080/delete 10038 OUTOFSCOPE http://app:8080/withdraw