mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-25 12:08:40 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/3285-delete-manager-email
This commit is contained in:
commit
04a08e19dd
70 changed files with 2405 additions and 615 deletions
61
.github/ISSUE_TEMPLATE/story.yml
vendored
61
.github/ISSUE_TEMPLATE/story.yml
vendored
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
@ -1567,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,
|
||||
|
@ -1658,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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ from registrar.views.report_views import (
|
|||
AnalyticsView,
|
||||
ExportDomainRequestDataFull,
|
||||
ExportDataTypeUser,
|
||||
ExportDataTypeRequests,
|
||||
ExportMembersPortfolio,
|
||||
)
|
||||
|
||||
|
@ -260,11 +259,6 @@ urlpatterns = [
|
|||
ExportDataTypeUser.as_view(),
|
||||
name="export_data_type_user",
|
||||
),
|
||||
path(
|
||||
"reports/export_data_type_requests/",
|
||||
ExportDataTypeRequests.as_view(),
|
||||
name="export_data_type_requests",
|
||||
),
|
||||
path(
|
||||
"domain-request/<int:id>/edit/",
|
||||
views.DomainRequestWizard.as_view(),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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?
|
||||
You’re 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 %}
|
|
@ -0,0 +1 @@
|
|||
An admin was invited to your .gov organization
|
|
@ -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?
|
||||
You’re 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 %}
|
|
@ -0,0 +1 @@
|
|||
An admin was removed from your .gov organization
|
|
@ -51,20 +51,7 @@
|
|||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% if portfolio %}
|
||||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
|
||||
<section aria-label="Domain Requests report component" class="margin-top-205">
|
||||
<!----------------------------------------------------------------------
|
||||
This link is commented out because we intend to add it back in later.
|
||||
------------------------------------------------------------------------->
|
||||
<!-- <a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
|
||||
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a> -->
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if portfolio %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -92,11 +92,13 @@
|
|||
{% endif %}
|
||||
|
||||
{% if has_organization_members_flag %}
|
||||
{% if has_view_members_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="{% url 'members' %}" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li class="usa-nav__primary-item">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 can’t be updated here.
|
||||
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
|
|
|
@ -267,6 +267,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="test@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert success message
|
||||
|
@ -517,6 +518,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="test@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert retrieve on domain invite only was called
|
||||
|
@ -580,6 +582,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="test@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert retrieve on domain invite only was called
|
||||
|
@ -706,6 +709,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="nonexistent@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert retrieve was not called
|
||||
|
@ -931,6 +935,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="nonexistent@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert retrieve on domain invite only was called
|
||||
|
@ -992,6 +997,7 @@ class TestDomainInvitationAdmin(MockEppLib, WebTest):
|
|||
email="nonexistent@example.com",
|
||||
requestor=self.superuser,
|
||||
portfolio=self.portfolio,
|
||||
is_admin_invitation=False,
|
||||
)
|
||||
|
||||
# Assert retrieve on domain invite only was called
|
||||
|
@ -1260,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."""
|
||||
|
||||
|
@ -1511,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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
@ -2198,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]
|
||||
)
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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):
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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__
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -201,17 +201,6 @@ class ExportMembersPortfolio(PortfolioReportsPermission, View):
|
|||
return response
|
||||
|
||||
|
||||
class ExportDataTypeRequests(DomainAndRequestsReportsPermission, View):
|
||||
"""Returns a domain requests report for a given user on the request"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = HttpResponse(content_type="text/csv")
|
||||
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
|
||||
csv_export.DomainRequestDataType.export_data_to_csv(response, request=request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(staff_member_required, name="dispatch")
|
||||
class ExportDataFull(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue