mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-24 11:38:39 +02:00
Merge branch 'main' into bob/1949-multi-selects-accessibility
This commit is contained in:
commit
a1c5b48f03
21 changed files with 571 additions and 51 deletions
13
.github/workflows/deploy-development.yaml
vendored
13
.github/workflows/deploy-development.yaml
vendored
|
@ -22,9 +22,16 @@ jobs:
|
|||
- name: Compile USWDS assets
|
||||
working-directory: ./src
|
||||
run: |
|
||||
docker compose run node npm install &&
|
||||
docker compose run node npx gulp copyAssets &&
|
||||
docker compose run node npx gulp compile
|
||||
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"
|
||||
- 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
|
@ -42,9 +42,16 @@ jobs:
|
|||
- name: Compile USWDS assets
|
||||
working-directory: ./src
|
||||
run: |
|
||||
docker compose run node npm install &&
|
||||
docker compose run node npx gulp copyAssets &&
|
||||
docker compose run node npx gulp compile
|
||||
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"
|
||||
- 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,9 +23,16 @@ jobs:
|
|||
- name: Compile USWDS assets
|
||||
working-directory: ./src
|
||||
run: |
|
||||
docker compose run node npm install &&
|
||||
docker compose run node npx gulp copyAssets &&
|
||||
docker compose run node npx gulp compile
|
||||
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"
|
||||
- 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,9 +23,16 @@ jobs:
|
|||
- name: Compile USWDS assets
|
||||
working-directory: ./src
|
||||
run: |
|
||||
docker compose run node npm install &&
|
||||
docker compose run node npx gulp copyAssets &&
|
||||
docker compose run node npx gulp compile
|
||||
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"
|
||||
- name: Collect static assets
|
||||
working-directory: ./src
|
||||
run: docker compose run app python manage.py collectstatic --no-input
|
||||
|
|
|
@ -602,18 +602,18 @@ That data are synthesized from the generic_org_type field and the is_election_bo
|
|||
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
|
||||
After downloading this file, place it in `src/migrationdata`
|
||||
|
||||
#### Step 2: Upload the domain_election_board file to your sandbox
|
||||
#### Step 3: Upload the domain_election_board file to your sandbox
|
||||
Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc.
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
#### Step 4: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-za`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
#### Step 5: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Running the script
|
||||
#### Step 6: Running the script
|
||||
```./manage.py populate_organization_type {domain_election_board_filename}```
|
||||
|
||||
- The domain_election_board_filename file must adhere to this format:
|
||||
|
@ -642,3 +642,29 @@ Example (assuming that this is being ran from src/):
|
|||
| | Parameter | Description |
|
||||
|:-:|:------------------------------------|:-------------------------------------------------------------------|
|
||||
| 1 | **domain_election_board_filename** | A file containing every domain that is an election office.
|
||||
|
||||
|
||||
## Populate Verification Type
|
||||
This section outlines how to run the `populate_verification_type` script.
|
||||
The script is used to update the verification_type field on User when it is None.
|
||||
|
||||
### Running on sandboxes
|
||||
|
||||
#### Step 1: Login to CloudFoundry
|
||||
```cf login -a api.fr.cloud.gov --sso```
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-za`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Running the script
|
||||
```./manage.py populate_verification_type```
|
||||
|
||||
### Running locally
|
||||
|
||||
#### Step 1: Running the script
|
||||
```docker-compose exec app ./manage.py populate_verification_type```
|
||||
|
|
|
@ -4,8 +4,10 @@ from django.http import HttpResponse
|
|||
from django.test import Client, TestCase, RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from djangooidc.exceptions import StateMismatch, InternalError
|
||||
from ..views import login_callback
|
||||
from registrar.models import User, Contact, VerifiedByStaff, DomainInvitation, TransitionDomain, Domain
|
||||
|
||||
from .common import less_console_noise
|
||||
|
||||
|
@ -16,6 +18,14 @@ class ViewsTest(TestCase):
|
|||
self.client = Client()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def tearDown(self):
|
||||
User.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
DomainInvitation.objects.all().delete()
|
||||
VerifiedByStaff.objects.all().delete()
|
||||
TransitionDomain.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
|
||||
def say_hi(*args):
|
||||
return HttpResponse("Hi")
|
||||
|
||||
|
@ -229,6 +239,140 @@ class ViewsTest(TestCase):
|
|||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_login_callback_sets_verification_type_regular(self, mock_client):
|
||||
"""
|
||||
Test that openid sets the verification type to regular on the returned user.
|
||||
Regular, in this context, means that this user was "Verifed by Login.gov"
|
||||
"""
|
||||
# SETUP
|
||||
session = self.client.session
|
||||
session.save()
|
||||
# MOCK
|
||||
# mock that callback returns user_info; this is the expected behavior
|
||||
mock_client.callback.side_effect = self.user_info
|
||||
# patch that the request does not require step up auth
|
||||
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||
"djangooidc.views._initialize_client"
|
||||
) as mock_init_client:
|
||||
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||
# TEST
|
||||
# test the login callback url
|
||||
response = self.client.get(reverse("openid_login_callback"))
|
||||
|
||||
# assert that _initialize_client was called
|
||||
mock_init_client.assert_called_once()
|
||||
|
||||
# Assert that we get a redirect
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
# Test the created user object
|
||||
created_user = User.objects.get(email="test@example.com")
|
||||
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.REGULAR)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_login_callback_sets_verification_type_invited(self, mock_client):
|
||||
"""Test that openid sets the verification type to invited on the returned user
|
||||
when they exist in the DomainInvitation table"""
|
||||
# SETUP
|
||||
session = self.client.session
|
||||
session.save()
|
||||
|
||||
domain, _ = Domain.objects.get_or_create(name="test123.gov")
|
||||
invitation, _ = DomainInvitation.objects.get_or_create(email="test@example.com", domain=domain)
|
||||
# MOCK
|
||||
# mock that callback returns user_info; this is the expected behavior
|
||||
mock_client.callback.side_effect = self.user_info
|
||||
# patch that the request does not require step up auth
|
||||
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||
"djangooidc.views._initialize_client"
|
||||
) as mock_init_client:
|
||||
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||
# TEST
|
||||
# test the login callback url
|
||||
response = self.client.get(reverse("openid_login_callback"))
|
||||
|
||||
# assert that _initialize_client was called
|
||||
mock_init_client.assert_called_once()
|
||||
|
||||
# Assert that we get a redirect
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
# Test the created user object
|
||||
created_user = User.objects.get(email="test@example.com")
|
||||
self.assertEqual(created_user.email, invitation.email)
|
||||
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.INVITED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_login_callback_sets_verification_type_grandfathered(self, mock_client):
|
||||
"""Test that openid sets the verification type to grandfathered
|
||||
on a user which exists in our TransitionDomain table"""
|
||||
# SETUP
|
||||
session = self.client.session
|
||||
session.save()
|
||||
# MOCK
|
||||
# mock that callback returns user_info; this is the expected behavior
|
||||
mock_client.callback.side_effect = self.user_info
|
||||
|
||||
td, _ = TransitionDomain.objects.get_or_create(username="test@example.com", domain_name="test123.gov")
|
||||
|
||||
# patch that the request does not require step up auth
|
||||
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||
"djangooidc.views._initialize_client"
|
||||
) as mock_init_client:
|
||||
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||
# TEST
|
||||
# test the login callback url
|
||||
response = self.client.get(reverse("openid_login_callback"))
|
||||
|
||||
# assert that _initialize_client was called
|
||||
mock_init_client.assert_called_once()
|
||||
|
||||
# Assert that we get a redirect
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
# Test the created user object
|
||||
created_user = User.objects.get(email="test@example.com")
|
||||
self.assertEqual(created_user.email, td.username)
|
||||
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_login_callback_sets_verification_type_verified_by_staff(self, mock_client):
|
||||
"""Test that openid sets the verification type to verified_by_staff
|
||||
on a user which exists in our VerifiedByStaff table"""
|
||||
# SETUP
|
||||
session = self.client.session
|
||||
session.save()
|
||||
# MOCK
|
||||
# mock that callback returns user_info; this is the expected behavior
|
||||
mock_client.callback.side_effect = self.user_info
|
||||
|
||||
vip, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com")
|
||||
|
||||
# patch that the request does not require step up auth
|
||||
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
|
||||
"djangooidc.views._initialize_client"
|
||||
) as mock_init_client:
|
||||
with patch("djangooidc.views._client_is_none", return_value=True):
|
||||
# TEST
|
||||
# test the login callback url
|
||||
response = self.client.get(reverse("openid_login_callback"))
|
||||
|
||||
# assert that _initialize_client was called
|
||||
mock_init_client.assert_called_once()
|
||||
|
||||
# Assert that we get a redirect
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, "/")
|
||||
|
||||
# Test the created user object
|
||||
created_user = User.objects.get(email="test@example.com")
|
||||
self.assertEqual(created_user.email, vip.email)
|
||||
self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF)
|
||||
|
||||
def test_login_callback_no_step_up_auth(self, mock_client):
|
||||
"""Walk through login_callback when _requires_step_up_auth returns False
|
||||
and assert that we have a redirect to /"""
|
||||
|
|
|
@ -99,8 +99,22 @@ def login_callback(request):
|
|||
return CLIENT.create_authn_request(request.session)
|
||||
user = authenticate(request=request, **userinfo)
|
||||
if user:
|
||||
|
||||
# Fixture users kind of exist in a superposition of verification types,
|
||||
# because while the system "verified" them, if they login,
|
||||
# we don't know how the user themselves was verified through login.gov until
|
||||
# they actually try logging in. This edge-case only matters in non-production environments.
|
||||
fixture_user = User.VerificationTypeChoices.FIXTURE_USER
|
||||
is_fixture_user = user.verification_type and user.verification_type == fixture_user
|
||||
|
||||
# Set the verification type if it doesn't already exist or if its a fixture user
|
||||
if not user.verification_type or is_fixture_user:
|
||||
user.set_user_verification_type()
|
||||
user.save()
|
||||
|
||||
login(request, user)
|
||||
logger.info("Successfully logged in user %s" % user)
|
||||
|
||||
# Clear the flag if the exception is not caught
|
||||
request.session.pop("redirect_attempted", None)
|
||||
return redirect(request.session.get("next", "/"))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
FROM docker.io/cimg/node:current-browsers
|
||||
|
||||
FROM node:21.7.3
|
||||
WORKDIR /app
|
||||
|
||||
# Install app dependencies
|
||||
|
@ -7,4 +7,6 @@ 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,6 +15,10 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@uswds/compile": "^1.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "21.7.3",
|
||||
"npm": "10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gulp-sourcemaps/identity-map": {
|
||||
|
|
|
@ -3,6 +3,11 @@
|
|||
"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"
|
||||
|
|
|
@ -537,7 +537,7 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("username", "password", "status")},
|
||||
{"fields": ("username", "password", "status", "verification_type")},
|
||||
),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
|
@ -555,13 +555,20 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
readonly_fields = ("verification_type",)
|
||||
|
||||
# Hide Username (uuid), Groups and Permissions
|
||||
# Q: Now that we're using Groups and Permissions,
|
||||
# do we expose those to analysts to view?
|
||||
analyst_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("status",)},
|
||||
{
|
||||
"fields": (
|
||||
"status",
|
||||
"verification_type",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
|
@ -681,11 +688,14 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
return []
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return () # No read-only fields for all access users
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
return self.analyst_readonly_fields
|
||||
return readonly_fields
|
||||
else:
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
return self.analyst_readonly_fields
|
||||
|
||||
|
||||
class HostIPInline(admin.StackedInline):
|
||||
|
|
|
@ -631,12 +631,18 @@ address.dja-address-contact-list {
|
|||
font-size: small;
|
||||
}
|
||||
|
||||
// Get rid of padding on all help texts
|
||||
form .aligned p.help, form .aligned div.help {
|
||||
padding-left: 0px !important;
|
||||
}
|
||||
|
||||
// We override the DJA header on multi list selects from h2 to h3
|
||||
// The following block of code styles our generated h3s to match the old h2s
|
||||
.selector .selector-available h3 {
|
||||
background: var(--darkened-bg);
|
||||
color: var(--body-quiet-color);
|
||||
}
|
||||
|
||||
.selector-available h3, .selector-chosen h3 {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
@ -648,6 +654,7 @@ address.dja-address-contact-list {
|
|||
padding: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.selector .selector-chosen h3 {
|
||||
background: var(--primary);
|
||||
color: var(--header-link-color);
|
||||
|
|
|
@ -7,6 +7,7 @@ from registrar.models import (
|
|||
UserGroup,
|
||||
)
|
||||
|
||||
|
||||
fake = Faker()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -207,6 +208,10 @@ class UserFixture:
|
|||
user.email = user_data["email"]
|
||||
user.is_staff = True
|
||||
user.is_active = True
|
||||
# This verification type will get reverted to "regular" (or whichever is applicables)
|
||||
# once the user logs in for the first time (as they then got verified through different means).
|
||||
# In the meantime, we can still describe how the user got here in the first place.
|
||||
user.verification_type = User.VerificationTypeChoices.FIXTURE_USER
|
||||
group = UserGroup.objects.get(name=group_name)
|
||||
user.groups.add(group)
|
||||
user.save()
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||
from registrar.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand, PopulateScriptTemplate):
|
||||
help = "Loops through each valid User object and updates its verification_type value"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Loops through each valid User object and updates its verification_type value"""
|
||||
filter_condition = {"verification_type__isnull": True}
|
||||
self.mass_populate_field(User, filter_condition, ["verification_type"])
|
||||
|
||||
def populate_field(self, field_to_update):
|
||||
"""Defines how we update the verification_type field"""
|
||||
field_to_update.set_user_verification_type()
|
||||
logger.info(
|
||||
f"{TerminalColors.OKCYAN}Updating {field_to_update} => "
|
||||
f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
|
||||
)
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from django.core.paginator import Paginator
|
||||
from typing import List
|
||||
from registrar.utility.enums import LogCode
|
||||
|
@ -58,6 +59,55 @@ class ScriptDataHelper:
|
|||
model_class.objects.bulk_update(page.object_list, fields_to_update)
|
||||
|
||||
|
||||
class PopulateScriptTemplate(ABC):
|
||||
"""
|
||||
Contains an ABC for generic populate scripts
|
||||
"""
|
||||
|
||||
def mass_populate_field(self, sender, filter_conditions, fields_to_update):
|
||||
"""Loops through each valid "sender" object - specified by filter_conditions - and
|
||||
updates fields defined by fields_to_update using populate_function.
|
||||
|
||||
You must define populate_field before you can use this function.
|
||||
"""
|
||||
|
||||
objects = sender.objects.filter(**filter_conditions)
|
||||
|
||||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
==Proposed Changes==
|
||||
Number of {sender} objects to change: {len(objects)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
""",
|
||||
prompt_title="Do you wish to patch this data?",
|
||||
)
|
||||
logger.info("Updating...")
|
||||
|
||||
to_update: List[sender] = []
|
||||
failed_to_update: List[sender] = []
|
||||
for updated_object in objects:
|
||||
try:
|
||||
self.populate_field(updated_object)
|
||||
to_update.append(updated_object)
|
||||
except Exception as err:
|
||||
failed_to_update.append(updated_object)
|
||||
logger.error(err)
|
||||
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}")
|
||||
|
||||
# Do a bulk update on the first_ready field
|
||||
ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update)
|
||||
|
||||
# Log what happened
|
||||
TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True)
|
||||
|
||||
@abstractmethod
|
||||
def populate_field(self, field_to_update):
|
||||
"""Defines how we update each field. Must be defined before using mass_populate_field."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TerminalHelper:
|
||||
@staticmethod
|
||||
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
|
||||
|
|
29
src/registrar/migrations/0089_user_verification_type.py
Normal file
29
src/registrar/migrations/0089_user_verification_type.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 4.2.10 on 2024-04-26 14:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0088_domaininformation_cisa_representative_email_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="verification_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("grandfathered", "Legacy user"),
|
||||
("verified_by_staff", "Verified by staff"),
|
||||
("regular", "Verified by Login.gov"),
|
||||
("invited", "Invited by a domain manager"),
|
||||
("fixture_user", "Created by fixtures"),
|
||||
],
|
||||
help_text="The means through which this user was verified",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -2,6 +2,7 @@ import logging
|
|||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
|
||||
|
@ -23,6 +24,28 @@ class User(AbstractUser):
|
|||
but can be customized later.
|
||||
"""
|
||||
|
||||
class VerificationTypeChoices(models.TextChoices):
|
||||
"""
|
||||
Users achieve access to our system in a few different ways.
|
||||
These choices reflect those pathways.
|
||||
|
||||
Overview of verification types:
|
||||
- GRANDFATHERED: User exists in the `TransitionDomain` table
|
||||
- VERIFIED_BY_STAFF: User exists in the `VerifiedByStaff` table
|
||||
- INVITED: User exists in the `DomainInvitation` table
|
||||
- REGULAR: User was verified through IAL2
|
||||
- FIXTURE_USER: User was created by fixtures
|
||||
"""
|
||||
|
||||
GRANDFATHERED = "grandfathered", "Legacy user"
|
||||
VERIFIED_BY_STAFF = "verified_by_staff", "Verified by staff"
|
||||
REGULAR = "regular", "Verified by Login.gov"
|
||||
INVITED = "invited", "Invited by a domain manager"
|
||||
# We need a type for fixture users (rather than using verified by staff)
|
||||
# because those users still do get "verified" through normal means
|
||||
# after they login.
|
||||
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
||||
|
||||
# #### Constants for choice fields ####
|
||||
RESTRICTED = "restricted"
|
||||
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
||||
|
@ -50,6 +73,13 @@ class User(AbstractUser):
|
|||
db_index=True,
|
||||
)
|
||||
|
||||
verification_type = models.CharField(
|
||||
choices=VerificationTypeChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The means through which this user was verified",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
# this info is pulled from Login.gov
|
||||
if self.first_name or self.last_name:
|
||||
|
@ -114,23 +144,61 @@ class User(AbstractUser):
|
|||
except Exception as err:
|
||||
raise err
|
||||
|
||||
# A new incoming user who is a domain manager for one of the domains
|
||||
# that we inputted from Verisign (that is, their email address appears
|
||||
# in the username field of a TransitionDomain)
|
||||
if TransitionDomain.objects.filter(username=email).exists():
|
||||
return False
|
||||
# We can't set the verification type here because the user may not
|
||||
# always exist at this point. We do it down the line.
|
||||
verification_type = cls.get_verification_type_from_email(email)
|
||||
|
||||
# New users flagged by Staff to bypass ial2
|
||||
if VerifiedByStaff.objects.filter(email=email).exists():
|
||||
return False
|
||||
# Checks if the user needs verification.
|
||||
# The user needs identity verification if they don't meet
|
||||
# any special criteria, i.e. we are validating them "regularly"
|
||||
return verification_type == cls.VerificationTypeChoices.REGULAR
|
||||
|
||||
# A new incoming user who is being invited to be a domain manager (that is,
|
||||
# their email address is in DomainInvitation for an invitation that is not yet "retrieved").
|
||||
invited = DomainInvitation.DomainInvitationStatus.INVITED
|
||||
if DomainInvitation.objects.filter(email=email, status=invited).exists():
|
||||
return False
|
||||
def set_user_verification_type(self):
|
||||
"""
|
||||
Given pre-existing data from TransitionDomain, VerifiedByStaff, and DomainInvitation,
|
||||
set the verification "type" defined in VerificationTypeChoices.
|
||||
"""
|
||||
email_or_username = self.email if self.email else self.username
|
||||
retrieved = DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||
verification_type = self.get_verification_type_from_email(email_or_username, invitation_status=retrieved)
|
||||
|
||||
return True
|
||||
# An existing user may have been invited to a domain after they got verified.
|
||||
# We need to check for this condition.
|
||||
if verification_type == User.VerificationTypeChoices.INVITED:
|
||||
invitation = (
|
||||
DomainInvitation.objects.filter(email=email_or_username, status=retrieved)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
# If you joined BEFORE the oldest invitation was created, then you were verified normally.
|
||||
# (See logic in get_verification_type_from_email)
|
||||
if not invitation and self.date_joined < invitation.created_at:
|
||||
verification_type = User.VerificationTypeChoices.REGULAR
|
||||
|
||||
self.verification_type = verification_type
|
||||
|
||||
@classmethod
|
||||
def get_verification_type_from_email(cls, email, invitation_status=DomainInvitation.DomainInvitationStatus.INVITED):
|
||||
"""Retrieves the verification type based off of a provided email address"""
|
||||
|
||||
verification_type = None
|
||||
if TransitionDomain.objects.filter(Q(username=email) | Q(email=email)).exists():
|
||||
# A new incoming user who is a domain manager for one of the domains
|
||||
# that we inputted from Verisign (that is, their email address appears
|
||||
# in the username field of a TransitionDomain)
|
||||
verification_type = cls.VerificationTypeChoices.GRANDFATHERED
|
||||
elif VerifiedByStaff.objects.filter(email=email).exists():
|
||||
# New users flagged by Staff to bypass ial2
|
||||
verification_type = cls.VerificationTypeChoices.VERIFIED_BY_STAFF
|
||||
elif DomainInvitation.objects.filter(email=email, status=invitation_status).exists():
|
||||
# A new incoming user who is being invited to be a domain manager (that is,
|
||||
# their email address is in DomainInvitation for an invitation that is not yet "retrieved").
|
||||
verification_type = cls.VerificationTypeChoices.INVITED
|
||||
else:
|
||||
verification_type = cls.VerificationTypeChoices.REGULAR
|
||||
|
||||
return verification_type
|
||||
|
||||
def check_domain_invitations_on_login(self):
|
||||
"""When a user first arrives on the site, we need to retrieve any domain
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
|
||||
|
||||
{% if show_formatted_name %}
|
||||
{% if contact.get_formatted_name %}
|
||||
{% if user.get_formatted_name %}
|
||||
<a href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a><br />
|
||||
{% else %}
|
||||
None<br />
|
||||
|
@ -47,7 +47,12 @@
|
|||
{% else %}
|
||||
None<br>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
No additional contact information found.
|
||||
No additional contact information found.<br>
|
||||
{% endif %}
|
||||
|
||||
{% if user_verification_type %}
|
||||
{{ user_verification_type }}
|
||||
{% endif %}
|
||||
</address>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
{% comment %}
|
||||
This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||
{% endcomment %}
|
||||
|
||||
{% block field_readonly %}
|
||||
{% with all_contacts=original_object.other_contacts.all %}
|
||||
{% if field.field.name == "other_contacts" %}
|
||||
|
@ -65,22 +66,15 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endwith %}
|
||||
{% endblock field_readonly %}
|
||||
|
||||
{% block help_text %}
|
||||
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
|
||||
<div>{{ field.field.help_text|safe }}</div>
|
||||
</div>
|
||||
{% endblock help_text %}
|
||||
|
||||
|
||||
{% block after_help_text %}
|
||||
{% if field.field.name == "creator" %}
|
||||
<div class="flex-container tablet:margin-top-1">
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Creator contact details"></label>
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
|
||||
</div>
|
||||
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
||||
{% elif field.field.name == "submitter" %}
|
||||
<div class="flex-container">
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Submitter contact details"></label>
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
|
||||
</div>
|
||||
|
|
|
@ -3065,7 +3065,15 @@ class TestMyUserAdmin(TestCase):
|
|||
request.user = create_user()
|
||||
fieldsets = self.admin.get_fieldsets(request)
|
||||
expected_fieldsets = (
|
||||
(None, {"fields": ("status",)}),
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"status",
|
||||
"verification_type",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
("Permissions", {"fields": ("is_active", "groups")}),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
|
|
|
@ -14,8 +14,9 @@ from registrar.models import (
|
|||
TransitionDomain,
|
||||
DomainInformation,
|
||||
UserDomainRole,
|
||||
VerifiedByStaff,
|
||||
PublicContact,
|
||||
)
|
||||
from registrar.models.public_contact import PublicContact
|
||||
|
||||
from django.core.management import call_command
|
||||
from unittest.mock import patch, call
|
||||
|
@ -25,6 +26,103 @@ from .common import MockEppLib, less_console_noise, completed_domain_request
|
|||
from api.tests.common import less_console_noise_decorator
|
||||
|
||||
|
||||
class TestPopulateVerificationType(MockEppLib):
|
||||
"""Tests for the populate_organization_type script"""
|
||||
|
||||
def setUp(self):
|
||||
"""Creates a fake domain object"""
|
||||
super().setUp()
|
||||
|
||||
# Get the domain requests
|
||||
self.domain_request_1 = completed_domain_request(
|
||||
name="lasers.gov",
|
||||
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
is_election_board=True,
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
)
|
||||
|
||||
# Approve the request
|
||||
self.domain_request_1.approve()
|
||||
|
||||
# Get the domains
|
||||
self.domain_1 = Domain.objects.get(name="lasers.gov")
|
||||
|
||||
# Get users
|
||||
self.regular_user, _ = User.objects.get_or_create(username="testuser@igormail.gov")
|
||||
|
||||
vip, _ = VerifiedByStaff.objects.get_or_create(email="vipuser@igormail.gov")
|
||||
self.verified_by_staff_user, _ = User.objects.get_or_create(username="vipuser@igormail.gov")
|
||||
|
||||
grandfathered, _ = TransitionDomain.objects.get_or_create(
|
||||
username="grandpa@igormail.gov", domain_name=self.domain_1.name
|
||||
)
|
||||
self.grandfathered_user, _ = User.objects.get_or_create(username="grandpa@igormail.gov")
|
||||
|
||||
invited, _ = DomainInvitation.objects.get_or_create(
|
||||
email="invited@igormail.gov", domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||
)
|
||||
self.invited_user, _ = User.objects.get_or_create(username="invited@igormail.gov")
|
||||
|
||||
self.untouched_user, _ = User.objects.get_or_create(
|
||||
username="iaminvincible@igormail.gov", verification_type=User.VerificationTypeChoices.GRANDFATHERED
|
||||
)
|
||||
|
||||
# Fixture users should be untouched by the script. These will auto update once the
|
||||
# user logs in / creates an account.
|
||||
self.fixture_user, _ = User.objects.get_or_create(
|
||||
username="fixture@igormail.gov", verification_type=User.VerificationTypeChoices.FIXTURE_USER
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Deletes all DB objects related to migrations"""
|
||||
super().tearDown()
|
||||
|
||||
# Delete domains and related information
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def run_populate_verification_type(self):
|
||||
"""
|
||||
This method executes the populate_organization_type command.
|
||||
|
||||
The 'call_command' function from Django's management framework is then used to
|
||||
execute the populate_organization_type command with the specified arguments.
|
||||
"""
|
||||
with patch(
|
||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
|
||||
return_value=True,
|
||||
):
|
||||
call_command("populate_verification_type")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_verification_type_script_populates_data(self):
|
||||
"""Ensures that the verification type script actually populates data"""
|
||||
|
||||
# Run the script
|
||||
self.run_populate_verification_type()
|
||||
|
||||
# Scripts don't work as we'd expect in our test environment, we need to manually
|
||||
# trigger the refresh event
|
||||
self.regular_user.refresh_from_db()
|
||||
self.grandfathered_user.refresh_from_db()
|
||||
self.invited_user.refresh_from_db()
|
||||
self.verified_by_staff_user.refresh_from_db()
|
||||
self.untouched_user.refresh_from_db()
|
||||
|
||||
# Test all users
|
||||
self.assertEqual(self.regular_user.verification_type, User.VerificationTypeChoices.REGULAR)
|
||||
self.assertEqual(self.grandfathered_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||
self.assertEqual(self.invited_user.verification_type, User.VerificationTypeChoices.INVITED)
|
||||
self.assertEqual(self.verified_by_staff_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF)
|
||||
self.assertEqual(self.untouched_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
|
||||
self.assertEqual(self.fixture_user.verification_type, User.VerificationTypeChoices.FIXTURE_USER)
|
||||
|
||||
|
||||
class TestPopulateOrganizationType(MockEppLib):
|
||||
"""Tests for the populate_organization_type script"""
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue