Files
notifications-admin/app/models/service.py
Alex Janousek 6f5750f095 Removed all govuk css (#2814)
* Removed all govuk css

* Updated reference files

* Removing govuk js

* Fixed casing for modules, removed unused page

* Got more reference images

* Updated template page

* Removed govuk padding util

* Updated hint to uswds hint

* More govuk cleanup

* Commiting backstopjs ref files

* Fixed all unit tests that broke due to brittleness around govuk styling

* Added new ref images

* Final removal of govuk

* Officially removed all govuk references

* Updated reference file

* Updated link to button

* UI modernization

* Cleanup

* removed govuk escaping tests since they are no longer needed

* Fix CodeQL security issue in escapeElementName function

- Escape backslashes first before other special characters
- Prevents potential double-escaping vulnerability
- Addresses CodeQL alert about improper string escaping

* Found more govuk removal. Fixed unit tests

* Add missing pipeline check to pre-commit

* updated test

* Updated e2e test

* More update to e2e test

* Fixed another e2e test

* Simple PR comments addressed

* More updates

* Updated backstop ref files

* Refactored folder selection for non-admins

* Updated redundant line

* Updated tests to include correct mocks

* Added more ref files

* Addressing carlos comments

* Addressing Carlo comments, cleanup of window initing

* More cleanup and addressing carlo comments

* Fixing a11 scan

* Fixed a few issues with javascript

* Fixed for pr

* Fixing e2e tests

* Tweaking e2e test

* Added more ref files and cleaned up urls.js

* Fixed bug with creating new template

* Removed brittle test - addressed code ql comment

* e2e race condition fix

* More e2e test fixes

* Updated e2e tests to not wait for text sent

* Updated test to not wait for button click response

* Made tear down more resilent if staging is down

* reverted e2e test to what was working before main merge

* Updated backstopRef images

* Updated gulp to include job-polling differently
2025-10-06 09:38:54 -04:00

509 lines
15 KiB
Python

from flask import abort, current_app
from werkzeug.utils import cached_property
from app.enums import ServicePermission
from app.models import JSONModel, SortByNameMixin
from app.models.job import ImmediateJobs, PaginatedJobs, PaginatedUploads, ScheduledJobs
from app.models.organization import Organization
from app.models.user import InvitedUsers, User, Users
from app.notify_client.api_key_api_client import api_key_api_client
from app.notify_client.billing_api_client import billing_api_client
from app.notify_client.inbound_number_client import inbound_number_client
from app.notify_client.invite_api_client import invite_api_client
from app.notify_client.job_api_client import job_api_client
from app.notify_client.organizations_api_client import organizations_client
from app.notify_client.service_api_client import service_api_client
from app.notify_client.template_folder_api_client import template_folder_api_client
from app.utils import get_default_sms_sender
from notifications_utils.serialised_model import SerialisedModelCollection
class Service(JSONModel, SortByNameMixin):
ALLOWED_PROPERTIES = {
"active",
"billing_contact_email_addresses",
"billing_contact_names",
"billing_reference",
"consent_to_research",
"contact_link",
"count_as_live",
"email_from",
"go_live_at",
"go_live_user",
"id",
"inbound_api",
"message_limit",
"rate_limit",
"name",
"notes",
"prefix_sms",
"purchase_order_number",
ServicePermission.RESEARCH_MODE,
"service_callback_api",
"volume_email",
"volume_sms",
}
TEMPLATE_TYPES = (
"email",
"sms",
)
ALL_PERMISSIONS = TEMPLATE_TYPES + (
ServicePermission.EDIT_FOLDER_PERMISSIONS,
ServicePermission.EMAIL_AUTH,
ServicePermission.INBOUND_SMS,
ServicePermission.INTERNATIONAL_SMS,
ServicePermission.UPLOAD_DOCUMENT,
)
@classmethod
def from_id(cls, service_id):
return cls(service_api_client.get_service(service_id)["data"])
@property
def permissions(self):
return self._dict.get("permissions", self.TEMPLATE_TYPES)
@property
def billing_details(self):
billing_details = [
self.billing_contact_email_addresses,
self.billing_contact_names,
self.billing_reference,
self.purchase_order_number,
]
if any(billing_details):
return billing_details
else:
return None
def update(self, **kwargs):
return service_api_client.update_service(self.id, **kwargs)
def update_count_as_live(self, count_as_live):
return service_api_client.update_count_as_live(
self.id, count_as_live=count_as_live
)
def update_status(self, live):
return service_api_client.update_status(self.id, live=live)
def switch_permission(self, permission):
return self.force_permission(
permission,
on=not self.has_permission(permission),
)
def force_permission(self, permission, on=False):
permissions, permission = set(self.permissions), {permission}
return self.update_permissions(
permissions | permission if on else permissions - permission,
)
def update_permissions(self, permissions):
return self.update(permissions=list(permissions))
def toggle_research_mode(self):
self.update(research_mode=not self.research_mode)
@property
def trial_mode(self):
return self._dict["restricted"]
@property
def live(self):
return not self.trial_mode
def has_permission(self, permission):
if permission not in self.ALL_PERMISSIONS:
raise KeyError(f"{permission} is not a service permission")
return permission in self.permissions
def get_page_of_jobs(self, page):
return PaginatedJobs(self.id, page=page)
def get_page_of_uploads(self, page):
return PaginatedUploads(self.id, page=page)
@cached_property
def has_jobs(self):
return job_api_client.has_jobs(self.id)
@cached_property
def immediate_jobs(self):
if not self.has_jobs:
return []
return ImmediateJobs(self.id)
@cached_property
def scheduled_jobs(self):
if not self.has_jobs:
return []
return ScheduledJobs(self.id)
@cached_property
def scheduled_job_stats(self):
if not self.has_jobs:
return {"count": 0}
return job_api_client.get_scheduled_job_stats(self.id)
@cached_property
def invited_users(self):
return InvitedUsers(self.id)
def invite_pending_for(self, email_address):
return email_address.lower() in (
invited_user.email_address.lower() for invited_user in self.invited_users
)
@cached_property
def active_users(self):
return Users(self.id)
@cached_property
def team_members(self):
return sorted(
self.invited_users + self.active_users,
key=lambda user: user.email_address.lower(),
)
@cached_property
def has_team_members(self):
return (
len(
[
user
for user in self.team_members
if user.has_permission_for_service(
self.id, ServicePermission.MANAGE_SERVICE
)
]
)
> 1
)
def cancel_invite(self, invited_user_id):
if str(invited_user_id) not in {user.id for user in self.invited_users}:
abort(404)
return invite_api_client.cancel_invited_user(
service_id=self.id,
invited_user_id=str(invited_user_id),
)
def resend_invite(self, invited_user_id):
if str(invited_user_id) not in {user.id for user in self.invited_users}:
abort(404)
return invite_api_client.resend_invite(
service_id=self.id,
invited_user_id=str(invited_user_id),
)
def get_team_member(self, user_id):
if str(user_id) not in {user.id for user in self.active_users}:
abort(404)
return User.from_id(user_id)
@cached_property
def all_templates(self):
templates = service_api_client.get_service_templates(self.id)["data"]
return [
template
for template in templates
if template["template_type"] in self.available_template_types
]
@cached_property
def all_template_ids(self):
return {template["id"] for template in self.all_templates}
def get_template(self, template_id, version=None):
return service_api_client.get_service_template(self.id, template_id, version)[
"data"
]
def get_template_folder_with_user_permission_or_403(self, folder_id, user):
template_folder = self.get_template_folder(folder_id)
if not user.has_template_folder_permission(template_folder):
abort(403)
return template_folder
def get_template_with_user_permission_or_403(self, template_id, user):
template = self.get_template(template_id)
self.get_template_folder_with_user_permission_or_403(template["folder"], user)
return template
@property
def available_template_types(self):
return list(filter(self.has_permission, self.TEMPLATE_TYPES))
@property
def has_templates(self):
return bool(self.all_templates)
def has_folders(self):
return bool(self.all_template_folders)
@property
def has_multiple_template_types(self):
return len({template["template_type"] for template in self.all_templates}) > 1
@property
def has_estimated_usage(self):
return self.consent_to_research is not None and any(
(
self.volume_email,
self.volume_sms,
)
)
def has_templates_of_type(self, template_type):
return any(
template
for template in self.all_templates
if template["template_type"] == template_type
)
@property
def has_email_templates(self):
return self.has_templates_of_type("email")
@property
def has_sms_templates(self):
return self.has_templates_of_type("sms")
@property
def intending_to_send_email(self):
if self.volume_email is None:
return self.has_email_templates
return self.volume_email > 0
@property
def intending_to_send_sms(self):
if self.volume_sms is None:
return self.has_sms_templates
return self.volume_sms > 0
@cached_property
def email_reply_to_addresses(self):
return service_api_client.get_reply_to_email_addresses(self.id)
@property
def has_email_reply_to_address(self):
return bool(self.email_reply_to_addresses)
@property
def count_email_reply_to_addresses(self):
return len(self.email_reply_to_addresses)
@property
def default_email_reply_to_address(self):
return next(
(
x["email_address"]
for x in self.email_reply_to_addresses
if x["is_default"]
),
None,
)
def get_email_reply_to_address(self, id):
return service_api_client.get_reply_to_email_address(self.id, id)
@property
def needs_to_add_email_reply_to_address(self):
return self.intending_to_send_email and not self.has_email_reply_to_address
@property
def shouldnt_use_default_sms_sender(self):
return self.organization_type != Organization.TYPE_FEDERAL
@cached_property
def sms_senders(self):
return service_api_client.get_sms_senders(self.id)
@property
def sms_senders_with_hints(self):
def attach_hint(sender):
hints = []
if sender["is_default"]:
hints += ["default"]
if sender["inbound_number_id"]:
hints += ["receives replies"]
if hints:
sender["hint"] = "(" + " and ".join(hints) + ")"
return sender
return [attach_hint(sender) for sender in self.sms_senders]
@property
def default_sms_sender(self):
return get_default_sms_sender(self.sms_senders)
@property
def count_sms_senders(self):
return len(self.sms_senders)
@property
def sms_sender_is_default(self):
return self.default_sms_sender in {"USGOV", "None"}
def get_sms_sender(self, id):
return service_api_client.get_sms_sender(self.id, id)
@property
def needs_to_change_sms_sender(self):
return all(
(
self.intending_to_send_sms,
self.shouldnt_use_default_sms_sender,
self.sms_sender_is_default,
)
)
@property
def volumes(self):
return sum(
filter(
None,
(
self.volume_email,
self.volume_sms,
),
)
)
@cached_property
def free_sms_fragment_limit(self):
return billing_api_client.get_free_sms_fragment_limit_for_year(self.id) or 0
@cached_property
def data_retention(self):
return service_api_client.get_service_data_retention(self.id)
def get_data_retention_item(self, id):
return next((dr for dr in self.data_retention if dr["id"] == id), None)
def get_days_of_retention(self, notification_type, number_of_days):
return next(
(
dr
for dr in self.data_retention
if dr["notification_type"] == notification_type
),
{},
).get(
"days_of_retention",
current_app.config["ACTIVITY_STATS_LIMIT_DAYS"].get(number_of_days),
)
@cached_property
def organization(self):
return Organization.from_id(self.organization_id)
@property
def organization_id(self):
return self._dict["organization"]
@property
def organization_type(self):
return self.organization.organization_type or self._dict["organization_type"]
@property
def organization_name(self):
if not self.organization_id:
return None
return organizations_client.get_organization_name(self.organization_id)
@property
def organization_type_label(self):
return Organization.TYPE_LABELS.get(self.organization_type)
@cached_property
def inbound_number(self):
return inbound_number_client.get_inbound_sms_number_for_service(self.id)[
"data"
].get("number", "")
@property
def has_inbound_number(self):
return bool(self.inbound_number)
@cached_property
def inbound_sms_summary(self):
if not self.has_permission(ServicePermission.INBOUND_SMS):
return None
return service_api_client.get_inbound_sms_summary(self.id)
@cached_property
def all_template_folders(self):
return sorted(
template_folder_api_client.get_template_folders(self.id),
key=lambda folder: folder["name"].lower(),
)
@cached_property
def all_template_folder_ids(self):
return {folder["id"] for folder in self.all_template_folders}
def get_template_folder(self, folder_id):
if folder_id is None:
return {
"id": None,
"name": "Templates",
"parent_id": None,
}
return self._get_by_id(self.all_template_folders, folder_id)
def get_template_folder_path(self, template_folder_id):
folder = self.get_template_folder(template_folder_id)
if folder["id"] is None:
return [folder]
return self.get_template_folder_path(folder["parent_id"]) + [
self.get_template_folder(folder["id"])
]
def get_template_path(self, template):
return self.get_template_folder_path(template["folder"]) + [
template,
]
@property
def count_of_templates_and_folders(self):
return len(self.all_templates + self.all_template_folders)
def move_to_folder(self, ids_to_move, move_to):
ids_to_move = set(ids_to_move)
template_folder_api_client.move_to_folder(
service_id=self.id,
folder_id=move_to,
template_ids=ids_to_move & self.all_template_ids,
folder_ids=ids_to_move & self.all_template_folder_ids,
)
@cached_property
def api_keys(self):
return sorted(
api_key_api_client.get_api_keys(self.id)["apiKeys"],
key=lambda key: key["name"].lower(),
)
def get_api_key(self, id):
return self._get_by_id(self.api_keys, id)
class Services(SerialisedModelCollection):
model = Service