Merge branch 'main' into nl/3075-action-td-adjustments

This commit is contained in:
CuriousX 2025-02-10 10:06:58 -07:00 committed by GitHub
commit 4238d064d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 2882 additions and 604 deletions

View file

@ -1,61 +0,0 @@
name: Story
description: Capture actionable sprint work
labels: ["story"]
body:
- type: markdown
id: help
attributes:
value: |
> **Note**
> GitHub Issues use [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting.
- type: textarea
id: story
attributes:
label: Story
description: |
Please add the "as a, I want, so that" details that describe the story.
If more than one "as a, I want, so that" describes the story, add multiple.
Example:
As an analyst
I want the ability to approve a domain request
so that a request can be fulfilled and a new .gov domain can be provisioned
value: |
As a
I want
so that
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: |
Please add the acceptance criteria that best describe the desired outcomes when this work is completed
Example:
- Application sends an email when analysts approve domain requests
- Domain request status is "approved"
Example ("given, when, then" format):
Given that I am an analyst who has finished reviewing a domain request
When I click to approve a domain request
Then the domain provisioning process should be initiated, and the applicant should receive an email update.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: "Please include additional references (screenshots, design links, documentation, etc.) that are relevant"
- type: textarea
id: issue-links
attributes:
label: Issue Links
description: |
What other issues does this story relate to and how?
Example:
- 🚧 Blocked by: #123
- 🔄 Relates to: #234

View file

@ -0,0 +1,90 @@
# This workflow can be run from the CLI
# gh workflow run reset-db.yaml -f environment=ENVIRONMENT
name: Reset database
run-name: Reset database for ${{ github.event.inputs.environment }}
on:
workflow_dispatch:
inputs:
environment:
type: choice
description: Which environment should we flush and re-load data for?
options:
- el
- ad
- ms
- ag
- litterbox
- hotgov
- cb
- bob
- meoward
- backup
- ky
- es
- nl
- rh
- za
- gd
- rb
- ko
- ab
- rjm
- dk
jobs:
reset-db:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
DESTINATION_ENVIRONMENT: ${{ github.event.inputs.environment}}
steps:
- name: Delete and Recreate Database
env:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
run: |
# install cf cli and other tools
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
sudo apt-get update
sudo apt-get install cf8-cli
cf api api.fr.cloud.gov
cf auth "$CF_USERNAME" "$CF_PASSWORD"
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT
# unbind the service
cf unbind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
#delete the service key
yes Y | cf delete-service-key getgov-$DESTINATION_ENVIRONMENT-database SERVICE_CONNECT
# delete the service
yes Y | cf delete-service getgov-$DESTINATION_ENVIRONMENT-database
# create it again
cf create-service aws-rds micro-psql getgov-$DESTINATION_ENVIRONMENT-database
# wait for it be created (up to 5 mins)
# this checks the creation cf service getgov-$DESTINATION_ENVIRONMENT-database
# the below command with check “status” line using cf service command mentioned above. if it says “create in progress” it will keep waiting otherwise the next steps fail
timeout 480 bash -c "until cf service getgov-$DESTINATION_ENVIRONMENT-database | grep -q 'The service instance status is succeeded'
do
echo 'Database not up yet, waiting...'
sleep 30
done"
# rebind the service
cf bind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
#restage the app or it will not connect to the database right for the next commands
cf restage getgov-$DESTINATION_ENVIRONMENT
# wait for the above command to finish
# if it is taking way to long and the annoying “instance starting” line that keeps repeating, then run following two commands in a separate window. This will interrupt the death loop where it keeps hitting an error with it failing health checks
# create the cache table and run migrations
cf run-task getgov-$DESTINATION_ENVIRONMENT --command 'python manage.py createcachetable' --name createcachetable
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py migrate' --name migrate
# load fixtures
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py load' --name loaddata

View file

@ -0,0 +1,7 @@
# Workflows Docs
========================
This directory contains files related to workflows
Delete And Recreate Database is in [docs/ops](../workflows/delete-and-recreate-db.md/).

View file

@ -0,0 +1,13 @@
## Delete And Recreate Database
This script destroys and recreates a database. This is another troubleshooting tool for issues with the database.
1. unbinds the database
2. deletes it
3. recreates it
4. binds it back to the sandbox
5. runs migrations
Addition Info in this slack thread:
- [Slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1725495150772119)

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-backup.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-development.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-es.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-gd.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-hotgov.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ko.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ky.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-litterbox.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: DEBUG
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-rb.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://manage.get.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: json
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Which OIDC provider to use

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-staging.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: json
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -21,6 +21,8 @@ applications:
DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# tell django what log format to use: console or json. See settings.py for more details.
DJANGO_LOG_FORMAT: console
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -28,7 +28,11 @@ from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.email_invitations import (
send_domain_invitation_email,
send_portfolio_admin_addition_emails,
send_portfolio_invitation_email,
)
from registrar.views.utility.invitation_helper import (
get_org_membership,
get_requested_user,
@ -1377,9 +1381,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/user_domain_role_change_form.html"
# Override for the delete confirmation page on the domain table (bulk delete action)
delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html"
# Fixes a bug where non-superusers are redirected to the main page
def delete_view(self, request, object_id, extra_context=None):
"""Custom delete_view implementation that specifies redirect behaviour"""
self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
response = super().delete_view(request, object_id, extra_context)
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
@ -1514,6 +1522,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["domain"]
change_form_template = "django/admin/domain_invitation_change_form.html"
# Override for the delete confirmation page on the domain table (bulk delete action)
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
# Select domain invitations to change -> Domain invitations
def changelist_view(self, request, extra_context=None):
@ -1523,6 +1533,16 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def delete_view(self, request, object_id, extra_context=None):
"""
Custom delete_view to perform additional actions or customize the template.
"""
# Set the delete template to a custom one
self.delete_confirmation_template = "django/admin/domain_invitation_delete_confirmation.html"
response = super().delete_view(request, object_id, extra_context=extra_context)
return response
def save_model(self, request, obj, form, change):
"""
Override the save_model method.
@ -1551,7 +1571,9 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
and not member_of_this_org
and not member_of_a_different_org
):
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
send_portfolio_invitation_email(
email=requested_email, requestor=requestor, portfolio=domain_org, is_admin_invitation=False
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email=requested_email,
portfolio=domain_org,
@ -1642,30 +1664,57 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
Emails sent to requested user / email.
When exceptions are raised, return without saving model.
"""
if not change: # Only send email if this is a new PortfolioInvitation (creation)
try:
portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user
# Look up a user with that email
requested_user = get_requested_user(requested_email)
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in obj.roles
if not change: # Only send email if this is a new PortfolioInvitation (creation)
# Look up a user with that email
requested_user = get_requested_user(requested_email)
permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
).exists()
try:
permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
).exists()
if not permission_exists:
# if permission does not exist for a user with requested_email, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
if not send_portfolio_invitation_email(
email=requested_email,
requestor=requestor,
portfolio=portfolio,
is_admin_invitation=is_admin_invitation,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
obj.retrieve()
messages.success(request, f"{requested_email} has been invited.")
else:
messages.warning(request, "User is already a member of this portfolio.")
except Exception as e:
# when exception is raised, handle and do not save the model
handle_invitation_exceptions(request, e, requested_email)
return
else: # Handle the case when updating an existing PortfolioInvitation
# Retrieve the existing object from the database
existing_obj = PortfolioInvitation.objects.get(pk=obj.pk)
# Check if the previous roles did NOT include ORGANIZATION_ADMIN
# and the new roles DO include ORGANIZATION_ADMIN
was_not_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in existing_obj.roles
# Check also if status is INVITED, ignore role changes for other statuses
is_invited = obj.status == PortfolioInvitation.PortfolioInvitationStatus.INVITED
if was_not_admin and is_admin_invitation and is_invited:
# send email to existing portfolio admins if new admin
if not send_portfolio_admin_addition_emails(
email=requested_email,
requestor=requestor,
portfolio=portfolio,
):
messages.warning(request, "Could not send email notification to existing organization admins.")
except Exception as e:
# when exception is raised, handle and do not save the model
handle_invitation_exceptions(request, e, requested_email)
return
# Call the parent save method to save the object
super().save_model(request, obj, form, change)

View file

@ -61,6 +61,7 @@ env_db_url = env.dj_db_url("DATABASE_URL")
env_debug = env.bool("DJANGO_DEBUG", default=False)
env_is_production = env.bool("IS_PRODUCTION", default=False)
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
env_log_format = env.str("DJANGO_LOG_FORMAT", "console")
env_base_url: str = env.str("DJANGO_BASE_URL")
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
@ -492,12 +493,18 @@ class JsonServerFormatter(ServerFormatter):
return json.dumps(log_entry)
# default to json formatted logs
server_formatter, console_formatter = "json.server", "json"
# don't use json format locally, it makes logs hard to read in console
# If we're running locally we don't want json formatting
if "localhost" in env_base_url:
server_formatter, console_formatter = "django.server", "verbose"
django_handlers = ["console"]
elif env_log_format == "json":
# in production we need everything to be logged as json so that log levels are parsed correctly
django_handlers = ["json"]
else:
# for non-production non-local environments:
# - send ERROR and above to json handler
# - send below ERROR to console handler with verbose formatting
# yes this is janky but it's the best we can do for now
django_handlers = ["split_console", "split_json"]
LOGGING = {
"version": 1,
@ -531,29 +538,52 @@ LOGGING = {
"console": {
"level": env_log_level,
"class": "logging.StreamHandler",
"formatter": console_formatter,
"formatter": "verbose",
},
# Special handlers for split logging case
"split_console": {
"level": env_log_level,
"class": "logging.StreamHandler",
"formatter": "verbose",
"filters": ["below_error"],
},
"split_json": {
"level": "ERROR",
"class": "logging.StreamHandler",
"formatter": "json",
},
"django.server": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": server_formatter,
"formatter": "django.server",
},
"json": {
"level": env_log_level,
"class": "logging.StreamHandler",
"formatter": "json",
},
# No file logger is configured,
# because containerized apps
# do not log to the file system.
},
"filters": {
"below_error": {
"()": "django.utils.log.CallbackFilter",
"callback": lambda record: record.levelno < logging.ERROR,
}
},
# define loggers: these are "sinks" into which
# messages are sent for processing
"loggers": {
# Django's generic logger
"django": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "INFO",
"propagate": False,
},
# Django's template processor
"django.template": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "INFO",
"propagate": False,
},
@ -571,19 +601,19 @@ LOGGING = {
},
# OpenID Connect logger
"oic": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "INFO",
"propagate": False,
},
# Django wrapper for OpenID Connect
"djangooidc": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "INFO",
"propagate": False,
},
# Our app!
"registrar": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "DEBUG",
"propagate": False,
},
@ -591,7 +621,7 @@ LOGGING = {
# root logger catches anything, unless
# defined by a more specific logger
"root": {
"handlers": ["console"],
"handlers": django_handlers,
"level": "INFO",
},
}

View file

@ -56,12 +56,11 @@ def add_path_to_context(request):
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
portfolio_context = {
"has_base_portfolio_permission": False,
"has_view_portfolio_permission": False,
"has_edit_portfolio_permission": False,
"has_any_domains_portfolio_permission": False,
"has_any_requests_portfolio_permission": False,
"has_edit_request_portfolio_permission": False,
"has_view_suborganization_portfolio_permission": False,
"has_edit_suborganization_portfolio_permission": False,
"has_view_members_portfolio_permission": False,
"has_edit_members_portfolio_permission": False,
"portfolio": None,
@ -82,15 +81,11 @@ def portfolio_permissions(request):
}
)
# Linting: line too long
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
if portfolio:
return {
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
"has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio),
"has_edit_portfolio_permission": request.user.has_edit_portfolio_permission(portfolio),
"has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
"has_view_suborganization_portfolio_permission": view_suborg,
"has_edit_suborganization_portfolio_permission": edit_suborg,
"has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),

View file

@ -3,7 +3,6 @@ from django.utils import timezone
import logging
import random
from faker import Faker
from django.db import transaction
from registrar.fixtures.fixtures_requests import DomainRequestFixture
from registrar.fixtures.fixtures_users import UserFixture
@ -29,19 +28,18 @@ class DomainFixture(DomainRequestFixture):
def load(cls):
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
except Exception as e:
logger.warning(e)
return
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
except Exception as e:
logger.warning(e)
return
# Approve each user associated with `in review` status domains
cls._approve_domain_requests(users)
# Approve each user associated with `in review` status domains
cls._approve_domain_requests(users)
@staticmethod
def _generate_fake_expiration_date(days_in_future=365):

View file

@ -1,7 +1,6 @@
import logging
import random
from faker import Faker
from django.db import transaction
from registrar.models import User, DomainRequest, FederalAgency
from registrar.models.portfolio import Portfolio
@ -84,42 +83,38 @@ class PortfolioFixture:
def load(cls):
"""Creates portfolios."""
logger.info("Going to load %s portfolios" % len(cls.PORTFOLIOS))
try:
user = User.objects.all().last()
except Exception as e:
logger.warning(e)
return
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
portfolios_to_create = []
for portfolio_data in cls.PORTFOLIOS:
organization_name = portfolio_data["organization_name"]
# Check if portfolio with the organization name already exists
if Portfolio.objects.filter(organization_name=organization_name).exists():
logger.info(
f"Portfolio with organization name '{organization_name}' already exists, skipping creation."
)
continue
try:
portfolio = Portfolio(
creator=user,
organization_name=portfolio_data["organization_name"],
)
cls._set_non_foreign_key_fields(portfolio, portfolio_data)
cls._set_foreign_key_fields(portfolio, portfolio_data, user)
portfolios_to_create.append(portfolio)
except Exception as e:
logger.warning(e)
# Bulk create portfolios
if len(portfolios_to_create) > 0:
try:
user = User.objects.all().last()
Portfolio.objects.bulk_create(portfolios_to_create)
logger.info(f"Successfully created {len(portfolios_to_create)} portfolios")
except Exception as e:
logger.warning(e)
return
portfolios_to_create = []
for portfolio_data in cls.PORTFOLIOS:
organization_name = portfolio_data["organization_name"]
# Check if portfolio with the organization name already exists
if Portfolio.objects.filter(organization_name=organization_name).exists():
logger.info(
f"Portfolio with organization name '{organization_name}' already exists, skipping creation."
)
continue
try:
portfolio = Portfolio(
creator=user,
organization_name=portfolio_data["organization_name"],
)
cls._set_non_foreign_key_fields(portfolio, portfolio_data)
cls._set_foreign_key_fields(portfolio, portfolio_data, user)
portfolios_to_create.append(portfolio)
except Exception as e:
logger.warning(e)
# Bulk create domain requests
if len(portfolios_to_create) > 0:
try:
Portfolio.objects.bulk_create(portfolios_to_create)
logger.info(f"Successfully created {len(portfolios_to_create)} portfolios")
except Exception as e:
logger.warning(f"Error bulk creating portfolios: {e}")
logger.warning(f"Error bulk creating portfolios: {e}")

View file

@ -3,7 +3,6 @@ from django.utils import timezone
import logging
import random
from faker import Faker
from django.db import transaction
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
from registrar.fixtures.fixtures_suborganizations import SuborganizationFixture
@ -303,24 +302,17 @@ class DomainRequestFixture:
def load(cls):
"""Creates domain requests for each user in the database."""
logger.info("Going to load %s domain requests" % len(cls.DOMAINREQUESTS))
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
# The atomic block will cause the code to stop executing if one instance in the
# nested iteration fails, which will cause an early exit and make it hard to debug.
# Comment out with transaction.atomic() when debugging.
with transaction.atomic():
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
except Exception as e:
logger.warning(e)
return
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
except Exception as e:
logger.warning(e)
return
cls._create_domain_requests(users)
cls._create_domain_requests(users)
@classmethod
def _create_domain_requests(cls, users): # noqa: C901

View file

@ -1,6 +1,5 @@
import logging
from faker import Faker
from django.db import transaction
from registrar.models.portfolio import Portfolio
from registrar.models.suborganization import Suborganization
@ -34,14 +33,12 @@ class SuborganizationFixture:
def load(cls):
"""Creates suborganizations."""
logger.info(f"Going to load {len(cls.SUBORGS)} suborgs")
portfolios = cls._get_portfolios()
if not portfolios:
return
with transaction.atomic():
portfolios = cls._get_portfolios()
if not portfolios:
return
suborgs_to_create = cls._prepare_suborgs_to_create(portfolios)
cls._bulk_create_suborgs(suborgs_to_create)
suborgs_to_create = cls._prepare_suborgs_to_create(portfolios)
cls._bulk_create_suborgs(suborgs_to_create)
@classmethod
def _get_portfolios(cls):

View file

@ -1,7 +1,6 @@
import logging
import random
from faker import Faker
from django.db import transaction
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
from registrar.fixtures.fixtures_users import UserFixture
@ -26,56 +25,55 @@ class UserPortfolioPermissionFixture:
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
try:
# Get the usernames of users created in the UserFixture
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
# Filter users to only include those created by the fixture
users = list(User.objects.filter(username__in=created_usernames))
organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS]
organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS]
portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names))
portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names))
if not users:
logger.warning("User fixtures missing.")
return
if not portfolios:
logger.warning("Portfolio fixtures missing.")
return
except Exception as e:
logger.warning(e)
if not users:
logger.warning("User fixtures missing.")
return
user_portfolio_permissions_to_create = []
for user in users:
# Assign a random portfolio to a user
portfolio = random.choice(portfolios) # nosec
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)
if not portfolios:
logger.warning("Portfolio fixtures missing.")
return
# Bulk create permissions
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
except Exception as e:
logger.warning(e)
return
user_portfolio_permissions_to_create = []
for user in users:
# Assign a random portfolio to a user
portfolio = random.choice(portfolios) # nosec
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
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
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
@classmethod
def _bulk_create_permissions(cls, user_portfolio_permissions_to_create):

View file

@ -1,6 +1,5 @@
import logging
from faker import Faker
from django.db import transaction
from registrar.models import (
User,
@ -455,10 +454,9 @@ class UserFixture:
@classmethod
def load(cls):
with transaction.atomic():
cls.load_users(cls.ADMINS, "full_access_group", are_superusers=True)
cls.load_users(cls.STAFF, "cisa_analysts_group")
cls.load_users(cls.ADMINS, "full_access_group", are_superusers=True)
cls.load_users(cls.STAFF, "cisa_analysts_group")
# Combine ADMINS and STAFF lists
all_users = cls.ADMINS + cls.STAFF
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
# Combine ADMINS and STAFF lists
all_users = cls.ADMINS + cls.STAFF
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)

View file

@ -2,7 +2,8 @@ from __future__ import annotations # allows forward references in annotations
import logging
from api.views import DOMAIN_API_MESSAGES
from phonenumber_field.formfields import PhoneNumberField # type: ignore
from registrar.models.portfolio import Portfolio
from registrar.utility.waffle import flag_is_active_anywhere
from django import forms
from django.core.validators import RegexValidator, MaxLengthValidator
from django.utils.safestring import mark_safe
@ -321,7 +322,8 @@ class OrganizationContactForm(RegistrarForm):
# if it has been filled in when required.
# uncomment to see if modelChoiceField can be an arg later
required=False,
queryset=FederalAgency.objects.exclude(agency__in=excluded_agencies),
# We populate this queryset in init.
queryset=FederalAgency.objects.none(),
widget=ComboboxWidget,
)
organization_name = forms.CharField(
@ -363,6 +365,20 @@ class OrganizationContactForm(RegistrarForm):
label="Urbanization (required for Puerto Rico only)",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set the queryset for federal agency.
# If the organization_requests flag is active, We want to exclude agencies with a portfolio.
federal_agency_queryset = FederalAgency.objects.exclude(agency__in=self.excluded_agencies)
if flag_is_active_anywhere("organization_feature") and flag_is_active_anywhere("organization_requests"):
# Exclude both predefined agencies and those matching portfolio records in one query
federal_agency_queryset = federal_agency_queryset.exclude(
id__in=Portfolio.objects.values_list("federal_agency__id", flat=True)
)
self.fields["federal_agency"].queryset = federal_agency_queryset
def clean_federal_agency(self):
"""Require something to be selected when this is a federal agency."""
federal_agency = self.cleaned_data.get("federal_agency", None)

View file

@ -312,6 +312,32 @@ class BasePortfolioMemberForm(forms.ModelForm):
self.initial["domain_permissions"] = selected_domain_permission
self.initial["member_permissions"] = selected_member_permission
def is_change_from_member_to_admin(self) -> bool:
"""
Checks if the roles have changed from not containing ORGANIZATION_ADMIN
to containing ORGANIZATION_ADMIN.
"""
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change
return (
UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in previous_roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in new_roles
)
def is_change_from_admin_to_member(self) -> bool:
"""
Checks if the roles have changed from containing ORGANIZATION_ADMIN
to not containing ORGANIZATION_ADMIN.
"""
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change
return (
UserPortfolioRoleChoices.ORGANIZATION_ADMIN in previous_roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)
class PortfolioMemberForm(BasePortfolioMemberForm):
"""

View file

@ -149,9 +149,9 @@ class Command(BaseCommand):
)
return
with transaction.atomic():
# Try to delete the portfolios
try:
# Try to delete the portfolios
try:
with transaction.atomic():
summary = []
for portfolio in portfolios_to_delete:
portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"]
@ -222,14 +222,14 @@ class Command(BaseCommand):
"""
)
except IntegrityError as e:
logger.info(
f"""{TerminalColors.FAIL}
Could not delete some portfolios due to integrity constraints:
{e}
{TerminalColors.ENDC}
"""
)
except IntegrityError as e:
logger.info(
f"""{TerminalColors.FAIL}
Could not delete some portfolios due to integrity constraints:
{e}
{TerminalColors.ENDC}
"""
)
def handle(self, *args, **options):
# Get all Portfolio entries not in the allowed portfolios list

View file

@ -0,0 +1,60 @@
# Generated by Django 4.2.10 on 2025-02-04 11:18
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0139_alter_domainrequest_action_needed_reason"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_members", "View members"),
("edit_members", "Create and edit members"),
("view_all_requests", "View all requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_members", "View members"),
("edit_members", "Create and edit members"),
("view_all_requests", "View all requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
]

View file

@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
from django.db import models, IntegrityError
from django.utils import timezone
from typing import Any
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.host import Host
from registrar.models.host_ip import HostIP
from registrar.utility.enums import DefaultEmail
@ -1177,6 +1178,10 @@ class Domain(TimeStampedModel, DomainHelper):
return "DNS needed"
return self.state.capitalize()
def active_invitations(self):
"""Returns only the active invitations (those with status 'invited')."""
return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED)
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
"""Maps the Epp contact representation to a PublicContact object.

View file

@ -15,8 +15,7 @@ from registrar.utility.constants import BranchChoices
from auditlog.models import LogEntry
from django.core.exceptions import ValidationError
from registrar.utility.waffle import flag_is_active_for_user
from registrar.utility.waffle import flag_is_active_for_user, flag_is_active_anywhere
from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain
@ -947,7 +946,7 @@ class DomainRequest(TimeStampedModel):
try:
if not context:
has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature")
is_org_user = has_organization_feature_flag and recipient.has_base_portfolio_permission(self.portfolio)
is_org_user = has_organization_feature_flag and recipient.has_view_portfolio_permission(self.portfolio)
context = {
"domain_request": self,
# This is the user that we refer to in the email
@ -1299,6 +1298,40 @@ class DomainRequest(TimeStampedModel):
return True
return False
def unlock_organization_contact(self) -> bool:
"""Unlocks the organization_contact step."""
if flag_is_active_anywhere("organization_feature") and flag_is_active_anywhere("organization_requests"):
# Check if the current federal agency is an outlawed one
if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_agency:
Portfolio = apps.get_model("registrar.Portfolio")
return (
FederalAgency.objects.exclude(
id__in=Portfolio.objects.values_list("federal_agency__id", flat=True),
)
.filter(id=self.federal_agency.id)
.exists()
)
return bool(
self.federal_agency is not None
or self.organization_name is not None
or self.address_line1 is not None
or self.city is not None
or self.state_territory is not None
or self.zipcode is not None
or self.urbanization is not None
)
def unlock_other_contacts(self) -> bool:
"""Unlocks the other contacts step"""
other_contacts_filled_out = self.other_contacts.filter(
first_name__isnull=False,
last_name__isnull=False,
title__isnull=False,
email__isnull=False,
phone__isnull=False,
).exists()
return (self.has_other_contacts() and other_contacts_filled_out) or self.no_other_contacts_rationale is not None
# ## Form policies ## #
#
# These methods control what questions need to be answered by applicants
@ -1396,140 +1429,6 @@ class DomainRequest(TimeStampedModel):
names = [n for n in [self.cisa_representative_first_name, self.cisa_representative_last_name] if n]
return " ".join(names) if names else "Unknown"
def _is_federal_complete(self):
# Federal -> "Federal government branch" page can't be empty + Federal Agency selection can't be None
return not (self.federal_type is None or self.federal_agency is None)
def _is_interstate_complete(self):
# Interstate -> "About your organization" page can't be empty
return self.about_your_organization is not None
def _is_state_or_territory_complete(self):
# State -> ""Election office" page can't be empty
return self.is_election_board is not None
def _is_tribal_complete(self):
# Tribal -> "Tribal name" and "Election office" page can't be empty
return self.tribe_name is not None and self.is_election_board is not None
def _is_county_complete(self):
# County -> "Election office" page can't be empty
return self.is_election_board is not None
def _is_city_complete(self):
# City -> "Election office" page can't be empty
return self.is_election_board is not None
def _is_special_district_complete(self):
# Special District -> "Election office" and "About your organization" page can't be empty
return self.is_election_board is not None and self.about_your_organization is not None
# Do we still want to test this after creator is autogenerated? Currently it went back to being selectable
def _is_creator_complete(self):
return self.creator is not None
def _is_organization_name_and_address_complete(self):
return not (
self.organization_name is None
and self.address_line1 is None
and self.city is None
and self.state_territory is None
and self.zipcode is None
)
def _is_senior_official_complete(self):
return self.senior_official is not None
def _is_requested_domain_complete(self):
return self.requested_domain is not None
def _is_purpose_complete(self):
return self.purpose is not None
def _has_other_contacts_and_filled(self):
# Other Contacts Radio button is Yes and if all required fields are filled
return (
self.has_other_contacts()
and self.other_contacts.filter(
first_name__isnull=False,
last_name__isnull=False,
title__isnull=False,
email__isnull=False,
phone__isnull=False,
).exists()
)
def _has_no_other_contacts_gives_rationale(self):
# Other Contacts Radio button is No and a rationale is provided
return self.has_other_contacts() is False and self.no_other_contacts_rationale is not None
def _is_other_contacts_complete(self):
if self._has_other_contacts_and_filled() or self._has_no_other_contacts_gives_rationale():
return True
return False
def _cisa_rep_check(self):
# Either does not have a CISA rep, OR has a CISA rep + both first name
# and last name are NOT empty and are NOT an empty string
to_return = (
self.has_cisa_representative is True
and self.cisa_representative_first_name is not None
and self.cisa_representative_first_name != ""
and self.cisa_representative_last_name is not None
and self.cisa_representative_last_name != ""
) or self.has_cisa_representative is False
return to_return
def _anything_else_radio_button_and_text_field_check(self):
# Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No
return (
self.has_anything_else_text is True and self.anything_else is not None and self.anything_else != ""
) or self.has_anything_else_text is False
def _is_additional_details_complete(self):
return self._cisa_rep_check() and self._anything_else_radio_button_and_text_field_check()
def _is_policy_acknowledgement_complete(self):
return self.is_policy_acknowledged is not None
def _is_general_form_complete(self, request):
return (
self._is_creator_complete()
and self._is_organization_name_and_address_complete()
and self._is_senior_official_complete()
and self._is_requested_domain_complete()
and self._is_purpose_complete()
and self._is_other_contacts_complete()
and self._is_additional_details_complete()
and self._is_policy_acknowledgement_complete()
)
def _form_complete(self, request):
match self.generic_org_type:
case DomainRequest.OrganizationChoices.FEDERAL:
is_complete = self._is_federal_complete()
case DomainRequest.OrganizationChoices.INTERSTATE:
is_complete = self._is_interstate_complete()
case DomainRequest.OrganizationChoices.STATE_OR_TERRITORY:
is_complete = self._is_state_or_territory_complete()
case DomainRequest.OrganizationChoices.TRIBAL:
is_complete = self._is_tribal_complete()
case DomainRequest.OrganizationChoices.COUNTY:
is_complete = self._is_county_complete()
case DomainRequest.OrganizationChoices.CITY:
is_complete = self._is_city_complete()
case DomainRequest.OrganizationChoices.SPECIAL_DISTRICT:
is_complete = self._is_special_district_complete()
case DomainRequest.OrganizationChoices.SCHOOL_DISTRICT:
is_complete = True
case _:
# NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type
is_complete = False
if not is_complete or not self._is_general_form_complete(request):
return False
return True
"""The following converted_ property methods get field data from this domain request's portfolio,
if there is an associated portfolio. If not, they return data from the domain request model."""

View file

@ -210,10 +210,10 @@ class User(AbstractUser):
return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
def has_base_portfolio_permission(self, portfolio):
def has_view_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_edit_org_portfolio_permission(self, portfolio):
def has_edit_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
def has_any_domains_portfolio_permission(self, portfolio):
@ -268,13 +268,6 @@ class User(AbstractUser):
def has_edit_request_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
# Field specific permission checks
def has_view_suborganization_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
def has_edit_suborganization_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
def is_portfolio_admin(self, portfolio):
return "Admin" in self.portfolio_role_summary(portfolio)
@ -293,7 +286,7 @@ class User(AbstractUser):
# Define the conditions and their corresponding roles
conditions_roles = [
(self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]),
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio)
@ -306,20 +299,20 @@ class User(AbstractUser):
["View-only admin"],
),
(
self.has_base_portfolio_permission(portfolio)
self.has_view_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio)
and self.has_any_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(
self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
["Domain requestor"],
),
(
self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_base_portfolio_permission(portfolio), ["Member"]),
(self.has_view_portfolio_permission(portfolio), ["Member"]),
]
# Evaluate conditions and add roles
@ -477,7 +470,7 @@ class User(AbstractUser):
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
portfolio = request.session.get("portfolio")
return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
return has_organization_feature_flag and self.has_view_portfolio_permission(portfolio)
def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""

View file

@ -27,13 +27,10 @@ class UserPortfolioPermission(TimeStampedModel):
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
# NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here.
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
],
}
@ -43,7 +40,6 @@ class UserPortfolioPermission(TimeStampedModel):
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
}

View file

@ -1,5 +1,6 @@
from registrar.utility import StrEnum
from django.db import models
from django.db.models import Q
from django.apps import apps
from django.forms import ValidationError
from registrar.utility.waffle import flag_is_active_for_user
@ -41,10 +42,6 @@ class UserPortfolioPermissionChoices(models.TextChoices):
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
# Domain: field specific permissions
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"
@classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
return cls(user_portfolio_permission).label if user_portfolio_permission else None
@ -136,9 +133,10 @@ def validate_user_portfolio_permission(user_portfolio_permission):
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)
existing_invitations = PortfolioInvitation.objects.exclude(
portfolio=user_portfolio_permission.portfolio
).filter(email=user_portfolio_permission.user.email)
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude(
Q(portfolio=user_portfolio_permission.portfolio)
| Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
)
if existing_invitations.exists():
raise ValidationError(
"This user is already assigned to a portfolio invitation. "
@ -195,8 +193,8 @@ def validate_portfolio_invitation(portfolio_invitation):
if not flag_is_active_for_user(user, "multiple_portfolios"):
existing_permissions = UserPortfolioPermission.objects.filter(user=user)
existing_invitations = PortfolioInvitation.objects.exclude(id=portfolio_invitation.id).filter(
email=portfolio_invitation.email
existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude(
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
)
if existing_permissions.exists():

View file

@ -0,0 +1,16 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'admin/delete_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'admin/delete_selected_confirmation.html' %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -103,12 +103,12 @@
{% endif %}
{% if portfolio %}
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
{% elif has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %}
{% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_suborganization_portfolio_permission view_button=True %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %}
{% endif %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}

View file

@ -61,7 +61,7 @@
{% if portfolio %}
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
{% if has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
{% if has_any_domains_portfolio_permission and has_view_portfolio_permission %}
{% with url_name="domain-suborganization" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}

View file

@ -39,7 +39,7 @@
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.sub_organization %}

View file

@ -0,0 +1,27 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
A domain manager was removed from {{ domain.name }}.
REMOVED BY: {{ removed_by.email }}
REMOVED ON: {{ date }}
MANAGER REMOVED: {{ manager_removed.email }}
----------------------------------------------------------------
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever a domain manager is removed from that domain.
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
A domain manager was removed from {{ domain.name }}

View file

@ -0,0 +1,40 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}
An admin was invited to your .gov organization.
ORGANIZATION: {{ portfolio.organization_name }}
INVITED BY: {{ requestor_email }}
INVITED ON: {{date}}
ADMIN INVITED: {{ invited_email_address }}
----------------------------------------------------------------
NEXT STEPS
The person who received the invitation will become an admin once they log in to the
.gov registrar. They'll need to access the registrar using a Login.gov account that's
associated with the invited email address.
If you need to cancel this invitation or remove the admin, you can do that by going to
the Members section for your organization <https://manage.get.gov/>.
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
whenever a new admin is invited to that organization.
If you have questions or concerns, reach out to the person who sent the invitation or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
An admin was invited to your .gov organization

View file

@ -0,0 +1,33 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}
An admin was removed from your .gov organization.
ORGANIZATION: {{ portfolio.organization_name }}
REMOVED BY: {{ requestor_email }}
REMOVED ON: {{date}}
ADMIN REMOVED: {{ removed_email_address }}
You can view this update by going to the Members section for your .gov organization <https://manage.get.gov/>.
----------------------------------------------------------------
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
whenever an admin is removed from that organization.
If you have questions or concerns, reach out to the person who removed the admin or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
An admin was removed from your .gov organization

View file

@ -208,7 +208,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if portfolio and has_view_suborganization_portfolio_permission %}
{% if portfolio and has_view_portfolio_permission %}
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th

View file

@ -41,7 +41,7 @@
{% endif %}
{% if step == Step.ORGANIZATION_CONTACT %}
{% if domain_request.organization_name %}
{% if domain_request.unlock_organization_contact %}
{% with title=form_titles|get_item:step value=domain_request %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
{% endwith %}
@ -116,7 +116,7 @@
{% endif %}
{% if step == Step.OTHER_CONTACTS %}
{% if domain_request.other_contacts.all %}
{% if domain_request.unlock_other_contacts %}
{% with title=form_titles|get_item:step value=domain_request.other_contacts.all %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url contact='true' list='true' %}
{% endwith %}

View file

@ -113,10 +113,10 @@
</ul>
{% endif %}
{% endif %}
{% if value.invitations.all %}
{% if value.active_invitations.all %}
<h4 class="margin-bottom-05">Invited domain managers</h4>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% for item in value.invitations.all %}
{% for item in value.active_invitations.all %}
<li>{{ item.email }}</li>
{% endfor %}
</ul>

View file

@ -28,7 +28,7 @@
<p>The name of your organization will be publicly listed as the domain registrant.</p>
{% if has_edit_org_portfolio_permission %}
{% if has_edit_portfolio_permission %}
<p>
Your organization name cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.

View file

@ -120,7 +120,7 @@ class TestFsmModelResource(TestCase):
fsm_field_mock.save.assert_not_called()
class TestDomainInvitationAdmin(TestCase):
class TestDomainInvitationAdmin(WebTest):
"""Tests for the DomainInvitationAdmin class as super user
Notes:
@ -128,15 +128,27 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin
"""
def setUp(self):
# csrf checks do not work with WebTest.
# We disable them here. TODO for another ticket.
csrf_checks = False
@classmethod
def setUpClass(self):
super().setUpClass()
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.superuser = create_superuser()
def setUp(self):
super().setUp()
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.domain = Domain.objects.create(name="example.com")
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
self.client.force_login(self.superuser)
self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all DomainInvitation objects"""
@ -254,6 +266,7 @@ class TestDomainInvitationAdmin(TestCase):
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert success message
@ -504,6 +517,7 @@ class TestDomainInvitationAdmin(TestCase):
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert retrieve on domain invite only was called
@ -567,6 +581,7 @@ class TestDomainInvitationAdmin(TestCase):
email="test@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert retrieve on domain invite only was called
@ -693,6 +708,7 @@ class TestDomainInvitationAdmin(TestCase):
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert retrieve was not called
@ -918,6 +934,7 @@ class TestDomainInvitationAdmin(TestCase):
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert retrieve on domain invite only was called
@ -979,6 +996,7 @@ class TestDomainInvitationAdmin(TestCase):
email="nonexistent@example.com",
requestor=self.superuser,
portfolio=self.portfolio,
is_admin_invitation=False,
)
# Assert retrieve on domain invite only was called
@ -1065,6 +1083,50 @@ class TestDomainInvitationAdmin(TestCase):
self.assertEqual(DomainInvitation.objects.count(), 0)
self.assertEqual(PortfolioInvitation.objects.count(), 1)
@less_console_noise_decorator
def test_custom_delete_confirmation_page(self):
"""Tests if custom alerts display on Domain Invitation delete page"""
self.client.force_login(self.superuser)
self.app.set_user(self.superuser.username)
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
domain_invitation_change_page = self.app.get(
reverse("admin:registrar_domaininvitation_change", args=[domain_invitation.pk])
)
self.assertContains(domain_invitation_change_page, "domain-invitation-test.gov")
# click the "Delete" link
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
custom_alert_content = "If you cancel the domain invitation here"
self.assertContains(confirmation_page, custom_alert_content)
@less_console_noise_decorator
def test_custom_selected_delete_confirmation_page(self):
"""Tests if custom alerts display on Domain Invitation selected delete page from Domain Invitation table"""
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
# Get the index. The post expects the index to be encoded as a string
index = f"{domain_invitation.id}"
test_helper = GenericTestHelper(
factory=self.factory,
user=self.superuser,
admin=self.admin,
url=reverse("admin:registrar_domaininvitation_changelist"),
model=Domain,
client=self.client,
)
# Simulate selecting a single record, then clicking "Delete selected domains"
response = test_helper.get_table_delete_confirmation_page("0", index)
# Check for custom alert message
custom_alert_content = "If you cancel the domain invitation here"
self.assertContains(response, custom_alert_content)
class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class"""
@ -1204,7 +1266,7 @@ class TestPortfolioInvitationAdmin(TestCase):
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.success") # Mock the `messages.warning` call
@patch("django.contrib.messages.success") # Mock the `messages.success` call
def test_save_sends_email(self, mock_messages_success, mock_send_email):
"""On save_model, an email is sent if an invitation already exists."""
@ -1455,6 +1517,94 @@ class TestPortfolioInvitationAdmin(TestCase):
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(request, "Could not send email invitation.")
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_admin_addition_emails")
def test_save_existing_sends_email_notification(self, mock_send_email):
"""On save_model to an existing invitation, an email is set to notify existing
admins, if the invitation changes from member to admin."""
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Mock the response value of the email send
mock_send_email.return_value = True
# Create and save a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation.objects.create(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
)
# Create a request object
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
request.user = self.superuser
# Change roles from MEMBER to ADMIN
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, True)
# Assert that send_portfolio_admin_addition_emails is called
mock_send_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
self.assertEqual(called_kwargs["requestor"], self.superuser)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_admin_addition_emails")
@patch("django.contrib.messages.warning") # Mock the `messages.warning` call
def test_save_existing_email_notification_warning(self, mock_messages_warning, mock_send_email):
"""On save_model for an existing invitation, a warning is displayed if method to
send email to notify admins returns False."""
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Mock the response value of the email send
mock_send_email.return_value = False
# Create and save a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation.objects.create(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
)
# Create a request object
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
request.user = self.superuser
# Change roles from MEMBER to ADMIN
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, True)
# Assert that send_portfolio_admin_addition_emails is called
mock_send_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
self.assertEqual(called_kwargs["requestor"], self.superuser)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Assert that messages.error was called with the correct message
mock_messages_warning.assert_called_once_with(
request, "Could not send email notification to existing organization admins."
)
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
@ -1922,7 +2072,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
class TestUserDomainRoleAdmin(TestCase):
class TestUserDomainRoleAdmin(WebTest):
"""Tests for the UserDomainRoleAdmin class as super user
Notes:
@ -1949,6 +2099,8 @@ class TestUserDomainRoleAdmin(TestCase):
"""Setup environment for a mock admin user"""
super().setUp()
self.client = Client(HTTP_HOST="localhost:8080")
self.client.force_login(self.superuser)
self.app.set_user(self.superuser.username)
def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles"""
@ -2111,6 +2263,48 @@ class TestUserDomainRoleAdmin(TestCase):
# We only need to check for the end of the HTML string
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
@less_console_noise_decorator
def test_custom_delete_confirmation_page(self):
"""Tests if custom alerts display on User Domain Role delete page"""
domain, _ = Domain.objects.get_or_create(name="user-domain-role-test.gov", state=Domain.State.READY)
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
domain_invitation_change_page = self.app.get(
reverse("admin:registrar_userdomainrole_change", args=[domain_role.pk])
)
self.assertContains(domain_invitation_change_page, "user-domain-role-test.gov")
# click the "Delete" link
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
custom_alert_content = "If you remove someone from a domain here"
self.assertContains(confirmation_page, custom_alert_content)
@less_console_noise_decorator
def test_custom_selected_delete_confirmation_page(self):
"""Tests if custom alerts display on selected delete page from User Domain Roles table"""
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
# Get the index. The post expects the index to be encoded as a string
index = f"{domain_role.id}"
test_helper = GenericTestHelper(
factory=self.factory,
user=self.superuser,
admin=self.admin,
url=reverse("admin:registrar_userdomainrole_changelist"),
model=Domain,
client=self.client,
)
# Simulate selecting a single record, then clicking "Delete selected domains"
response = test_helper.get_table_delete_confirmation_page("0", index)
# Check for custom alert message
custom_alert_content = "If you remove someone from a domain here"
self.assertContains(response, custom_alert_content)
class TestListHeaderAdmin(TestCase):
"""Tests for the ListHeaderAdmin class as super user

View file

@ -2,12 +2,24 @@ import unittest
from unittest.mock import patch, MagicMock
from datetime import date
from registrar.models.domain import Domain
from registrar.models.portfolio import Portfolio
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_domain_invitation_email, send_emails_to_domain_managers
from registrar.utility.email_invitations import (
_send_portfolio_admin_addition_emails_to_portfolio_admins,
_send_portfolio_admin_removal_emails_to_portfolio_admins,
send_domain_invitation_email,
send_emails_to_domain_managers,
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
)
from api.tests.common import less_console_noise_decorator
from registrar.utility.errors import MissingEmailError
class DomainInvitationEmail(unittest.TestCase):
@ -16,9 +28,9 @@ class DomainInvitationEmail(unittest.TestCase):
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
@patch("registrar.utility.email_invitations._validate_invitation")
@patch("registrar.utility.email_invitations.get_requestor_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.send_invitation_email")
@patch("registrar.utility.email_invitations.normalize_domains")
@patch("registrar.utility.email_invitations._normalize_domains")
def test_send_domain_invitation_email(
self,
mock_normalize_domains,
@ -58,7 +70,7 @@ class DomainInvitationEmail(unittest.TestCase):
# Assertions
mock_normalize_domains.assert_called_once_with(mock_domain)
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
mock_validate_invitation.assert_called_once_with(
email, None, [mock_domain], mock_requestor, is_member_of_different_org
)
@ -81,9 +93,9 @@ class DomainInvitationEmail(unittest.TestCase):
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
@patch("registrar.utility.email_invitations._validate_invitation")
@patch("registrar.utility.email_invitations.get_requestor_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.send_invitation_email")
@patch("registrar.utility.email_invitations.normalize_domains")
@patch("registrar.utility.email_invitations._normalize_domains")
def test_send_domain_invitation_email_multiple_domains(
self,
mock_normalize_domains,
@ -137,7 +149,7 @@ class DomainInvitationEmail(unittest.TestCase):
# Assertions
mock_normalize_domains.assert_called_once_with([mock_domain1, mock_domain2])
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain1, mock_domain2])
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain1, mock_domain2])
mock_validate_invitation.assert_called_once_with(
email, None, [mock_domain1, mock_domain2], mock_requestor, is_member_of_different_org
)
@ -197,7 +209,7 @@ class DomainInvitationEmail(unittest.TestCase):
mock_validate_invitation.assert_called_once()
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.get_requestor_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
def test_send_domain_invitation_email_raises_get_requestor_email_exception(self, mock_get_requestor_email):
"""Test sending domain invitation email for one domain and assert exception
when get_requestor_email fails.
@ -217,9 +229,9 @@ class DomainInvitationEmail(unittest.TestCase):
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._validate_invitation")
@patch("registrar.utility.email_invitations.get_requestor_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.send_invitation_email")
@patch("registrar.utility.email_invitations.normalize_domains")
@patch("registrar.utility.email_invitations._normalize_domains")
def test_send_domain_invitation_email_raises_sending_email_exception(
self,
mock_normalize_domains,
@ -258,7 +270,7 @@ class DomainInvitationEmail(unittest.TestCase):
# Assertions
mock_normalize_domains.assert_called_once_with(mock_domain)
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
mock_validate_invitation.assert_called_once_with(
email, None, [mock_domain], mock_requestor, is_member_of_different_org
)
@ -267,9 +279,9 @@ class DomainInvitationEmail(unittest.TestCase):
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_emails_to_domain_managers")
@patch("registrar.utility.email_invitations._validate_invitation")
@patch("registrar.utility.email_invitations.get_requestor_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.send_invitation_email")
@patch("registrar.utility.email_invitations.normalize_domains")
@patch("registrar.utility.email_invitations._normalize_domains")
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
self,
mock_normalize_domains,
@ -306,7 +318,7 @@ class DomainInvitationEmail(unittest.TestCase):
# Assertions
mock_normalize_domains.assert_called_once_with(mock_domain)
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
mock_validate_invitation.assert_called_once_with(
email, None, [mock_domain], mock_requestor, is_member_of_different_org
)
@ -469,3 +481,410 @@ class DomainInvitationEmail(unittest.TestCase):
"date": date.today(),
},
)
class PortfolioInvitationEmailTests(unittest.TestCase):
def setUp(self):
"""Setup common test data for all test cases"""
self.email = "invitee@example.com"
self.requestor = MagicMock(name="User")
self.requestor.email = "requestor@example.com"
self.portfolio = MagicMock(name="Portfolio")
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
def test_send_portfolio_invitation_email_success(self, mock_send_templated_email):
"""Test successful email sending"""
is_admin_invitation = False
result = send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
self.assertTrue(result)
mock_send_templated_email.assert_called_once()
@less_console_noise_decorator
@patch(
"registrar.utility.email_invitations.send_templated_email",
side_effect=EmailSendingError("Failed to send email"),
)
def test_send_portfolio_invitation_email_failure(self, mock_send_templated_email):
"""Test failure when sending email"""
is_admin_invitation = False
with self.assertRaises(EmailSendingError) as context:
send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
self.assertIn("Could not sent email invitation to", str(context.exception))
@less_console_noise_decorator
@patch(
"registrar.utility.email_invitations._get_requestor_email",
side_effect=MissingEmailError("Requestor has no email"),
)
@less_console_noise_decorator
def test_send_portfolio_invitation_email_missing_requestor_email(self, mock_get_email):
"""Test when requestor has no email"""
is_admin_invitation = False
with self.assertRaises(MissingEmailError) as context:
send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
self.assertIn(
"Can't send invitation email. No email is associated with your user account.", str(context.exception)
)
@less_console_noise_decorator
@patch(
"registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins",
return_value=False,
)
@patch("registrar.utility.email_invitations.send_templated_email")
def test_send_portfolio_invitation_email_admin_invitation(self, mock_send_templated_email, mock_admin_email):
"""Test admin invitation email logic"""
is_admin_invitation = True
result = send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
self.assertFalse(result) # Admin email sending failed
mock_send_templated_email.assert_called_once()
mock_admin_email.assert_called_once()
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins")
def test_send_email_success(self, mock_send_admin_emails, mock_get_requestor_email):
"""Test successful sending of admin addition emails."""
mock_get_requestor_email.return_value = "requestor@example.com"
mock_send_admin_emails.return_value = True
result = send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_admin_emails.assert_called_once_with(self.email, "requestor@example.com", self.portfolio)
self.assertTrue(result)
@less_console_noise_decorator
@patch(
"registrar.utility.email_invitations._get_requestor_email",
side_effect=MissingEmailError("Requestor email missing"),
)
def test_missing_requestor_email_raises_exception(self, mock_get_requestor_email):
"""Test exception raised if requestor email is missing."""
with self.assertRaises(MissingEmailError):
send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins")
def test_send_email_failure(self, mock_send_admin_emails, mock_get_requestor_email):
"""Test handling of failure in sending admin addition emails."""
mock_get_requestor_email.return_value = "requestor@example.com"
mock_send_admin_emails.return_value = False # Simulate failure
result = send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
self.assertFalse(result)
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_admin_emails.assert_called_once_with(self.email, "requestor@example.com", self.portfolio)
class SendPortfolioAdminAdditionEmailsTests(unittest.TestCase):
"""Unit tests for _send_portfolio_admin_addition_emails_to_portfolio_admins function."""
def setUp(self):
"""Set up test data."""
self.email = "new.admin@example.com"
self.requestor_email = "requestor@example.com"
self.portfolio = MagicMock(spec=Portfolio)
self.portfolio.organization_name = "Test Organization"
# Mock portfolio admin users
self.admin_user1 = MagicMock(spec=User)
self.admin_user1.email = "admin1@example.com"
self.admin_user2 = MagicMock(spec=User)
self.admin_user2.email = "admin2@example.com"
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
self.portfolio_admin1.user = self.admin_user1
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
self.portfolio_admin2.user = self.admin_user2
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_send_email_success(self, mock_filter, mock_send_templated_email):
"""Test successful sending of admin addition emails."""
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
mock_send_templated_email.return_value = None # No exception means success
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_addition_notification.txt",
"emails/portfolio_admin_addition_notification_subject.txt",
to_address=self.admin_user1.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"invited_email_address": self.email,
"portfolio_admin": self.admin_user1,
"date": date.today(),
},
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_addition_notification.txt",
"emails/portfolio_admin_addition_notification_subject.txt",
to_address=self.admin_user2.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"invited_email_address": self.email,
"portfolio_admin": self.admin_user2,
"date": date.today(),
},
)
self.assertTrue(result)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
"""Test handling of failure in sending admin addition emails."""
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
self.assertFalse(result)
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_addition_notification.txt",
"emails/portfolio_admin_addition_notification_subject.txt",
to_address=self.admin_user1.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"invited_email_address": self.email,
"portfolio_admin": self.admin_user1,
"date": date.today(),
},
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_addition_notification.txt",
"emails/portfolio_admin_addition_notification_subject.txt",
to_address=self.admin_user2.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"invited_email_address": self.email,
"portfolio_admin": self.admin_user2,
"date": date.today(),
},
)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_no_admins_to_notify(self, mock_filter):
"""Test case where there are no portfolio admins to notify."""
mock_filter.return_value.exclude.return_value = [] # No admins
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
self.assertTrue(result) # No emails sent, but also no failures
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
class SendPortfolioAdminRemovalEmailsToAdminsTests(unittest.TestCase):
"""Unit tests for _send_portfolio_admin_removal_emails_to_portfolio_admins function."""
def setUp(self):
"""Set up test data."""
self.email = "removed.admin@example.com"
self.requestor_email = "requestor@example.com"
self.portfolio = MagicMock(spec=Portfolio)
self.portfolio.organization_name = "Test Organization"
# Mock portfolio admin users
self.admin_user1 = MagicMock(spec=User)
self.admin_user1.email = "admin1@example.com"
self.admin_user2 = MagicMock(spec=User)
self.admin_user2.email = "admin2@example.com"
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
self.portfolio_admin1.user = self.admin_user1
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
self.portfolio_admin2.user = self.admin_user2
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_send_email_success(self, mock_filter, mock_send_templated_email):
"""Test successful sending of admin removal emails."""
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
mock_send_templated_email.return_value = None # No exception means success
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_removal_notification.txt",
"emails/portfolio_admin_removal_notification_subject.txt",
to_address=self.admin_user1.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"removed_email_address": self.email,
"portfolio_admin": self.admin_user1,
"date": date.today(),
},
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_removal_notification.txt",
"emails/portfolio_admin_removal_notification_subject.txt",
to_address=self.admin_user2.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"removed_email_address": self.email,
"portfolio_admin": self.admin_user2,
"date": date.today(),
},
)
self.assertTrue(result)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
"""Test handling of failure in sending admin removal emails."""
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
self.assertFalse(result)
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_removal_notification.txt",
"emails/portfolio_admin_removal_notification_subject.txt",
to_address=self.admin_user1.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"removed_email_address": self.email,
"portfolio_admin": self.admin_user1,
"date": date.today(),
},
)
mock_send_templated_email.assert_any_call(
"emails/portfolio_admin_removal_notification.txt",
"emails/portfolio_admin_removal_notification_subject.txt",
to_address=self.admin_user2.email,
context={
"portfolio": self.portfolio,
"requestor_email": self.requestor_email,
"removed_email_address": self.email,
"portfolio_admin": self.admin_user2,
"date": date.today(),
},
)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
def test_no_admins_to_notify(self, mock_filter):
"""Test case where there are no portfolio admins to notify."""
mock_filter.return_value.exclude.return_value = [] # No admins
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
self.email, self.requestor_email, self.portfolio
)
self.assertTrue(result) # No emails sent, but also no failures
mock_filter.assert_called_once_with(
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
"""Unit tests for send_portfolio_admin_removal_emails function."""
def setUp(self):
"""Set up test data."""
self.email = "removed.admin@example.com"
self.requestor = MagicMock(spec=User)
self.requestor.email = "requestor@example.com"
self.portfolio = MagicMock(spec=Portfolio)
self.portfolio.organization_name = "Test Organization"
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins")
def test_send_email_success(self, mock_send_removal_emails, mock_get_requestor_email):
"""Test successful execution of send_portfolio_admin_removal_emails."""
mock_get_requestor_email.return_value = self.requestor.email
mock_send_removal_emails.return_value = True # Simulating success
result = send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
self.assertTrue(result)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=MissingEmailError("No email found"))
@patch("registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins")
def test_missing_email_error(self, mock_send_removal_emails, mock_get_requestor_email):
"""Test handling of MissingEmailError when requestor has no email."""
with self.assertRaises(MissingEmailError) as context:
send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_not_called() # Should not proceed if email retrieval fails
self.assertEqual(
str(context.exception), "Can't send invitation email. No email is associated with your user account."
)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch(
"registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins",
return_value=False,
)
def test_send_email_failure(self, mock_send_removal_emails, mock_get_requestor_email):
"""Test handling of failure when admin removal emails fail to send."""
mock_get_requestor_email.return_value = self.requestor.email
mock_send_removal_emails.return_value = False # Simulating failure
result = send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
self.assertFalse(result)

View file

@ -24,6 +24,7 @@ from registrar.forms.portfolio import (
PortfolioMemberForm,
PortfolioNewMemberForm,
)
from waffle.models import get_waffle_flag_model
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
@ -39,6 +40,10 @@ class TestFormValidation(MockEppLib):
self.API_BASE_PATH = "/api/v1/available/?domain="
self.user = get_user_model().objects.create(username="username")
self.factory = RequestFactory()
# We use both of these flags in the test. In the normal app these are generated normally.
# The alternative syntax is adding the decorator to each test.
get_waffle_flag_model().objects.get_or_create(name="organization_feature")
get_waffle_flag_model().objects.get_or_create(name="organization_requests")
@less_console_noise_decorator
def test_org_contact_zip_invalid(self):

View file

@ -1,9 +1,10 @@
from django.forms import ValidationError
from django.test import TestCase
from unittest.mock import patch
from unittest.mock import Mock
from django.test import RequestFactory
from waffle.models import get_waffle_flag_model
from registrar.views.domain_request import DomainRequestWizard
from registrar.models import (
Contact,
DomainRequest,
@ -1190,8 +1191,8 @@ class TestUser(TestCase):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_suborganization_portfolio_permission", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
@patch.object(User, "has_edit_portfolio_permission", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_org):
# Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@ -1216,7 +1217,7 @@ class TestUser(TestCase):
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_view_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
has_any_domains_portfolio_permission=lambda self, portfolio: True,
)
@ -1226,7 +1227,7 @@ class TestUser(TestCase):
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_view_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor(self):
@ -1235,14 +1236,14 @@ class TestUser(TestCase):
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_view_portfolio_permission=lambda self, portfolio: True,
has_any_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
@patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
def test_portfolio_role_summary_member(self):
# Test if the user is recognized as a Member
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
@ -1252,17 +1253,17 @@ class TestUser(TestCase):
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@patch("registrar.models.User._has_portfolio_permission")
def test_has_base_portfolio_permission(self, mock_has_permission):
def test_has_view_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_base_portfolio_permission(self.portfolio))
self.assertTrue(self.user.has_view_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_edit_org_portfolio_permission(self, mock_has_permission):
def test_has_edit_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_edit_org_portfolio_permission(self.portfolio))
self.assertTrue(self.user.has_edit_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
@patch("registrar.models.User._has_portfolio_permission")
@ -1305,20 +1306,6 @@ class TestUser(TestCase):
self.assertTrue(self.user.has_edit_request_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_suborganization_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_view_suborganization_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_edit_suborganization_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_edit_suborganization_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
@less_console_noise_decorator
def test_check_transition_domains_without_domains_on_login(self):
"""A user's on_each_login callback does not check transition domains.
@ -2105,11 +2092,20 @@ class TestDomainRequestIncomplete(TestCase):
anything_else="Anything else",
is_policy_acknowledged=True,
creator=self.user,
city="fake",
)
self.domain_request.other_contacts.add(other)
self.domain_request.current_websites.add(current)
self.domain_request.alternative_domains.add(alt)
self.wizard = DomainRequestWizard()
self.wizard._domain_request = self.domain_request
self.wizard.request = Mock(user=self.user, session={})
self.wizard.kwargs = {"id": self.domain_request.id}
# We use both of these flags in the test. In the normal app these are generated normally.
# The alternative syntax is adding the decorator to each test.
get_waffle_flag_model().objects.get_or_create(name="organization_feature")
get_waffle_flag_model().objects.get_or_create(name="organization_requests")
def tearDown(self):
super().tearDown()
@ -2124,30 +2120,31 @@ class TestDomainRequestIncomplete(TestCase):
@less_console_noise_decorator
def test_is_federal_complete(self):
self.assertTrue(self.domain_request._is_federal_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.federal_type = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_federal_complete())
self.domain_request.refresh_from_db()
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_interstate_complete(self):
self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE
self.domain_request.about_your_organization = "Something something about your organization"
self.domain_request.save()
self.assertTrue(self.domain_request._is_interstate_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.about_your_organization = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_interstate_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_state_or_territory_complete(self):
self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY
self.domain_request.is_election_board = True
self.domain_request.save()
self.assertTrue(self.domain_request._is_state_or_territory_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_state_or_territory_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_tribal_complete(self):
@ -2155,33 +2152,33 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.tribe_name = "Tribe Name"
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_tribal_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_tribal_complete())
self.assertFalse(self.wizard.form_is_complete())
self.domain_request.tribe_name = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_tribal_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_county_complete(self):
self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.COUNTY
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_county_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_county_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_city_complete(self):
self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.CITY
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_city_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_city_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_special_district_complete(self):
@ -2189,55 +2186,55 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.about_your_organization = "Something something about your organization"
self.domain_request.is_election_board = False
self.domain_request.save()
self.assertTrue(self.domain_request._is_special_district_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_election_board = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_special_district_complete())
self.assertFalse(self.wizard.form_is_complete())
self.domain_request.about_your_organization = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_special_district_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_organization_name_and_address_complete(self):
self.assertTrue(self.domain_request._is_organization_name_and_address_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.organization_name = None
self.domain_request.address_line1 = None
self.domain_request.save()
self.assertTrue(self.domain_request._is_organization_name_and_address_complete())
self.assertTrue(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_senior_official_complete(self):
self.assertTrue(self.domain_request._is_senior_official_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.senior_official = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_senior_official_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_requested_domain_complete(self):
self.assertTrue(self.domain_request._is_requested_domain_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.requested_domain = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_requested_domain_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_purpose_complete(self):
self.assertTrue(self.domain_request._is_purpose_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.purpose = None
self.domain_request.save()
self.assertFalse(self.domain_request._is_purpose_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_other_contacts_complete_missing_one_field(self):
self.assertTrue(self.domain_request._is_other_contacts_complete())
self.assertTrue(self.wizard.form_is_complete())
contact = self.domain_request.other_contacts.first()
contact.first_name = None
contact.save()
self.assertFalse(self.domain_request._is_other_contacts_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_other_contacts_complete_all_none(self):
self.domain_request.other_contacts.clear()
self.assertFalse(self.domain_request._is_other_contacts_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_other_contacts_False_and_has_rationale(self):
@ -2245,7 +2242,7 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.other_contacts.clear()
self.domain_request.other_contacts.exists = False
self.domain_request.no_other_contacts_rationale = "Some rationale"
self.assertTrue(self.domain_request._is_other_contacts_complete())
self.assertTrue(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_other_contacts_False_and_NO_rationale(self):
@ -2253,7 +2250,7 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.other_contacts.clear()
self.domain_request.other_contacts.exists = False
self.domain_request.no_other_contacts_rationale = None
self.assertFalse(self.domain_request._is_other_contacts_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_is_additional_details_complete(self):
@ -2457,28 +2454,28 @@ class TestDomainRequestIncomplete(TestCase):
self.domain_request.save()
self.domain_request.refresh_from_db()
self.assertEqual(
self.domain_request._is_additional_details_complete(),
self.wizard.form_is_complete(),
case["expected"],
msg=f"Failed for case: {case}",
)
@less_console_noise_decorator
def test_is_policy_acknowledgement_complete(self):
self.assertTrue(self.domain_request._is_policy_acknowledgement_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_policy_acknowledged = False
self.assertTrue(self.domain_request._is_policy_acknowledgement_complete())
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.is_policy_acknowledged = None
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
self.assertFalse(self.wizard.form_is_complete())
@less_console_noise_decorator
def test_form_complete(self):
request = self.factory.get("/")
request.user = self.user
self.assertTrue(self.domain_request._form_complete(request))
self.assertTrue(self.wizard.form_is_complete())
self.domain_request.generic_org_type = None
self.domain_request.save()
self.assertFalse(self.domain_request._form_complete(request))
self.assertFalse(self.wizard.form_is_complete())
class TestPortfolio(TestCase):

View file

@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
federal_agency=fed_agency,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
)
user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
UserPortfolioPermission.objects.create(
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Adds cc'ed email in this test's allow list

View file

@ -725,7 +725,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
# @less_console_noise_decorator
@less_console_noise_decorator
def test_domain_request_data_full(self):
"""Tests the full domain request report."""
# Remove "Submitted at" because we can't guess this immutable, dynamically generated test data

View file

@ -1,7 +1,8 @@
from django.test import TestCase
from registrar.models import User
from waffle.testutils import override_flag
from registrar.utility.waffle import flag_is_active_for_user
from waffle.models import get_waffle_flag_model
from registrar.utility.waffle import flag_is_active_for_user, flag_is_active_anywhere
class FlagIsActiveForUserTest(TestCase):
@ -21,3 +22,40 @@ class FlagIsActiveForUserTest(TestCase):
# Test that the flag is inactive for the user
is_active = flag_is_active_for_user(self.user, "test_flag")
self.assertFalse(is_active)
class TestFlagIsActiveAnywhere(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="testuser")
self.flag_name = "test_flag"
@override_flag("test_flag", active=True)
def test_flag_active_for_everyone(self):
"""Test when flag is active for everyone"""
is_active = flag_is_active_anywhere("test_flag")
self.assertTrue(is_active)
@override_flag("test_flag", active=False)
def test_flag_inactive_for_everyone(self):
"""Test when flag is inactive for everyone"""
is_active = flag_is_active_anywhere("test_flag")
self.assertFalse(is_active)
def test_flag_active_for_some_users(self):
"""Test when flag is active for specific users"""
flag, _ = get_waffle_flag_model().objects.get_or_create(name="test_flag")
flag.everyone = None
flag.save()
flag.users.add(self.user)
is_active = flag_is_active_anywhere("test_flag")
self.assertTrue(is_active)
def test_flag_inactive_with_no_users(self):
"""Test when flag has no users and everyone is None"""
flag, _ = get_waffle_flag_model().objects.get_or_create(name="test_flag")
flag.everyone = None
flag.save()
is_active = flag_is_active_anywhere("test_flag")
self.assertFalse(is_active)

View file

@ -849,7 +849,10 @@ class TestDomainManagers(TestDomainOverview):
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
email="mayor@igorville.gov",
requestor=self.user,
portfolio=self.portfolio,
is_admin_invitation=False,
)
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
@ -903,7 +906,10 @@ class TestDomainManagers(TestDomainOverview):
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="notauser@igorville.gov", requestor=self.user, portfolio=self.portfolio
email="notauser@igorville.gov",
requestor=self.user,
portfolio=self.portfolio,
is_admin_invitation=False,
)
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
@ -1038,7 +1044,10 @@ class TestDomainManagers(TestDomainOverview):
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
email="mayor@igorville.gov",
requestor=self.user,
portfolio=self.portfolio,
is_admin_invitation=False,
)
mock_send_domain_email.assert_not_called()
@ -1054,6 +1063,23 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "Failed to send email.")
@boto3_mocking.patching
@less_console_noise_decorator
@patch("registrar.views.domain.send_templated_email")
def test_domain_remove_manager(self, mock_send_templated_email):
"""Removing a domain manager sends notification email to other domain managers."""
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
# Verify that the notification emails were sent to domain manager
mock_send_templated_email.assert_called_once_with(
"emails/domain_manager_deleted_notification.txt",
"emails/domain_manager_deleted_notification_subject.txt",
to_address="info@example.com",
context=ANY,
)
@less_console_noise_decorator
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_invitation_created(self, mock_send_domain_email):
@ -2181,7 +2207,7 @@ class TestDomainSuborganization(TestDomainOverview):
self.domain_information.refresh_from_db()
# Add portfolio perms to the user object
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)

View file

@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
domain=domain3,
)
# create another domain in the portfolio
# but make sure the domain invitation is canceled
domain4 = Domain.objects.create(
name="somedomain4.com",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain4,
)
DomainInvitation.objects.create(
email=self.email6,
domain=domain4,
status=DomainInvitation.DomainInvitationStatus.CANCELED,
)
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
self.assertIn("somedomain1.com", domain_names)
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
self.assertNotIn("somedomain3.com", domain_names)
self.assertNotIn("somedomain4.com", domain_names)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)

File diff suppressed because it is too large Load diff

View file

@ -3079,19 +3079,16 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
first_name="Henry", last_name="Mcfakerson", title="test", email="moar@igorville.gov", phone="1234567890"
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
first_name="Saturn", last_name="Mars", title="test", email="moar@igorville.gov", phone="1234567890"
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakey",
first_name="Hank", last_name="McFakey", title="test", email="moar@igorville.gov", phone="1234567890"
)
site = DraftDomain.objects.create(name="igorville.gov")
@ -3221,6 +3218,37 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
federal_agency.delete()
domain_request.delete()
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_unlock_organization_contact_flags_enabled(self):
"""Tests unlock_organization_contact when agency exists in a portfolio"""
# Create a federal agency
federal_agency = FederalAgency.objects.create(agency="Portfolio Agency")
# Create a portfolio with matching organization name
Portfolio.objects.create(
creator=self.user, organization_name=federal_agency.agency, federal_agency=federal_agency
)
# Create domain request with the portfolio agency
domain_request = completed_domain_request(federal_agency=federal_agency, user=self.user)
self.assertFalse(domain_request.unlock_organization_contact())
@override_flag("organization_feature", active=False)
@override_flag("organization_requests", active=False)
@less_console_noise_decorator
def test_unlock_organization_contact_flags_disabled(self):
"""Tests unlock_organization_contact when organization flags are disabled"""
# Create a federal agency
federal_agency = FederalAgency.objects.create(agency="Portfolio Agency")
# Create a portfolio with matching organization name
Portfolio.objects.create(creator=self.user, organization_name=federal_agency.agency)
domain_request = completed_domain_request(federal_agency=federal_agency, user=self.user)
self.assertTrue(domain_request.unlock_organization_contact())
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):

View file

@ -1,6 +1,9 @@
from datetime import date
from django.conf import settings
from registrar.models import Domain, DomainInvitation, UserDomainRole
from registrar.models.portfolio import Portfolio
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.errors import (
AlreadyDomainInvitedError,
AlreadyDomainManagerError,
@ -37,8 +40,8 @@ def send_domain_invitation_email(
OutsideOrgMemberError: If the requested_user is part of a different organization.
EmailSendingError: If there is an error while sending the email.
"""
domains = normalize_domains(domains)
requestor_email = get_requestor_email(requestor, domains)
domains = _normalize_domains(domains)
requestor_email = _get_requestor_email(requestor, domains=domains)
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
@ -92,22 +95,27 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain,
return all_emails_sent
def normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
"""Ensures domains is always a list."""
return [domains] if isinstance(domains, Domain) else domains
def get_requestor_email(requestor, domains):
def _get_requestor_email(requestor, domains=None, portfolio=None):
"""Get the requestor's email or raise an error if it's missing.
If the requestor is staff, default email is returned.
Raises:
MissingEmailError
"""
if requestor.is_staff:
return settings.DEFAULT_FROM_EMAIL
if not requestor.email or requestor.email.strip() == "":
domain_names = ", ".join([domain.name for domain in domains])
raise MissingEmailError(email=requestor.email, domain=domain_names)
domain_names = None
if domains:
domain_names = ", ".join([domain.name for domain in domains])
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
return requestor.email
@ -169,7 +177,7 @@ def send_invitation_email(email, requestor_email, domains, requested_user):
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
def send_portfolio_invitation_email(email: str, requestor, portfolio):
def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation):
"""
Sends a portfolio member invitation email to the specified address.
@ -179,21 +187,17 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
email (str): Email address of the recipient
requestor (User): The user initiating the invitation.
portfolio (Portfolio): The portfolio object for which the invitation is being sent.
is_admin_invitation (boolean): boolean indicating if the invitation is an admin invitation
Returns:
Boolean indicating if all messages were sent successfully.
Raises:
MissingEmailError: If the requestor has no email associated with their account.
EmailSendingError: If there is an error while sending the email.
"""
# Default email address for staff
requestor_email = settings.DEFAULT_FROM_EMAIL
# Check if the requestor is staff and has an email
if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "":
raise MissingEmailError(email=email, portfolio=portfolio)
else:
requestor_email = requestor.email
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
try:
send_templated_email(
@ -210,3 +214,119 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
raise EmailSendingError(
f"Could not sent email invitation to {email} for portfolio {portfolio}. Portfolio invitation not saved."
) from err
all_admin_emails_sent = True
# send emails to portfolio admins
if is_admin_invitation:
all_admin_emails_sent = _send_portfolio_admin_addition_emails_to_portfolio_admins(
email=email,
requestor_email=requestor_email,
portfolio=portfolio,
)
return all_admin_emails_sent
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
Returns:
Boolean indicating if all messages were sent successfully.
Raises:
MissingEmailError
"""
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
return _send_portfolio_admin_addition_emails_to_portfolio_admins(email, requestor_email, portfolio)
def _send_portfolio_admin_addition_emails_to_portfolio_admins(email: str, requestor_email, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
Returns:
Boolean indicating if all messages were sent successfully.
"""
all_emails_sent = True
# Get each portfolio admin from list
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
).exclude(user__email=email)
for user_portfolio_permission in user_portfolio_permissions:
# Send email to each portfolio_admin
user = user_portfolio_permission.user
try:
send_templated_email(
"emails/portfolio_admin_addition_notification.txt",
"emails/portfolio_admin_addition_notification_subject.txt",
to_address=user.email,
context={
"portfolio": portfolio,
"requestor_email": requestor_email,
"invited_email_address": email,
"portfolio_admin": user,
"date": date.today(),
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization admin notification to %s " "for portfolio: %s",
user.email,
portfolio.organization_name,
exc_info=True,
)
all_emails_sent = False
return all_emails_sent
def send_portfolio_admin_removal_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a removed portfolio admin
Returns:
Boolean indicating if all messages were sent successfully.
Raises:
MissingEmailError
"""
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
return _send_portfolio_admin_removal_emails_to_portfolio_admins(email, requestor_email, portfolio)
def _send_portfolio_admin_removal_emails_to_portfolio_admins(email: str, requestor_email, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a removed portfolio admin
Returns:
Boolean indicating if all messages were sent successfully.
"""
all_emails_sent = True
# Get each portfolio admin from list
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
).exclude(user__email=email)
for user_portfolio_permission in user_portfolio_permissions:
# Send email to each portfolio_admin
user = user_portfolio_permission.user
try:
send_templated_email(
"emails/portfolio_admin_removal_notification.txt",
"emails/portfolio_admin_removal_notification_subject.txt",
to_address=user.email,
context={
"portfolio": portfolio,
"requestor_email": requestor_email,
"removed_email_address": email,
"portfolio_admin": user,
"date": date.today(),
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization admin notification to %s " "for portfolio: %s",
user.email,
portfolio.organization_name,
exc_info=True,
)
all_emails_sent = False
return all_emails_sent

View file

@ -1,5 +1,6 @@
from django.http import HttpRequest
from waffle.decorators import flag_is_active
from waffle.models import get_waffle_flag_model
def flag_is_active_for_user(user, flag_name):
@ -10,3 +11,21 @@ def flag_is_active_for_user(user, flag_name):
request = HttpRequest()
request.user = user
return flag_is_active(request, flag_name)
def flag_is_active_anywhere(flag_name):
"""Checks if the given flag name is active for anyone, anywhere.
More specifically, it checks on flag.everyone or flag.users.exists().
Does not check self.superuser, self.staff or self.group.
This function effectively behaves like a switch:
If said flag is enabled for someone, somewhere - return true.
Otherwise - return false.
"""
try:
flag = get_waffle_flag_model().get(flag_name)
if flag.everyone is None:
return flag.users.exists()
return flag.everyone
except get_waffle_flag_model().DoesNotExist:
return False

View file

@ -1234,7 +1234,9 @@ class DomainAddUserView(DomainFormBaseView):
and requestor_can_update_portfolio
and not member_of_this_org
):
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
send_portfolio_invitation_email(
email=requested_email, requestor=requestor, portfolio=domain_org, is_admin_invitation=False
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email=requested_email, portfolio=domain_org, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
@ -1346,10 +1348,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Delete the object
super().form_valid(form)
# Email all domain managers that domain manager has been removed
domain = self.object.domain
context = {
"domain": domain,
"removed_by": self.request.user,
"manager_removed": self.object.user,
"date": date.today(),
"changes": "Domain Manager",
}
self.email_domain_managers(
domain,
"emails/domain_manager_deleted_notification.txt",
"emails/domain_manager_deleted_notification_subject.txt",
context,
)
# Add a success message
messages.success(self.request, self.get_success_message())
return redirect(self.get_success_url())
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
"user", flat=True
)
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
for email in emails:
try:
send_templated_email(
template,
subject_template,
to_address=email,
context=context,
)
except EmailSendingError:
logger.warning(
"Could not send notification email to %s for domain %s",
email,
domain.name,
exc_info=True,
)
def post(self, request, *args, **kwargs):
"""Custom post implementation to ensure last userdomainrole is not removed and to
redirect to home in the event that the user deletes themselves"""

View file

@ -107,15 +107,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
Step.TRIBAL_GOVERNMENT: lambda self: self.domain_request.tribe_name is not None,
Step.ORGANIZATION_FEDERAL: lambda self: self.domain_request.federal_type is not None,
Step.ORGANIZATION_ELECTION: lambda self: self.domain_request.is_election_board is not None,
Step.ORGANIZATION_CONTACT: lambda self: (
self.domain_request.federal_agency is not None
or self.domain_request.organization_name is not None
or self.domain_request.address_line1 is not None
or self.domain_request.city is not None
or self.domain_request.state_territory is not None
or self.domain_request.zipcode is not None
or self.domain_request.urbanization is not None
),
Step.ORGANIZATION_CONTACT: lambda self: self.from_model("unlock_organization_contact", False),
Step.ABOUT_YOUR_ORGANIZATION: lambda self: self.domain_request.about_your_organization is not None,
Step.SENIOR_OFFICIAL: lambda self: self.domain_request.senior_official is not None,
Step.CURRENT_SITES: lambda self: (
@ -123,9 +115,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
),
Step.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
Step.PURPOSE: lambda self: self.domain_request.purpose is not None,
Step.OTHER_CONTACTS: lambda self: (
self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None
),
Step.OTHER_CONTACTS: lambda self: self.from_model("unlock_other_contacts", False),
Step.ADDITIONAL_DETAILS: lambda self: (
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
@ -434,20 +424,28 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
Queries the DB for a domain request and returns a list of unlocked steps."""
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
def form_is_complete(self):
"""Determines if all required steps in the domain request form are complete.
Returns:
bool: True if all required steps are complete, False otherwise
"""
# 1. Get all steps visibly present to the user (required steps)
# 2. Return every possible step that is "unlocked" (even hidden, conditional ones)
# 3. Narrows down the list to remove hidden conditional steps
required_steps = set(self.steps.all)
unlockable_steps = {step.value for step in self.db_check_for_unlocking_steps()}
unlocked_steps = {step for step in required_steps if step in unlockable_steps}
return required_steps == unlocked_steps
def get_context_data(self):
"""Define context for access on all wizard pages."""
requested_domain_name = None
if self.domain_request.requested_domain is not None:
requested_domain_name = self.domain_request.requested_domain.name
context = {}
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
# org_steps_complete is using at some point.
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
org_steps_complete = self.form_is_complete()
if org_steps_complete:
context = {
"form_titles": self.titles,
"steps": self.steps,
@ -782,7 +780,8 @@ class Review(DomainRequestWizard):
forms = [] # type: ignore
def get_context_data(self):
if DomainRequest._form_complete(self.domain_request, self.request) is False:
form_complete = self.form_is_complete()
if form_complete is False:
logger.warning("User arrived at review page with an incomplete form.")
context = super().get_context_data()
context["Step"] = self.get_step_enum().__members__

View file

@ -123,7 +123,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
# Subquery to get concatenated domain information for each email
domain_invitations = (
DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
DomainInvitation.objects.filter(
email=OuterRef("email"),
domain__domain_info__portfolio=portfolio,
status=DomainInvitation.DomainInvitationStatus.INVITED,
)
.annotate(
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
)

View file

@ -15,7 +15,12 @@ from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.email_invitations import (
send_domain_invitation_email,
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
)
from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues
from registrar.views.utility.mixins import PortfolioMemberPermission
@ -143,6 +148,19 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
# if member being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
try:
# attempt to send notification emails of the removal to other portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
except Exception as e:
self._handle_exceptions(e)
# passed all error conditions
portfolio_member_permission.delete()
@ -154,6 +172,18 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
messages.success(request, success_message)
return redirect(reverse("members"))
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
if isinstance(exception, MissingEmailError):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
logger.warning(
"Could not send email notification to existing organization admins.",
exc_info=True,
)
else:
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
messages.warning(self.request, "Could not send email notification to existing organization admins.")
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
@ -177,16 +207,33 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user_initially_is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission)
removing_admin_role_on_self = False
if form.is_valid():
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = (
request.user == user
and user_initially_is_admin
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
)
try:
if form.is_change_from_member_to_admin():
if not send_portfolio_admin_addition_emails(
email=portfolio_permission.user.email,
requestor=request.user,
portfolio=portfolio_permission.portfolio,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
elif form.is_change_from_admin_to_member():
if not send_portfolio_admin_removal_emails(
email=portfolio_permission.user.email,
requestor=request.user,
portfolio=portfolio_permission.portfolio,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = request.user == user
except Exception as e:
self._handle_exceptions(e)
form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
@ -200,6 +247,18 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
},
)
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
if isinstance(exception, MissingEmailError):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
logger.warning(
"Could not send email notification to existing organization admins.",
exc_info=True,
)
else:
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
messages.warning(self.request, "Could not send email notification to existing organization admins.")
class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
@ -380,6 +439,17 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
"""
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
# if invitation being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
try:
# attempt to send notification emails of the removal to portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_invitation.email, requestor=request.user, portfolio=portfolio_invitation.portfolio
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
except Exception as e:
self._handle_exceptions(e)
portfolio_invitation.delete()
success_message = f"You've removed {portfolio_invitation.email} from the organization."
@ -390,6 +460,18 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
messages.success(request, success_message)
return redirect(reverse("members"))
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
if isinstance(exception, MissingEmailError):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
logger.warning(
"Could not send email notification to existing organization admins.",
exc_info=True,
)
else:
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
messages.warning(self.request, "Could not send email notification to existing organization admins.")
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
@ -413,6 +495,27 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid():
try:
if form.is_change_from_member_to_admin():
if not send_portfolio_admin_addition_emails(
email=portfolio_invitation.email,
requestor=request.user,
portfolio=portfolio_invitation.portfolio,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
elif form.is_change_from_admin_to_member():
if not send_portfolio_admin_removal_emails(
email=portfolio_invitation.email,
requestor=request.user,
portfolio=portfolio_invitation.portfolio,
):
messages.warning(
self.request, "Could not send email notification to existing organization admins."
)
except Exception as e:
self._handle_exceptions(e)
form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("invitedmember", pk=pk)
@ -426,6 +529,18 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
},
)
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
if isinstance(exception, MissingEmailError):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
logger.warning(
"Could not send email notification to existing organization admins.",
exc_info=True,
)
else:
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
messages.warning(self.request, "Could not send email notification to existing organization admins.")
class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
@ -641,7 +756,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
"""Add additional context data to the template."""
context = super().get_context_data(**kwargs)
portfolio = self.request.session.get("portfolio")
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission(portfolio)
context["has_edit_portfolio_permission"] = self.request.user.has_edit_portfolio_permission(portfolio)
return context
def get_object(self, queryset=None):
@ -781,12 +896,19 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
requested_email = form.cleaned_data["email"]
requestor = self.request.user
portfolio = form.cleaned_data["portfolio"]
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"]
requested_user = User.objects.filter(email=requested_email).first()
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
try:
if not requested_user or not permission_exists:
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
if not send_portfolio_invitation_email(
email=requested_email,
requestor=requestor,
portfolio=portfolio,
is_admin_invitation=is_admin_invitation,
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
portfolio_invitation = form.save()
# if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None:
@ -809,7 +931,7 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
portfolio,
exc_info=True,
)
messages.warning(self.request, "Could not send portfolio email invitation.")
messages.error(self.request, "Could not send organization invitation email.")
elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception))
logger.error(