Skip to content
10 changes: 10 additions & 0 deletions docs/developer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ Analysts are a variant of the admin role with limited permissions. The process f

Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`

## Adding `Standard` Users & what they are

Standard users are non-staff, non-admin test accounts used for user research. Each is pre-loaded with domain requests in every `DomainRequestStatus` and domains in every `Domain.State`.

These users load automatically on sandboxes when `./manage.py load` runs (such as via `reset-db.yml`). They do **not** load locally, as if emails are turned on locally it could interfer with user testing.

To add or update a standard user, edit the `STANDARD_USERS` list in [fixtures_users](../../src/registrar/fixtures/fixtures_users.py). The `username` field must be the UUID from the user's login.gov identity sandbox account.

These users are added to the email whitelist by default.

## Adding an email address to the email whitelist (sandboxes only)
On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions.

Expand Down
2 changes: 2 additions & 0 deletions src/registrar/fixtures/fixtures_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ def _set_non_foreign_key_fields(cls, request: DomainRequest, request_dict: dict)
@classmethod
def _set_foreign_key_fields(cls, request: DomainRequest, request_dict: dict, user: User):
"""Helper method used by `load`."""
if not user.is_staff:
user = random.choice(User.objects.filter(is_staff=True)) # nosec
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having trouble following this. Why was this done?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! The "user" variable is used to set investigator on the next line. But, investigator has to a staff member as only staff members should be approving/investigating the validity of a request. Now that these new users are meant to be purely non staff users for UAT, the code hits an error if any of them are assigned as investigators. Hence, I'm checking if the user is not a staff member, then pick a random staff member as the investigator just so it doesn't error when it gets to approving the domain request.

request.investigator = cls._get_investigator(request, request_dict, user)
request.senior_official = cls._get_senior_official(request, request_dict)
request.requested_domain = cls._get_requested_domain(request, request_dict)
Expand Down
241 changes: 241 additions & 0 deletions src/registrar/fixtures/fixtures_standard_user_domains.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
from datetime import timedelta
import logging
import random

from django.utils import timezone

from registrar.fixtures.fixtures_requests import DomainRequestFixture
from registrar.fixtures.fixtures_users import UserFixture
from registrar.models import User, DomainRequest
from registrar.models.domain import Domain

logger = logging.getLogger(__name__)


class StandardUserDomainFixture(DomainRequestFixture):
"""
Creates domain requests and domains for standard test users.

For each standard user:
- 7 IN_REVIEW requests are approved, creating a domain per request. Each
domain's state is then forced to cover every Domain.State value plus
expired and expiring-soon variants. These domains will show up as "unknown" in EPP still,
- Additional non-approved requests cover the remaining DomainRequestStatus
values (excluding INELIGIBLE).

Domain states are forced via Domain.objects.filter(...).update(state=...) which
bypasses FSMField(protected=True) on Domain.state — no EPP/OT&E commands are
sent. These domains exist solely for UI filter testing.

Depends on fixtures_users (STANDARD_USERS must be loaded first).

Make sure this class' `load` method is called from `handle`
in management/commands/load.py, then use `./manage.py load`
to run this code.
"""

# Each entry produces one approved domain request and one domain whose state
# is forced to _target_state after approval.
DOMAIN_STATE_CONFIGS = [
{
"organization_name": "Standard User - Domain Unknown",
"_target_state": Domain.State.UNKNOWN,
},
{
"organization_name": "Standard User - Domain DNS Needed",
"_target_state": Domain.State.DNS_NEEDED,
},
{
"organization_name": "Standard User - Domain Ready",
"_target_state": Domain.State.READY,
},
{
"organization_name": "Standard User - Domain On Hold",
"_target_state": Domain.State.ON_HOLD,
},
{
"organization_name": "Standard User - Domain Deleted",
"_target_state": Domain.State.DELETED,
},
{
"organization_name": "Standard User - Domain Expired",
"_target_state": Domain.State.READY,
"_expired": True,
},
{
"organization_name": "Standard User - Domain Expiring Soon",
"_target_state": Domain.State.READY,
"_expiring_soon": True,
},
]

# Non-approved requests covering all remaining DomainRequestStatus values.
# APPROVED is already represented by the 7 approved requests above.
# INELIGIBLE is intentionally excluded.
STATUS_REQUEST_CONFIGS = [
{
"status": DomainRequest.DomainRequestStatus.STARTED,
"organization_name": "Standard User - Started",
},
{
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
"organization_name": "Standard User - Submitted",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Standard User - In Review",
},
{
"status": DomainRequest.DomainRequestStatus.IN_REVIEW_OMB,
"organization_name": "Standard User - In Review OMB",
},
{
"status": DomainRequest.DomainRequestStatus.ACTION_NEEDED,
"organization_name": "Standard User - Action Needed",
},
{
"status": DomainRequest.DomainRequestStatus.WITHDRAWN,
"organization_name": "Standard User - Withdrawn",
},
{
"status": DomainRequest.DomainRequestStatus.REJECTED,
"organization_name": "Standard User - Rejected",
},
]

@classmethod
def load(cls):
standard_usernames = [u["username"] for u in UserFixture.STANDARD_USERS]
users = list(User.objects.filter(username__in=standard_usernames))

if not users:
logger.warning("Standard users not found, skipping StandardUserDomainFixture.")
return

cls._create_status_requests(users)
cls._create_domains(users)

@classmethod
def _create_status_requests(cls, users):
"""Bulk-creates the non-approved status requests for each standard user."""
requests_to_create = []
for user in users:
for config in cls.STATUS_REQUEST_CONFIGS:
try:
request = DomainRequest(
requester=user,
organization_name=config["organization_name"],
)
cls._set_non_foreign_key_fields(request, config)
cls._set_foreign_key_fields(request, {}, user)
requests_to_create.append(request)
except Exception as e:
logger.warning(f"Error preparing status request for {user}: {e}")

cls._bulk_create_requests(requests_to_create)

for request in requests_to_create:
try:
cls._set_many_to_many_relations(request, {})
except Exception as e:
logger.warning(e)

@classmethod
def _create_domains(cls, users):
"""
Uses the 7 IN_REVIEW requests per standard user and approves them with
approve(), creating a Domain and a UserDomainRole.MANAGER for the requester), then bulk-updates the request
statuses. Domain states are forced in _force_domain_states().
"""
for user in users:
approved_pairs = []
# get all the in_review requests for this user, up to the number of domain state configs we have
in_review_requests = list(
DomainRequest.objects.filter(
requester=user,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
).order_by("id")[: len(cls.DOMAIN_STATE_CONFIGS)]
)

if len(in_review_requests) != len(cls.DOMAIN_STATE_CONFIGS):
logger.warning(
f"Not enough IN_REVIEW requests for user {user.username} to approve. "
f"Expected {len(cls.DOMAIN_STATE_CONFIGS)}, found {len(in_review_requests)}."
)
continue

for request, config in zip(in_review_requests, cls.DOMAIN_STATE_CONFIGS):
try:
request.investigator = random.choice(User.objects.filter(is_staff=True)) # nosec
request.approve(send_email=False)
approved_pairs.append((request, config))
except Exception as e:
logger.warning(f"Cannot approve domain request for {user}: {e}")

if approved_pairs:
try:
DomainRequest.objects.bulk_update([r for r, _ in approved_pairs], ["status", "investigator"])
except Exception as e:
logger.error(f"Error bulk updating domain requests for {user}: {e}")

cls._force_domain_states(approved_pairs)

@classmethod
def _force_domain_states(cls, approved_pairs):
"""
Forces each approved domain into its target state via queryset update().
Domain.state is FSMField(protected=True), so direct attribute assignment raises AttributeError
— queryset update() issues a raw SQL UPDATE that bypasses the FSM with no EPP calls.
Expect these domains to throw errors or switch back to "unknown" if any code tries to send EPP commands for them
(such as clicking 'manage' on the domain's table)
"""
today = timezone.now().date()
for request, config in approved_pairs:
try:
domain = Domain.objects.get(domain_info__domain_request=request)
except Domain.DoesNotExist:
logger.warning(f"Domain not found for request {request.id}, skipping state update.")
continue

target_state = config["_target_state"]

if config.get("_expired"):
expiration_date = today - timedelta(days=random.randint(1, 365)) # nosec
elif config.get("_expiring_soon"):
expiration_date = today + timedelta(days=random.randint(1, 30)) # nosec
else:
expiration_date = today + timedelta(days=random.randint(31, 365)) # nosec

Domain.objects.filter(id=domain.id).update(
state=target_state,
expiration_date=expiration_date,
is_enrolled_in_dns_hosting=True,
)
48 changes: 48 additions & 0 deletions src/registrar/fixtures/fixtures_user_portfolio_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,54 @@ def load(cls):

# Bulk create permissions
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
cls.load_standard_user_permissions()

@classmethod
def load_standard_user_permissions(cls):
"""Assigns ORGANIZATION_MEMBER role to standard users on a random portfolio."""
logger.info("Going to set standard user portfolio permissions")
try:
standard_usernames = [u["username"] for u in UserFixture.STANDARD_USERS]
users = list(User.objects.filter(username__in=standard_usernames))
organization_names = [p["organization_name"] for p in PortfolioFixture.PORTFOLIOS]
portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names))

if not users:
logger.warning("Standard user fixtures missing.")
return
if not portfolios:
logger.warning("Portfolio fixtures missing.")
return
except Exception as e:
logger.warning(f"Error occurred while fetching standard user or portfolio data: {e}", exc_info=True)
return

permissions_to_create = []
for user in users:
portfolio = random.choice(portfolios) # nosec
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
permissions_to_create.append(
UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)

# Bulk create permissions for standard users
cls._bulk_create_permissions(permissions_to_create)

@classmethod
def _bulk_create_permissions(cls, user_portfolio_permissions_to_create):
Expand Down
Loading
Loading