mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-18 18:39:21 +02:00
Merge main --> nl branch
This commit is contained in:
commit
aa34697fb4
31 changed files with 626 additions and 229 deletions
13
.github/workflows/deploy-development.yaml
vendored
13
.github/workflows/deploy-development.yaml
vendored
|
@ -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
|
||||
|
|
13
.github/workflows/deploy-sandbox.yaml
vendored
13
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -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
|
||||
|
|
13
.github/workflows/deploy-stable.yaml
vendored
13
.github/workflows/deploy-stable.yaml
vendored
|
@ -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
|
||||
|
|
13
.github/workflows/deploy-staging.yaml
vendored
13
.github/workflows/deploy-staging.yaml
vendored
|
@ -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
|
||||
|
|
|
@ -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/"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -15,7 +15,6 @@ from oic.oic.message import AccessTokenResponse
|
|||
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
|
||||
from oic.utils import keyio
|
||||
|
||||
|
||||
from . import exceptions as o_e
|
||||
|
||||
__author__ = "roland"
|
||||
|
@ -85,7 +84,6 @@ class Client(oic.Client):
|
|||
def create_authn_request(
|
||||
self,
|
||||
session,
|
||||
do_step_up_auth=False,
|
||||
extra_args=None,
|
||||
):
|
||||
"""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"],
|
||||
"nonce": session["nonce"],
|
||||
"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:
|
||||
request_args.update(extra_args)
|
||||
|
@ -117,35 +114,6 @@ class Client(oic.Client):
|
|||
|
||||
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:
|
||||
# prepare the request for sending
|
||||
cis = self.construct_AuthorizationRequest(request_args=request_args)
|
||||
|
@ -158,7 +126,6 @@ class Client(oic.Client):
|
|||
method="GET",
|
||||
request_args=request_args,
|
||||
)
|
||||
|
||||
logger.debug("body: %s" % body)
|
||||
logger.debug("URL: %s" % url)
|
||||
logger.debug("headers: %s" % headers)
|
||||
|
@ -167,7 +134,19 @@ class Client(oic.Client):
|
|||
logger.error("Failed to prepare request for %s" % 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):
|
||||
"""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):
|
||||
logger.error("Unable to get user info (%s) for %s" % (info_response.get("error", ""), 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.
|
||||
# If a user has this information, then they are bumped up in terms of verification level.
|
||||
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
|
||||
logger.debug("user info: %s" % info_response)
|
||||
return info_response.to_dict()
|
||||
|
||||
def _request_token(self, state, code, session):
|
||||
"""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)
|
||||
|
||||
def get_default_acr_value(self):
|
||||
"""Returns the acr_value from settings.
|
||||
This helper function is called from djangooidc views."""
|
||||
"""returns the acr_value from settings
|
||||
this helper function is called from djangooidc views"""
|
||||
return self.behaviour.get("acr_value")
|
||||
|
||||
def get_vtm_value(self):
|
||||
"""Returns the vtm value from settings.
|
||||
This helper function is called from djangooidc views."""
|
||||
return self.behaviour.get("vtm")
|
||||
|
||||
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 get_step_up_acr_value(self):
|
||||
"""returns the step_up_acr_value from settings
|
||||
this helper function is called from djangooidc views"""
|
||||
return self.behaviour.get("step_up_acr_value")
|
||||
|
||||
def __repr__(self):
|
||||
return "Client {} {} {}".format(
|
||||
|
|
|
@ -397,31 +397,25 @@ class ViewsTest(TestCase):
|
|||
"""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."""
|
||||
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
|
||||
request = self.factory.get("/some-url")
|
||||
request.session = {"acr_value": ""}
|
||||
|
||||
# Ensure that the CLIENT instance used in login_callback is the mock
|
||||
# patch _requires_step_up_auth to return True
|
||||
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:
|
||||
with patch("djangooidc.views.CLIENT.get_vtm_value") as mock_get_vtm_value, patch(
|
||||
"djangooidc.views.CLIENT.get_vtr_value"
|
||||
) as mock_get_vtr_value:
|
||||
mock_get_vtm_value.return_value = "test_vtm"
|
||||
mock_get_vtr_value.return_value = "test_vtr"
|
||||
# TEST
|
||||
# test the login callback
|
||||
login_callback(request)
|
||||
|
||||
# TEST
|
||||
# test the login callback
|
||||
login_callback(request)
|
||||
# ASSERTIONS
|
||||
# create_authn_request only gets called when _requires_step_up_auth is True.
|
||||
# The acr_value should be blank here
|
||||
self.assertEqual(request.session["acr_value"], "")
|
||||
self.assertEqual(request.session["vtm"], "test_vtm")
|
||||
self.assertEqual(request.session["vtr"], "test_vtr")
|
||||
|
||||
# create_authn_request only gets called when _requires_step_up_auth is True
|
||||
# and it changes this acr_value in request.session
|
||||
# Assert that acr_value is no longer empty string
|
||||
self.assertNotEqual(request.session["acr_value"], "")
|
||||
# And create_authn_request was called again
|
||||
mock_create_authn_request.assert_called_once()
|
||||
|
||||
|
|
|
@ -91,21 +91,12 @@ def login_callback(request):
|
|||
_initialize_client()
|
||||
query = parse_qs(request.GET.urlencode())
|
||||
userinfo = CLIENT.callback(query, request.session)
|
||||
|
||||
# test for need for identity verification and if it is satisfied
|
||||
# if not satisfied, redirect user to login requiring biometric auth
|
||||
|
||||
# Tests for the presence of the vtm/vtr values in the userinfo object.
|
||||
# If they are there, then we can set a flag in our session for tracking purposes.
|
||||
needs_step_up_auth = _requires_step_up_auth(userinfo)
|
||||
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)
|
||||
|
||||
# if not satisfied, redirect user to login with stepped up acr_value
|
||||
if _requires_step_up_auth(userinfo):
|
||||
# add acr_value to request.session
|
||||
request.session["acr_value"] = CLIENT.get_step_up_acr_value()
|
||||
return CLIENT.create_authn_request(request.session)
|
||||
user = authenticate(request=request, **userinfo)
|
||||
if user:
|
||||
|
||||
|
@ -147,27 +138,14 @@ def login_callback(request):
|
|||
return error_page(request, err)
|
||||
|
||||
|
||||
def _requires_step_up_auth(userinfo) -> bool:
|
||||
"""
|
||||
Checks for the presence of the key 'vtm' and 'vtr' in the provided `userinfo` object.
|
||||
|
||||
If they are not found, then we call `User.needs_identity_verification()`.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
def _requires_step_up_auth(userinfo):
|
||||
"""if User.needs_identity_verification and step_up_acr_value not in
|
||||
ial returned from callback, return True"""
|
||||
step_up_acr_value = CLIENT.get_step_up_acr_value()
|
||||
acr_value = userinfo.get("ial", "")
|
||||
uuid = userinfo.get("sub", "")
|
||||
email = userinfo.get("email", "")
|
||||
# This value is returned after successful auth
|
||||
user_verified = userinfo.get("vot", "")
|
||||
if not userinfo.get("vtm") or not userinfo.get("vtr") or not user_verified:
|
||||
if acr_value != step_up_acr_value:
|
||||
# The acr of this attempt is not at the highest level
|
||||
# so check if the user needs the higher level
|
||||
return User.needs_identity_verification(email, uuid)
|
||||
|
|
|
@ -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
|
4
src/package-lock.json
generated
4
src/package-lock.json
generated
|
@ -15,10 +15,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@uswds/compile": "^1.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "21.7.3",
|
||||
"npm": "10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1126,7 +1126,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"Type of organization",
|
||||
{
|
||||
"fields": [
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"organization_type",
|
||||
]
|
||||
|
@ -1173,7 +1172,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
]
|
||||
|
||||
# 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
|
||||
analyst_readonly_fields = [
|
||||
|
@ -1387,7 +1386,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"Type of organization",
|
||||
{
|
||||
"fields": [
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"organization_type",
|
||||
]
|
||||
|
@ -1438,7 +1436,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"other_contacts",
|
||||
"current_websites",
|
||||
"alternative_domains",
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"federal_agency",
|
||||
)
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -562,11 +562,7 @@ OIDC_PROVIDERS = {
|
|||
"scope": ["email", "profile:name", "phone"],
|
||||
"user_info_request": ["email", "first_name", "last_name", "phone"],
|
||||
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
|
||||
# "P1" is the current IdV option; "Pb" stands for 'biometric'
|
||||
"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",
|
||||
"step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
|
||||
},
|
||||
"client_registration": {
|
||||
"client_id": "cisa_dotgov_registrar",
|
||||
|
@ -584,11 +580,7 @@ OIDC_PROVIDERS = {
|
|||
"scope": ["email", "profile:name", "phone"],
|
||||
"user_info_request": ["email", "first_name", "last_name", "phone"],
|
||||
"acr_value": "http://idmanagement.gov/ns/assurance/ial/1",
|
||||
# "P1" is the current IdV option; "Pb" stands for 'biometric'
|
||||
"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",
|
||||
"step_up_acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
|
||||
},
|
||||
"client_registration": {
|
||||
"client_id": ("urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"),
|
||||
|
|
|
@ -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
|
||||
|
|
63
src/registrar/forms/user_profile.py
Normal file
63
src/registrar/forms/user_profile.py
Normal 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)
|
|
@ -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 %}
|
||||
|
|
|
@ -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%}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,27 +1,75 @@
|
|||
{% 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 }}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<button type="submit" class="usa-button usa-button--big">Save Changes</button>
|
||||
</form>
|
||||
</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 %}
|
||||
{% 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 won’t be made public.</p>
|
||||
|
||||
<h2>Contact information</h2>
|
||||
<p>Review the details below and update any required information. Note that editing this information won’t 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, you’ll 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 %}
|
||||
|
||||
|
|
|
@ -2230,7 +2230,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"other_contacts",
|
||||
"current_websites",
|
||||
"alternative_domains",
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"federal_agency",
|
||||
"id",
|
||||
|
@ -2285,7 +2284,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"other_contacts",
|
||||
"current_websites",
|
||||
"alternative_domains",
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"federal_agency",
|
||||
"creator",
|
||||
|
@ -2314,7 +2312,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"other_contacts",
|
||||
"current_websites",
|
||||
"alternative_domains",
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"federal_agency",
|
||||
]
|
||||
|
@ -3173,7 +3170,6 @@ class TestDomainInformationAdmin(TestCase):
|
|||
|
||||
expected_fields = [
|
||||
"other_contacts",
|
||||
"generic_org_type",
|
||||
"is_election_board",
|
||||
"federal_agency",
|
||||
"creator",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -14,5 +14,6 @@ from .domain import (
|
|||
DomainInvitationDeleteView,
|
||||
DomainDeleteUserView,
|
||||
)
|
||||
from .user_profile import UserProfileView
|
||||
from .health import *
|
||||
from .index import *
|
||||
|
|
|
@ -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
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email_address} has already been invited to this domain.",
|
||||
)
|
||||
else:
|
||||
self._send_domain_invitation_email(email=email_address, requestor=requestor)
|
||||
if invite is not None:
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email_address} has already been invited to this domain.",
|
||||
)
|
||||
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
|
||||
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:
|
||||
UserDomainRole.objects.create(
|
||||
|
|
|
@ -227,6 +227,7 @@ 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:
|
||||
context = self.get_context_data()
|
||||
|
@ -235,7 +236,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
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,
|
||||
|
@ -705,6 +705,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
|
||||
|
@ -715,6 +722,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
|
||||
|
|
77
src/registrar/views/user_profile.py
Normal file
77
src/registrar/views/user_profile.py
Normal 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
|
|
@ -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:
|
||||
return render(request, "500.html", context=context, status=500)
|
||||
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:
|
||||
return render(request, "401.html", context=context, status=401)
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue