merge conflict

This commit is contained in:
Rebecca Hsieh 2024-05-28 12:49:41 -07:00
commit 9c112ee4dd
No known key found for this signature in database
33 changed files with 1192 additions and 6177 deletions

View file

@ -22,16 +22,9 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node bash -c "\
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
export NVM_DIR=\"\$HOME/.nvm\" && \
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
nvm install 21.7.3 && \
nvm use 21.7.3 && \
npm install && \
npx gulp copyAssets && \
npx gulp compile"
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input

View file

@ -43,16 +43,9 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node bash -c "\
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
export NVM_DIR=\"\$HOME/.nvm\" && \
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
nvm install 21.7.3 && \
nvm use 21.7.3 && \
npm install && \
npx gulp copyAssets && \
npx gulp compile"
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input

View file

@ -23,16 +23,9 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node bash -c "\
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
export NVM_DIR=\"\$HOME/.nvm\" && \
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
nvm install 21.7.3 && \
nvm use 21.7.3 && \
npm install && \
npx gulp copyAssets && \
npx gulp compile"
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input

View file

@ -23,16 +23,9 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node bash -c "\
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
export NVM_DIR=\"\$HOME/.nvm\" && \
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
[ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
nvm install 21.7.3 && \
nvm use 21.7.3 && \
npm install && \
npx gulp copyAssets && \
npx gulp compile"
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input

View file

@ -19,6 +19,7 @@
"http://localhost:8080/request/other_contacts/",
"http://localhost:8080/request/anything_else/",
"http://localhost:8080/request/requirements/",
"http://localhost:8080/request/finished/"
"http://localhost:8080/request/finished/",
"http://localhost:8080/user-profile/"
]
}

View file

@ -33,4 +33,5 @@ exports.init = uswds.init;
exports.compile = uswds.compile;
exports.watch = uswds.watch;
exports.copyAssets = uswds.copyAssets
exports.updateUswds = uswds.updateUswds

View file

@ -1,5 +1,4 @@
FROM docker.io/cimg/node:current-browsers
FROM node:21.7.3
WORKDIR /app
# Install app dependencies
@ -7,6 +6,4 @@ WORKDIR /app
# where available (npm@5+)
COPY --chown=circleci:circleci package*.json ./
RUN npm install -g npm@10.5.0
RUN npm install

6614
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,11 +3,6 @@
"version": "1.0.0",
"description": "========================",
"main": "index.js",
"engines": {
"node": "21.7.3",
"npm": "10.5.0"
},
"engineStrict": true,
"scripts": {
"pa11y-ci": "pa11y-ci",
"test": "echo \"Error: no test specified\" && exit 1"
@ -15,7 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@uswds/uswds": "^3.3.0",
"@uswds/uswds": "^3.8.0",
"pa11y-ci": "^3.0.1",
"sass": "^1.54.8"
},

View file

@ -594,7 +594,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None,
{"fields": ("username", "password", "status", "verification_type")},
),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}),
(
"Permissions",
{
@ -625,7 +625,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
)
},
),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}),
(
"Permissions",
{
@ -651,7 +651,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
analyst_readonly_fields = [
"Personal Info",
"first_name",
"middle_name",
"last_name",
"title",
"email",
"Permissions",
"is_active",
@ -1124,7 +1126,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Type of organization",
{
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
@ -1171,7 +1172,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts", "generic_org_type", "is_election_board")
readonly_fields = ("other_contacts", "is_election_board", "federal_agency")
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -1385,7 +1386,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Type of organization",
{
"fields": [
"generic_org_type",
"is_election_board",
"organization_type",
]
@ -1436,8 +1436,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"federal_agency",
)
# Read only that we'll leverage for CISA Analysts
@ -1879,7 +1879,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
readonly_fields = ["state", "expiration_date", "first_ready", "deleted"]
readonly_fields = ("state", "expiration_date", "first_ready", "deleted", "federal_agency")
# Table ordering
ordering = ["name"]

View file

@ -46,6 +46,35 @@ body {
margin-top:units(1);
}
.usa-nav__primary-username {
display: inline-block;
padding: units(1) units(2);
max-width: 208px;
overflow: hidden;
text-overflow: ellipsis;
@include at-media(desktop) {
padding: units(2);
max-width: 500px;
}
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 50%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
.section--outlined {
background-color: color('white');
border: 1px solid color('base-lighter');

View file

@ -178,6 +178,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
path(
"user-profile",
views.UserProfileView.as_view(),
name="user-profile",
),
path(
"invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
@ -206,6 +211,7 @@ urlpatterns = [
# Rather than dealing with that, we keep everything centralized in one location.
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG

View file

@ -385,7 +385,6 @@ class DomainOrgNameAddressForm(forms.ModelForm):
# because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
"federal_agency": forms.TextInput,
"organization_name": forms.TextInput,
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,

View file

@ -0,0 +1,63 @@
from django import forms
from registrar.models.contact import Contact
from django.core.validators import MaxLengthValidator
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.utility.domain_helper import DomainHelper
class UserProfileForm(forms.ModelForm):
"""Form for updating user profile."""
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):
"""Override the inerited __init__ method to update the fields."""
super().__init__(*args, **kwargs)
# take off maxlength attribute for the phone number field
# which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
# Define a custom validator for the email field with a custom error message
email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
self.fields["email"].validators.append(email_max_length_validator)
for field_name in self.required:
self.fields[field_name].required = True
# Set custom form label
self.fields["first_name"].label = "First name / given name"
self.fields["middle_name"].label = "Middle name (optional)"
self.fields["last_name"].label = "Last name / family name"
self.fields["title"].label = "Title or role in your organization"
self.fields["email"].label = "Organizational email"
# Set custom error messages
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
self.fields["last_name"].error_messages = {"required": "Enter your last name / family name."}
self.fields["title"].error_messages = {
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
}
self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
DomainHelper.disable_field(self.fields["email"], disable_required=True)

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-05-22 14:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0094_create_groups_v12"),
]
operations = [
migrations.AddField(
model_name="user",
name="middle_name",
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name="user",
name="title",
field=models.CharField(blank=True, null=True, verbose_name="title / role"),
),
]

View file

@ -80,6 +80,17 @@ class User(AbstractUser):
db_index=True,
)
middle_name = models.CharField(
null=True,
blank=True,
)
title = models.CharField(
null=True,
blank=True,
verbose_name="title / role",
)
verification_type = models.CharField(
choices=VerificationTypeChoices.choices,
null=True,

View file

@ -81,7 +81,7 @@
<div class="grid-col-auto">
<img class="usa-banner__header-flag" src="{% static 'img/us_flag_small.png' %}" alt="U.S. flag" />
</div>
<div class="grid-col-fill tablet:grid-col-auto">
<div class="grid-col-fill tablet:grid-col-auto" aria-hidden="true">
<p class="usa-banner__header-text">
An official website of the United States government
</p>
@ -134,7 +134,7 @@
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
{% block banner %}
<header class="usa-header usa-header-basic">
<header class="usa-header usa-header--basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% block logo %}
@ -147,19 +147,25 @@
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation,">
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion display-flex flex-align-center">
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span>{{ user.email }}</span>
<span class="usa-nav__primary-username">{{ user.email }}</span>
</li>
<li class="usa-nav__primary-item display-flex flex-align-center margin-left-2">
<span class="text-base"> | </span>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
<a class="usa-nav-link {% if request.path == user_profile_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
</li>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}

View file

@ -59,8 +59,11 @@
{% url 'domain-authorizing-official' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Authorizing official' value=domain.domain_info.authorizing_official contact='true' edit_link=url editable=domain.is_editable %}
{# Conditionally display profile #}
{% if not has_profile_feature_flag %}
{% url 'domain-your-contact-information' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url editable=domain.is_editable %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}

View file

@ -73,6 +73,8 @@
</a>
</li>
{% if not has_profile_feature_flag %}
{# Conditionally display profile link in main nav #}
<li class="usa-sidenav__item">
{% url 'domain-your-contact-information' pk=domain.id as url %}
<a href="{{ url }}"
@ -81,6 +83,7 @@
Your contact information
</a>
</li>
{% endif %}
<li class="usa-sidenav__item">
{% url 'domain-security-email' pk=domain.id as url %}

View file

@ -93,7 +93,7 @@
</li>
<li class="usa-identifier__required-links-item">
<a rel="noopener noreferrer" target="_blank" href="https://www.dhs.gov/accessibility" class="usa-identifier__required-link usa-link usa-link--external"
>Accessibility</a
>Accessibility statement</a
>
</li>
<li class="usa-identifier__required-links-item">

View file

@ -32,12 +32,12 @@ error messages, if necessary.
{% if sublabel_text %}
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1">
{% comment %} If the link_text appears more than once, the first instance will be a link and the other instances will be ignored {% endcomment %}
{# If the link_text appears more than once, the first instance will be a link and the other instances will be ignored #}
{% if link_text and link_text in sublabel_text %}
{% with link_index=sublabel_text|find_index:link_text %}
{{ sublabel_text|slice:link_index }}
{% comment %} HTML will convert a new line into a space, resulting with a space before the fullstop in case link_text is at the end of sublabel_text, hence the unfortunate line below {% endcomment %}
<a {% if external_link == "true" %}rel="noopener noreferrer" class="usa-link usa-link--external" {% endif %}{% if target_blank == "true" %}target="_blank" {% endif %}href="{{ link_href }}">{{ link_text }}</a>{% with sublabel_part_after=sublabel_text|slice_after:link_text %}{{ sublabel_part_after }}{% endwith %}
{# HTML will convert a new line into a space, resulting with a space before the fullstop in case link_text is at the end of sublabel_text, hence the unfortunate line below #}
<a {% if external_link %}rel="noopener noreferrer" class="usa-link usa-link--external" {% endif %}{% if target_blank == "true" %}target="_blank" {% endif %}href="{{ link_href }}">{{ link_text }}</a>{% with sublabel_part_after=sublabel_text|slice_after:link_text %}{{ sublabel_part_after }}{% endwith %}
{% endwith %}
{% else %}
{{ sublabel_text }}
@ -76,7 +76,7 @@ error messages, if necessary.
</div>
{% endif %}
{% if widget.attrs.maxlength %}
{% if widget.attrs.maxlength and not do_not_show_max_chars %}
<span
id="{{ widget.attrs.id }}__message"
class="usa-character-count__message"

View file

@ -1,26 +1,74 @@
{% extends 'base.html' %}
{% block title %}
Edit your User Profile |
{% endblock title %}
{% load static url_helpers %}
{% load field_helpers %}
{% block content %}
<main id="main-content" class="grid-container">
<form class="usa-form usa-form--large" method="post" enctype="multipart/form-data">
<fieldset class="usa-fieldset">
<legend class="usa-legend usa-legend--large">Your profile</legend>
<p>
Required fields are marked with an asterisk (<abbr
title="required"
class="usa-hint usa-hint--required"
>*</abbr>).
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<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>
</svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
</p>
{% for field in profile_form %}
<label class="usa-label" for="id_{{ field.name }}">{{ field.label }}</label>
{{ field }}
</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 %}
</fieldset>
<button type="submit" class="usa-button usa-button--big">Save Changes</button>
{% endif %}
{% include "includes/form_errors.html" with form=form %}
<h1>Your profile</h1>
<p>We <a href="{% public_site_url 'domains/requirements/#what-.gov-domain-registrants-must-do' %}" target="_blank">require</a> that you maintain accurate contact information. The details you provide will only be used to support the administration of .gov and wont be made public.</p>
<h2>Contact information</h2>
<p>Review the details below and update any required information. Note that editing this information wont affect your Login.gov account information.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% 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 %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% with link_href=login_help_url %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" %}
{% with target_blank=True %}
{% with do_not_show_max_chars=True %}
{% input_with_errors form.email %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %}
{% endwith %}
<button type="submit" class="usa-button">Save</button>
</form>
</main>
{% endblock content %}

View file

@ -2230,8 +2230,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"federal_agency",
"id",
"created_at",
"updated_at",
@ -2284,8 +2284,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"federal_agency",
"creator",
"about_your_organization",
"requested_domain",
@ -2312,8 +2312,8 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts",
"current_websites",
"alternative_domains",
"generic_org_type",
"is_election_board",
"federal_agency",
]
self.assertEqual(readonly_fields, expected_fields)
@ -3170,8 +3170,8 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [
"other_contacts",
"generic_org_type",
"is_election_board",
"federal_agency",
"creator",
"type_of_work",
"more_organization_information",
@ -3534,7 +3534,7 @@ class TestMyUserAdmin(TestCase):
)
},
),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}),
("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)

View file

@ -1,11 +1,14 @@
from datetime import date
from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model
from django_webtest import WebTest # type: ignore
from django.conf import settings
from api.tests.common import less_console_noise_decorator
from registrar.models.contact import Contact
from registrar.models.domain import Domain
from registrar.models.draft_domain import DraftDomain
from registrar.models.public_contact import PublicContact
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from registrar.views.domain import DomainNameserversView
@ -18,6 +21,7 @@ from registrar.models import (
DomainRequest,
DomainInformation,
)
from waffle.testutils import override_flag
import logging
logger = logging.getLogger(__name__)
@ -505,3 +509,177 @@ class HomeTests(TestWithUser):
with less_console_noise():
response = self.client.get("/request/", follow=True)
self.assertEqual(response.status_code, 403)
class UserProfileTests(TestWithUser, WebTest):
"""A series of tests that target your profile functionality"""
def setUp(self):
super().setUp()
self.client.force_login(self.user)
self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
def tearDown(self):
super().tearDown()
PublicContact.objects.filter(domain=self.domain).delete()
self.role.delete()
self.domain.delete()
Contact.objects.all().delete()
DraftDomain.objects.all().delete()
DomainRequest.objects.all().delete()
@less_console_noise_decorator
def error_500_main_nav_with_profile_feature_turned_on(self):
"""test that Your profile is in main nav of 500 error page when profile_feature is on.
Our treatment of 401 and 403 error page handling with that waffle feature is similar, so we
assume that the same test results hold true for 401 and 403."""
with override_flag("profile_feature", active=True):
with self.assertRaises(Exception):
response = self.client.get(reverse("home"))
self.assertEqual(response.status_code, 500)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def error_500_main_nav_with_profile_feature_turned_off(self):
"""test that Your profile is not in main nav of 500 error page when profile_feature is off.
Our treatment of 401 and 403 error page handling with that waffle feature is similar, so we
assume that the same test results hold true for 401 and 403."""
with override_flag("profile_feature", active=False):
with self.assertRaises(Exception):
response = self.client.get(reverse("home"), follow=True)
self.assertEqual(response.status_code, 500)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_home_page_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of home page when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/")
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_home_page_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of home page when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/")
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of new request when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/request/")
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of new request when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/request/")
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of user profile when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/user-profile")
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_returns_404_when_feature_off(self):
"""test that Your profile returns 404 when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/user-profile")
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
def test_domain_detail_profile_feature_on(self):
"""test that domain detail view when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get(reverse("domain", args=[self.domain.pk]))
self.assertContains(response, "Your profile")
self.assertNotContains(response, "Your contact information")
@less_console_noise_decorator
def test_domain_your_contact_information_when_profile_feature_off(self):
"""test that Your contact information is accessible when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information")
self.assertContains(response, "Your contact information")
@less_console_noise_decorator
def test_domain_your_contact_information_when_profile_feature_on(self):
"""test that Your contact information is not accessible when profile feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information")
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
def test_request_when_profile_feature_on(self):
"""test that Your profile is in request page when profile feature is on"""
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user,
submitter=contact_user,
)
with override_flag("profile_feature", active=True):
response = self.client.get(f"/domain-request/{domain_request.id}")
self.assertContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw")
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_request_when_profile_feature_off(self):
"""test that Your profile is not in request page when profile feature is off"""
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
authorizing_official=contact_user,
submitter=contact_user,
)
with override_flag("profile_feature", active=False):
response = self.client.get(f"/domain-request/{domain_request.id}")
self.assertNotContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw")
self.assertNotContains(response, "Your profile")
# cleanup
domain_request.delete()
site.delete()
@less_console_noise_decorator
def test_user_profile_form_submission(self):
"""test user profile form submission"""
self.app.set_user(self.user.username)
with override_flag("profile_feature", active=True):
profile_page = self.app.get(reverse("user-profile"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
profile_form = profile_page.form
profile_page = profile_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# assert that first result contains errors
self.assertContains(profile_page, "Enter your title")
self.assertContains(profile_page, "Enter your phone number")
profile_form = profile_page.form
profile_form["title"] = "sample title"
profile_form["phone"] = "(201) 555-1212"
profile_page = profile_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
profile_page = profile_page.follow()
self.assertEqual(profile_page.status_code, 200)
self.assertContains(profile_page, "Your profile has been updated")

View file

@ -1447,7 +1447,7 @@ class TestDomainOrganization(TestDomainOverview):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["federal_agency"] = "Department of State"
org_name_page.form["federal_agency"] = FederalAgency.objects.filter(agency="Department of State").get().id
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1456,9 +1456,8 @@ class TestDomainOrganization(TestDomainOverview):
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check for the old and new value
self.assertContains(success_result_page, federal_agency.id)
self.assertNotContains(success_result_page, "Department of State")
# Check that the agency has not changed
self.assertEqual(self.domain_information.federal_agency.agency, "AMTRAK")
# Do another check on the form itself
form = success_result_page.forms[0]

View file

@ -32,8 +32,9 @@ def send_templated_email(
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.
Raises EmailSendingError if SES client could not be accessed
"""
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
template = get_template(template_name)
email_body = template.render(context=context)
@ -48,7 +49,9 @@ def send_templated_email(
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG,
)
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
except Exception as exc:
logger.debug("E-mail unable to send! Could not access the SES client.")
raise EmailSendingError("Could not access the SES client.") from exc
destination = {"ToAddresses": [to_address]}

View file

@ -14,5 +14,6 @@ from .domain import (
DomainInvitationDeleteView,
DomainDeleteUserView,
)
from .user_profile import UserProfileView
from .health import *
from .index import *

View file

@ -59,7 +59,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag
logger = logging.getLogger(__name__)
@ -102,6 +102,13 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainFormBaseView(DomainBaseView, FormMixin):
"""
@ -568,6 +575,10 @@ class DomainYourContactInformationView(DomainFormBaseView):
template_name = "domain_your_contact_information.html"
form_class = ContactForm
@waffle_flag("!profile_feature") # type: ignore
def dispatch(self, request, *args, **kwargs): # type: ignore
return super().dispatch(request, *args, **kwargs)
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)
@ -734,7 +745,10 @@ class DomainAddUserView(DomainFormBaseView):
does not make a domain information object
email: string- email to send to
add_success: bool- default True indicates:
adding a success message to the view if the email sending succeeds"""
adding a success message to the view if the email sending succeeds
raises EmailSendingError
"""
# Set a default email address to send to for staff
requestor_email = settings.DEFAULT_FROM_EMAIL
@ -762,33 +776,43 @@ class DomainAddUserView(DomainFormBaseView):
"requestor_email": requestor_email,
},
)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
except EmailSendingError as exc:
logger.warn(
"Could not sent email invitation to %s for domain %s",
email,
self.object,
exc_info=True,
)
raise EmailSendingError("Could not send email invitation.") from exc
else:
if add_success:
messages.success(self.request, f"{email} has been invited to this domain.")
def _make_invitation(self, email_address: str, requestor: User):
"""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:
# Check to see if an invite has already been sent (NOTE: we do not want to create an invite just yet.)
try:
invite = DomainInvitation.objects.get(email=email_address, domain=self.object)
# that invitation already existed
if invite is not None:
messages.warning(
self.request,
f"{email_address} has already been invited to this domain.",
)
else:
except DomainInvitation.DoesNotExist:
# Try to send the invitation. If it succeeds, add it to the DomainInvitation table.
try:
self._send_domain_invitation_email(email=email_address, requestor=requestor)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
else:
# (NOTE: only create a domainInvitation if the e-mail sends correctly)
DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
return redirect(self.get_success_url())
def form_valid(self, form):
"""Add the specified user on this domain."""
"""Add the specified user on this domain.
Throws EmailSendingError."""
requested_email = form.cleaned_data["email"]
requestor = self.request.user
# look up a user with that email
@ -799,7 +823,22 @@ class DomainAddUserView(DomainFormBaseView):
return self._make_invitation(requested_email, requestor)
else:
# if user already exists then just send an email
try:
self._send_domain_invitation_email(requested_email, requestor, add_success=False)
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
try:
UserDomainRole.objects.create(

View file

@ -24,6 +24,8 @@ from .utility import (
from waffle.decorators import flag_is_active
from waffle.decorators import flag_is_active
logger = logging.getLogger(__name__)
@ -227,14 +229,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# will NOT be redirected. The purpose of this is to allow code to
# send users "to the domain request wizard" without needing to
# know which view is first in the list of steps.
context = self.get_context_data()
if self.__class__ == DomainRequestWizard:
if request.path_info == self.NEW_URL_NAME:
return render(request, "domain_request_intro.html")
return render(request, "domain_request_intro.html", context)
else:
return self.goto(self.steps.first)
self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms()
# if pending requests exist and user does not have approved domains,
@ -714,6 +716,13 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw
@ -724,6 +733,13 @@ class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
template_name = "domain_request_withdraw_confirmation.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template

View file

@ -0,0 +1,77 @@
"""Views for a User Profile.
"""
import logging
from django.contrib import messages
from django.views.generic.edit import FormMixin
from registrar.forms.user_profile import UserProfileForm
from django.urls import reverse
from registrar.models import (
Contact,
)
from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag
logger = logging.getLogger(__name__)
class UserProfileView(UserProfilePermissionView, FormMixin):
"""
Base View for the User Profile. Handles getting and setting the User Profile
"""
model = Contact
template_name = "profile.html"
form_class = UserProfileForm
def get(self, request, *args, **kwargs):
"""Handle get requests by getting user's contact object and setting object
and form to context before rendering."""
self.object = self.get_object()
form = self.form_class(instance=self.object)
context = self.get_context_data(object=self.object, form=form)
return self.render_to_response(context)
@waffle_flag("profile_feature") # type: ignore
def dispatch(self, request, *args, **kwargs): # type: ignore
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
def get_success_url(self):
"""Redirect to the user's profile page."""
return reverse("user-profile")
def post(self, request, *args, **kwargs):
"""Handle post requests (form submissions)"""
self.object = self.get_object()
form = self.form_class(request.POST, instance=self.object)
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""Handle successful and valid form submissions."""
form.save()
messages.success(self.request, "Your profile has been updated.")
# superclass has the redirect
return super().form_valid(form)
def get_object(self, queryset=None):
"""Override get_object to return the logged-in user's contact"""
user = self.request.user # get the logged in user
if hasattr(user, "contact"): # Check if the user has a contact instance
return user.contact
return None

View file

@ -14,19 +14,28 @@ Rather than dealing with that, we keep everything centralized in one location.
"""
from django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view"""
if context is None:
return render(request, "500.html", status=500)
else:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500)
def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view"""
if context is None:
return render(request, "401.html", status=401)
else:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401)
def custom_403_error_view(request, exception=None, context=None):
"""Used to redirect 403 errors to a custom view"""
if context is None:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "403.html", context=context, status=403)

View file

@ -382,3 +382,18 @@ class DomainInvitationPermission(PermissionsLoginMixin):
return False
return True
class UserProfilePermission(PermissionsLoginMixin):
"""Permission mixin that redirects to user profile if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access.
If the user is authenticated, they have access
"""
if not self.request.user.is_authenticated:
return False
return True

View file

@ -4,6 +4,7 @@ import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView
from registrar.models import Domain, DomainRequest, DomainInvitation
from registrar.models.contact import Contact
from registrar.models.user_domain_role import UserDomainRole
from .mixins import (
@ -13,6 +14,7 @@ from .mixins import (
DomainInvitationPermission,
DomainRequestWizardPermission,
UserDeleteDomainRolePermission,
UserProfilePermission,
)
import logging
@ -142,3 +144,22 @@ class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteV
# variable name in template context for the model object
context_object_name = "userdomainrole"
class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC):
"""Abstract base view for user profile view that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = Contact
# variable name in template context for the model object
context_object_name = "contact"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError