Merge main --> nl branch

This commit is contained in:
CocoByte 2024-05-28 11:16:22 -06:00
commit aa34697fb4
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
31 changed files with 626 additions and 229 deletions

View file

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

View file

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

View file

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

View file

@ -23,16 +23,9 @@ jobs:
- name: Compile USWDS assets - name: Compile USWDS assets
working-directory: ./src working-directory: ./src
run: | run: |
docker compose run node bash -c "\ docker compose run node npm install &&
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ docker compose run node npx gulp copyAssets &&
export NVM_DIR=\"\$HOME/.nvm\" && \ docker compose run node npx gulp compile
[ -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"
- name: Collect static assets - name: Collect static assets
working-directory: ./src working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input 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/other_contacts/",
"http://localhost:8080/request/anything_else/", "http://localhost:8080/request/anything_else/",
"http://localhost:8080/request/requirements/", "http://localhost:8080/request/requirements/",
"http://localhost:8080/request/finished/" "http://localhost:8080/request/finished/",
"http://localhost:8080/user-profile/"
] ]
} }

View file

@ -15,7 +15,6 @@ from oic.oic.message import AccessTokenResponse
from oic.utils.authn.client import CLIENT_AUTHN_METHOD from oic.utils.authn.client import CLIENT_AUTHN_METHOD
from oic.utils import keyio from oic.utils import keyio
from . import exceptions as o_e from . import exceptions as o_e
__author__ = "roland" __author__ = "roland"
@ -85,7 +84,6 @@ class Client(oic.Client):
def create_authn_request( def create_authn_request(
self, self,
session, session,
do_step_up_auth=False,
extra_args=None, extra_args=None,
): ):
"""Step 2: Construct a login URL at OP's domain and send the user to it.""" """Step 2: Construct a login URL at OP's domain and send the user to it."""
@ -102,11 +100,10 @@ class Client(oic.Client):
"state": session["state"], "state": session["state"],
"nonce": session["nonce"], "nonce": session["nonce"],
"redirect_uri": self.registration_response["redirect_uris"][0], "redirect_uri": self.registration_response["redirect_uris"][0],
# acr_value may be passed in session if overriding, as in the case
# of step up auth, otherwise get from settings.py
"acr_values": session.get("acr_value") or self.behaviour.get("acr_value"),
} }
if do_step_up_auth:
self._set_args_for_biometric_auth_request(session, request_args)
else:
request_args["acr_values"] = self.behaviour.get("acr_value")
if extra_args is not None: if extra_args is not None:
request_args.update(extra_args) request_args.update(extra_args)
@ -117,35 +114,6 @@ class Client(oic.Client):
logger.debug("request args: %s" % request_args) logger.debug("request args: %s" % request_args)
url, headers = self._prepare_authn_request(request_args, state) # C901 too complex
try:
# create the redirect object
response = HttpResponseRedirect(str(url))
# add headers to the object, if any
if headers:
for key, value in headers.items():
response[key] = value
except Exception as err:
logger.error(err)
logger.error("Failed to create redirect object for %s" % state)
raise o_e.InternalError(locator=state)
return response
def _set_args_for_biometric_auth_request(self, session, request_args):
if "acr_value" in session:
session.pop("acr_value")
request_args["vtr"] = session.get("vtr")
request_args["vtm"] = session.get("vtm")
def _prepare_authn_request(self, request_args, state):
"""
Constructs an authorization request. Then, assembles the url, body, headers, and cis.
Returns the assembled url and associated header information: `(url, headers)`
"""
try: try:
# prepare the request for sending # prepare the request for sending
cis = self.construct_AuthorizationRequest(request_args=request_args) cis = self.construct_AuthorizationRequest(request_args=request_args)
@ -158,7 +126,6 @@ class Client(oic.Client):
method="GET", method="GET",
request_args=request_args, request_args=request_args,
) )
logger.debug("body: %s" % body) logger.debug("body: %s" % body)
logger.debug("URL: %s" % url) logger.debug("URL: %s" % url)
logger.debug("headers: %s" % headers) logger.debug("headers: %s" % headers)
@ -167,7 +134,19 @@ class Client(oic.Client):
logger.error("Failed to prepare request for %s" % state) logger.error("Failed to prepare request for %s" % state)
raise o_e.InternalError(locator=state) raise o_e.InternalError(locator=state)
return (url, headers) try:
# create the redirect object
response = HttpResponseRedirect(str(url))
# add headers to the object, if any
if headers:
for key, value in headers.items():
response[key] = value
except Exception as err:
logger.error(err)
logger.error("Failed to create redirect object for %s" % state)
raise o_e.InternalError(locator=state)
return response
def callback(self, unparsed_response, session): def callback(self, unparsed_response, session):
"""Step 3: Receive OP's response, request an access token, and user info.""" """Step 3: Receive OP's response, request an access token, and user info."""
@ -245,18 +224,9 @@ class Client(oic.Client):
if isinstance(info_response, ErrorResponse): if isinstance(info_response, ErrorResponse):
logger.error("Unable to get user info (%s) for %s" % (info_response.get("error", ""), state)) logger.error("Unable to get user info (%s) for %s" % (info_response.get("error", ""), state))
raise o_e.AuthenticationFailed(locator=state) raise o_e.AuthenticationFailed(locator=state)
info_response_dict = info_response.to_dict()
# Define vtm/vtr information on the user dictionary so we can track this in one location. logger.debug("user info: %s" % info_response)
# If a user has this information, then they are bumped up in terms of verification level. return info_response.to_dict()
if session.get("needs_step_up_auth") is True:
if "ial" in info_response_dict:
info_response_dict.pop("ial")
info_response_dict["vtm"] = session.get("vtm", "")
info_response_dict["vtr"] = session.get("vtr", "")
logger.debug("user info: %s" % info_response_dict)
return info_response_dict
def _request_token(self, state, code, session): def _request_token(self, state, code, session):
"""Request a token from OP to allow us to then request user info.""" """Request a token from OP to allow us to then request user info."""
@ -315,20 +285,14 @@ class Client(oic.Client):
super(Client, self).store_response(resp, info) super(Client, self).store_response(resp, info)
def get_default_acr_value(self): def get_default_acr_value(self):
"""Returns the acr_value from settings. """returns the acr_value from settings
This helper function is called from djangooidc views.""" this helper function is called from djangooidc views"""
return self.behaviour.get("acr_value") return self.behaviour.get("acr_value")
def get_vtm_value(self): def get_step_up_acr_value(self):
"""Returns the vtm value from settings. """returns the step_up_acr_value from settings
This helper function is called from djangooidc views.""" this helper function is called from djangooidc views"""
return self.behaviour.get("vtm") return self.behaviour.get("step_up_acr_value")
def get_vtr_value(self, cleaned=True):
"""Returns the vtr value from settings.
This helper function is called from djangooidc views."""
vtr = self.behaviour.get("vtr")
return json.dumps(vtr) if cleaned else vtr
def __repr__(self): def __repr__(self):
return "Client {} {} {}".format( return "Client {} {} {}".format(

View file

@ -397,31 +397,25 @@ class ViewsTest(TestCase):
"""Invoke login_callback passing it a request when _requires_step_up_auth returns True """Invoke login_callback passing it a request when _requires_step_up_auth returns True
and assert that session is updated and create_authn_request (mock) is called.""" and assert that session is updated and create_authn_request (mock) is called."""
with less_console_noise(): with less_console_noise():
# MOCK
# Configure the mock to return an expected value for get_step_up_acr_value
mock_client.return_value.get_step_up_acr_value.return_value = "step_up_acr_value"
# Create a mock request # Create a mock request
request = self.factory.get("/some-url") request = self.factory.get("/some-url")
request.session = {"acr_value": ""} request.session = {"acr_value": ""}
# Ensure that the CLIENT instance used in login_callback is the mock # Ensure that the CLIENT instance used in login_callback is the mock
# patch _requires_step_up_auth to return True # patch _requires_step_up_auth to return True
with patch("djangooidc.views._requires_step_up_auth", return_value=True), patch( with patch("djangooidc.views._requires_step_up_auth", return_value=True), patch(
"djangooidc.views.CLIENT.create_authn_request" "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock()
) as mock_create_authn_request: ) as mock_create_authn_request:
with patch("djangooidc.views.CLIENT.get_vtm_value") as mock_get_vtm_value, patch( # TEST
"djangooidc.views.CLIENT.get_vtr_value" # test the login callback
) as mock_get_vtr_value: login_callback(request)
mock_get_vtm_value.return_value = "test_vtm"
mock_get_vtr_value.return_value = "test_vtr"
# TEST
# test the login callback
login_callback(request)
# ASSERTIONS # ASSERTIONS
# create_authn_request only gets called when _requires_step_up_auth is True. # create_authn_request only gets called when _requires_step_up_auth is True
# The acr_value should be blank here # and it changes this acr_value in request.session
self.assertEqual(request.session["acr_value"], "") # Assert that acr_value is no longer empty string
self.assertEqual(request.session["vtm"], "test_vtm") self.assertNotEqual(request.session["acr_value"], "")
self.assertEqual(request.session["vtr"], "test_vtr")
# And create_authn_request was called again # And create_authn_request was called again
mock_create_authn_request.assert_called_once() mock_create_authn_request.assert_called_once()

View file

@ -91,21 +91,12 @@ def login_callback(request):
_initialize_client() _initialize_client()
query = parse_qs(request.GET.urlencode()) query = parse_qs(request.GET.urlencode())
userinfo = CLIENT.callback(query, request.session) userinfo = CLIENT.callback(query, request.session)
# test for need for identity verification and if it is satisfied # test for need for identity verification and if it is satisfied
# if not satisfied, redirect user to login requiring biometric auth # if not satisfied, redirect user to login with stepped up acr_value
if _requires_step_up_auth(userinfo):
# Tests for the presence of the vtm/vtr values in the userinfo object. # add acr_value to request.session
# If they are there, then we can set a flag in our session for tracking purposes. request.session["acr_value"] = CLIENT.get_step_up_acr_value()
needs_step_up_auth = _requires_step_up_auth(userinfo) return CLIENT.create_authn_request(request.session)
request.session["needs_step_up_auth"] = needs_step_up_auth
# Return a redirect request to a new auth url that does biometric validation
if needs_step_up_auth:
request.session["vtm"] = CLIENT.get_vtm_value()
request.session["vtr"] = CLIENT.get_vtr_value()
return CLIENT.create_authn_request(request.session, do_step_up_auth=True)
user = authenticate(request=request, **userinfo) user = authenticate(request=request, **userinfo)
if user: if user:
@ -147,27 +138,14 @@ def login_callback(request):
return error_page(request, err) return error_page(request, err)
def _requires_step_up_auth(userinfo) -> bool: def _requires_step_up_auth(userinfo):
""" """if User.needs_identity_verification and step_up_acr_value not in
Checks for the presence of the key 'vtm' and 'vtr' in the provided `userinfo` object. ial returned from callback, return True"""
step_up_acr_value = CLIENT.get_step_up_acr_value()
If they are not found, then we call `User.needs_identity_verification()`. acr_value = userinfo.get("ial", "")
Args:
userinfo (dict): A dictionary of data from the returned user object.
Return Conditions:
If the provided user does not exist in any tables which would preclude them from doing
biometric authentication, then we return True. Otherwise, we return False.
Alternatively, if 'vtm' and 'vtr' already exist on `userinfo`, then we return False.
"""
uuid = userinfo.get("sub", "") uuid = userinfo.get("sub", "")
email = userinfo.get("email", "") email = userinfo.get("email", "")
# This value is returned after successful auth if acr_value != step_up_acr_value:
user_verified = userinfo.get("vot", "")
if not userinfo.get("vtm") or not userinfo.get("vtr") or not user_verified:
# The acr of this attempt is not at the highest level # The acr of this attempt is not at the highest level
# so check if the user needs the higher level # so check if the user needs the higher level
return User.needs_identity_verification(email, uuid) return User.needs_identity_verification(email, uuid)

View file

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

4
src/package-lock.json generated
View file

@ -15,10 +15,6 @@
}, },
"devDependencies": { "devDependencies": {
"@uswds/compile": "^1.0.0-beta.3" "@uswds/compile": "^1.0.0-beta.3"
},
"engines": {
"node": "21.7.3",
"npm": "10.5.0"
} }
}, },
"node_modules/@bufbuild/protobuf": { "node_modules/@bufbuild/protobuf": {

View file

@ -3,11 +3,6 @@
"version": "1.0.0", "version": "1.0.0",
"description": "========================", "description": "========================",
"main": "index.js", "main": "index.js",
"engines": {
"node": "21.7.3",
"npm": "10.5.0"
},
"engineStrict": true,
"scripts": { "scripts": {
"pa11y-ci": "pa11y-ci", "pa11y-ci": "pa11y-ci",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"

View file

@ -1126,7 +1126,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Type of organization", "Type of organization",
{ {
"fields": [ "fields": [
"generic_org_type",
"is_election_board", "is_election_board",
"organization_type", "organization_type",
] ]
@ -1173,7 +1172,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
] ]
# Readonly fields for analysts and superusers # Readonly fields for analysts and superusers
readonly_fields = ("other_contacts", "generic_org_type", "is_election_board", "federal_agency") readonly_fields = ("other_contacts", "is_election_board", "federal_agency")
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [ analyst_readonly_fields = [
@ -1387,7 +1386,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"Type of organization", "Type of organization",
{ {
"fields": [ "fields": [
"generic_org_type",
"is_election_board", "is_election_board",
"organization_type", "organization_type",
] ]
@ -1438,7 +1436,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
) )

View file

@ -46,6 +46,35 @@ body {
margin-top:units(1); 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 { .section--outlined {
background-color: color('white'); background-color: color('white');
border: 1px solid color('base-lighter'); border: 1px solid color('base-lighter');

View file

@ -562,11 +562,7 @@ OIDC_PROVIDERS = {
"scope": ["email", "profile:name", "phone"], "scope": ["email", "profile:name", "phone"],
"user_info_request": ["email", "first_name", "last_name", "phone"], "user_info_request": ["email", "first_name", "last_name", "phone"],
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1", "acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
# "P1" is the current IdV option; "Pb" stands for 'biometric' "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
"vtr": ["Pb", "P1"],
# The url that biometric authentication takes place at.
# A similar analog is the url for acr_value.
"vtm": "https://developer.login.gov/vot-trust-framework",
}, },
"client_registration": { "client_registration": {
"client_id": "cisa_dotgov_registrar", "client_id": "cisa_dotgov_registrar",
@ -584,11 +580,7 @@ OIDC_PROVIDERS = {
"scope": ["email", "profile:name", "phone"], "scope": ["email", "profile:name", "phone"],
"user_info_request": ["email", "first_name", "last_name", "phone"], "user_info_request": ["email", "first_name", "last_name", "phone"],
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1", "acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
# "P1" is the current IdV option; "Pb" stands for 'biometric' "step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
"vtr": ["Pb", "P1"],
# The url that biometric authentication takes place at.
# A similar analog is the url for acr_value.
"vtm": "https://developer.login.gov/vot-trust-framework",
}, },
"client_registration": { "client_registration": {
"client_id": ("urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"), "client_id": ("urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"),

View file

@ -178,6 +178,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(), views.DomainAddUserView.as_view(),
name="domain-users-add", name="domain-users-add",
), ),
path(
"user-profile",
views.UserProfileView.as_view(),
name="user-profile",
),
path( path(
"invitation/<int:pk>/delete", "invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), 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. # 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. # 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" 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 # 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 # DEBUG = False even when these apps have been loaded because settings.DEBUG

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

@ -134,7 +134,7 @@
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %} {% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
{% block banner %} {% block banner %}
<header class="usa-header usa-header-basic"> <header class="usa-header usa-header--basic">
<div class="usa-nav-container"> <div class="usa-nav-container">
<div class="usa-navbar"> <div class="usa-navbar">
{% block logo %} {% block logo %}
@ -147,19 +147,25 @@
<button type="button" class="usa-menu-btn">Menu</button> <button type="button" class="usa-menu-btn">Menu</button>
</div> </div>
{% block usa_nav %} {% 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"> <button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" /> <img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button> </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"> <li class="usa-nav__primary-item">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<span>{{ user.email }}</span> <span class="usa-nav__primary-username">{{ user.email }}</span>
</li> </li>
<li class="usa-nav__primary-item display-flex flex-align-center margin-left-2"> {% if has_profile_feature_flag %}
<span class="text-base"> | </span> <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> <a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
</li>
{% else %} {% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a> <a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %} {% endif %}

View file

@ -59,8 +59,11 @@
{% url 'domain-authorizing-official' pk=domain.id as url %} {% 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 %} {% 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 %} {% 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 %} {% 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 %} {% url 'domain-security-email' pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%} {% if security_email is not None and security_email not in hidden_security_emails%}

View file

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

View file

@ -32,12 +32,12 @@ error messages, if necessary.
{% if sublabel_text %} {% if sublabel_text %}
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1"> <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 %} {% if link_text and link_text in sublabel_text %}
{% with link_index=sublabel_text|find_index:link_text %} {% with link_index=sublabel_text|find_index:link_text %}
{{ sublabel_text|slice:link_index }} {{ 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 %} {# 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 == "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 %} <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 %} {% endwith %}
{% else %} {% else %}
{{ sublabel_text }} {{ sublabel_text }}
@ -76,7 +76,7 @@ error messages, if necessary.
</div> </div>
{% endif %} {% endif %}
{% if widget.attrs.maxlength %} {% if widget.attrs.maxlength and not do_not_show_max_chars %}
<span <span
id="{{ widget.attrs.id }}__message" id="{{ widget.attrs.id }}__message"
class="usa-character-count__message" class="usa-character-count__message"

View file

@ -1,27 +1,75 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %} {% block title %}
Edit your User Profile | Edit your User Profile |
{% endblock title %} {% endblock title %}
{% load static url_helpers %}
{% load field_helpers %}
{% block content %} {% block content %}
<main id="main-content" class="grid-container"> <main id="main-content" class="grid-container">
<form class="usa-form usa-form--large" method="post" enctype="multipart/form-data"> <div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<fieldset class="usa-fieldset"> <a href="{% url 'home' %}" class="breadcrumb__back">
<legend class="usa-legend usa-legend--large">Your profile</legend> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<p> <use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
Required fields are marked with an asterisk (<abbr </svg>
title="required"
class="usa-hint usa-hint--required" <p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
>*</abbr>). Back to manage your domains
</p> </p>
{% for field in profile_form %} </a>
<label class="usa-label" for="id_{{ field.name }}">{{ field.label }}</label> {# messages block is under the back breadcrumb link #}
{{ field }} {% if messages %}
{% endfor %} {% for message in messages %}
</fieldset> <div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<button type="submit" class="usa-button usa-button--big">Save Changes</button> <div class="usa-alert__body">
</form> {{ message }}
</div>
</div>
{% endfor %}
{% 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> </main>
{% endblock content %} {% endblock content %}

View file

@ -2230,7 +2230,6 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"id", "id",
@ -2285,7 +2284,6 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"creator", "creator",
@ -2314,7 +2312,6 @@ class TestDomainRequestAdmin(MockEppLib):
"other_contacts", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"generic_org_type",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
] ]
@ -3173,7 +3170,6 @@ class TestDomainInformationAdmin(TestCase):
expected_fields = [ expected_fields = [
"other_contacts", "other_contacts",
"generic_org_type",
"is_election_board", "is_election_board",
"federal_agency", "federal_agency",
"creator", "creator",

View file

@ -1,11 +1,14 @@
from datetime import date from datetime import date
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model 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 api.tests.common import less_console_noise_decorator
from registrar.models.contact import Contact from registrar.models.contact import Contact
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.draft_domain import DraftDomain from registrar.models.draft_domain import DraftDomain
from registrar.models.public_contact import PublicContact
from registrar.models.user import User from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.views.domain import DomainNameserversView from registrar.views.domain import DomainNameserversView
@ -18,6 +21,7 @@ from registrar.models import (
DomainRequest, DomainRequest,
DomainInformation, DomainInformation,
) )
from waffle.testutils import override_flag
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -505,3 +509,177 @@ class HomeTests(TestWithUser):
with less_console_noise(): with less_console_noise():
response = self.client.get("/request/", follow=True) response = self.client.get("/request/", follow=True)
self.assertEqual(response.status_code, 403) 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

@ -32,8 +32,9 @@ def send_templated_email(
template_name and subject_template_name are relative to the same template template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information context as Django's HTML templates. context gives additional information
that the template may use. 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) template = get_template(template_name)
email_body = template.render(context=context) email_body = template.render(context=context)
@ -48,7 +49,9 @@ def send_templated_email(
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG, config=settings.BOTO_CONFIG,
) )
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
except Exception as exc: 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 raise EmailSendingError("Could not access the SES client.") from exc
destination = {"ToAddresses": [to_address]} destination = {"ToAddresses": [to_address]}

View file

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

View file

@ -59,7 +59,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,6 +102,13 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk")) domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object 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): class DomainFormBaseView(DomainBaseView, FormMixin):
""" """
@ -568,6 +575,10 @@ class DomainYourContactInformationView(DomainFormBaseView):
template_name = "domain_your_contact_information.html" template_name = "domain_your_contact_information.html"
form_class = ContactForm 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): def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.submitter instance to make a bound form.""" """Add domain_info.submitter instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs) form_kwargs = super().get_form_kwargs(*args, **kwargs)
@ -734,7 +745,10 @@ class DomainAddUserView(DomainFormBaseView):
does not make a domain information object does not make a domain information object
email: string- email to send to email: string- email to send to
add_success: bool- default True indicates: 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 # Set a default email address to send to for staff
requestor_email = settings.DEFAULT_FROM_EMAIL requestor_email = settings.DEFAULT_FROM_EMAIL
@ -762,33 +776,43 @@ class DomainAddUserView(DomainFormBaseView):
"requestor_email": requestor_email, "requestor_email": requestor_email,
}, },
) )
except EmailSendingError: except EmailSendingError as exc:
messages.warning(self.request, "Could not send email invitation.")
logger.warn( logger.warn(
"Could not sent email invitation to %s for domain %s", "Could not sent email invitation to %s for domain %s",
email, email,
self.object, self.object,
exc_info=True, exc_info=True,
) )
raise EmailSendingError("Could not send email invitation.") from exc
else: else:
if add_success: if add_success:
messages.success(self.request, f"{email} has been invited to this domain.") messages.success(self.request, f"{email} has been invited to this domain.")
def _make_invitation(self, email_address: str, requestor: User): def _make_invitation(self, email_address: str, requestor: User):
"""Make a Domain invitation for this email and redirect with a message.""" """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) # Check to see if an invite has already been sent (NOTE: we do not want to create an invite just yet.)
if not created: try:
invite = DomainInvitation.objects.get(email=email_address, domain=self.object)
# that invitation already existed # that invitation already existed
messages.warning( if invite is not None:
self.request, messages.warning(
f"{email_address} has already been invited to this domain.", self.request,
) f"{email_address} has already been invited to this domain.",
else: )
self._send_domain_invitation_email(email=email_address, requestor=requestor) 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()) return redirect(self.get_success_url())
def form_valid(self, form): 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"] requested_email = form.cleaned_data["email"]
requestor = self.request.user requestor = self.request.user
# look up a user with that email # look up a user with that email
@ -799,7 +823,22 @@ class DomainAddUserView(DomainFormBaseView):
return self._make_invitation(requested_email, requestor) return self._make_invitation(requested_email, requestor)
else: else:
# if user already exists then just send an email # if user already exists then just send an email
self._send_domain_invitation_email(requested_email, requestor, add_success=False) 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: try:
UserDomainRole.objects.create( UserDomainRole.objects.create(

View file

@ -227,6 +227,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# will NOT be redirected. The purpose of this is to allow code to # will NOT be redirected. The purpose of this is to allow code to
# send users "to the domain request wizard" without needing to # send users "to the domain request wizard" without needing to
# know which view is first in the list of steps. # know which view is first in the list of steps.
context = self.get_context_data()
if self.__class__ == DomainRequestWizard: if self.__class__ == DomainRequestWizard:
if request.path_info == self.NEW_URL_NAME: if request.path_info == self.NEW_URL_NAME:
context = self.get_context_data() context = self.get_context_data()
@ -235,7 +236,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
return self.goto(self.steps.first) return self.goto(self.steps.first)
self.steps.current = current_url self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms() context["forms"] = self.get_forms()
# if pending requests exist and user does not have approved domains, # if pending requests exist and user does not have approved domains,
@ -705,6 +705,13 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView): class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html" 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): class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw """This page will ask user to confirm if they want to withdraw
@ -715,6 +722,13 @@ class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
template_name = "domain_request_withdraw_confirmation.html" 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): class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template # 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 django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None): def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view""" """Used to redirect 500 errors to a custom view"""
if context is None: if context is None:
return render(request, "500.html", status=500) context = {}
else: context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500) return render(request, "500.html", context=context, status=500)
def custom_401_error_view(request, context=None): def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view""" """Used to redirect 401 errors to a custom view"""
if context is None: if context is None:
return render(request, "401.html", status=401) context = {}
else: context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401) 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 False
return True 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 django.views.generic import DetailView, DeleteView, TemplateView
from registrar.models import Domain, DomainRequest, DomainInvitation from registrar.models import Domain, DomainRequest, DomainInvitation
from registrar.models.contact import Contact
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from .mixins import ( from .mixins import (
@ -13,6 +14,7 @@ from .mixins import (
DomainInvitationPermission, DomainInvitationPermission,
DomainRequestWizardPermission, DomainRequestWizardPermission,
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission,
) )
import logging import logging
@ -142,3 +144,22 @@ class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteV
# variable name in template context for the model object # variable name in template context for the model object
context_object_name = "userdomainrole" 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