2017-09-20 15:21:05 +01:00
|
|
|
|
import itertools
|
2016-01-28 11:42:13 +00:00
|
|
|
|
import uuid
|
2016-09-22 11:56:26 +01:00
|
|
|
|
|
2021-03-10 13:55:06 +00:00
|
|
|
|
from flask import current_app, url_for
|
2023-04-12 13:30:13 -04:00
|
|
|
|
from sqlalchemy import CheckConstraint, Index, UniqueConstraint
|
2024-01-25 14:45:37 -05:00
|
|
|
|
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
|
2021-03-10 13:55:06 +00:00
|
|
|
|
from sqlalchemy.ext.associationproxy import association_proxy
|
2025-01-10 11:21:39 -08:00
|
|
|
|
from sqlalchemy.ext.declarative import DeclarativeMeta, declared_attr
|
2023-01-06 10:02:23 -05:00
|
|
|
|
from sqlalchemy.orm import validates
|
2021-03-10 13:55:06 +00:00
|
|
|
|
from sqlalchemy.orm.collections import attribute_mapped_collection
|
|
|
|
|
|
|
2020-12-18 17:39:35 +00:00
|
|
|
|
from app import db, encryption
|
2024-01-18 10:28:15 -05:00
|
|
|
|
from app.enums import (
|
2024-01-10 12:32:25 -05:00
|
|
|
|
AgreementStatus,
|
|
|
|
|
|
AgreementType,
|
2024-01-18 10:28:15 -05:00
|
|
|
|
AuthType,
|
|
|
|
|
|
BrandType,
|
|
|
|
|
|
CallbackType,
|
2024-01-12 17:46:00 -05:00
|
|
|
|
CodeType,
|
|
|
|
|
|
InvitedUserStatus,
|
2024-01-15 14:22:56 -05:00
|
|
|
|
JobStatus,
|
2024-01-18 10:28:15 -05:00
|
|
|
|
KeyType,
|
|
|
|
|
|
NotificationStatus,
|
2024-01-10 12:32:25 -05:00
|
|
|
|
NotificationType,
|
2024-01-18 10:28:15 -05:00
|
|
|
|
OrganizationType,
|
2024-01-12 17:46:00 -05:00
|
|
|
|
PermissionType,
|
2024-01-12 17:27:31 -05:00
|
|
|
|
RecipientType,
|
2024-01-12 16:05:18 -05:00
|
|
|
|
ServicePermissionType,
|
2024-01-18 10:28:15 -05:00
|
|
|
|
TemplateProcessType,
|
2024-01-10 12:32:25 -05:00
|
|
|
|
TemplateType,
|
|
|
|
|
|
)
|
2021-03-10 13:55:06 +00:00
|
|
|
|
from app.hashing import check_hash, hashpw
|
|
|
|
|
|
from app.history_meta import Versioned
|
2021-01-15 13:15:00 +00:00
|
|
|
|
from app.utils import (
|
|
|
|
|
|
DATETIME_FORMAT,
|
|
|
|
|
|
DATETIME_FORMAT_NO_TIMEZONE,
|
|
|
|
|
|
get_dt_string_or_none,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
utc_now,
|
2021-01-15 13:15:00 +00:00
|
|
|
|
)
|
2024-05-16 10:17:45 -04:00
|
|
|
|
from notifications_utils.clients.encryption.encryption_client import EncryptionError
|
|
|
|
|
|
from notifications_utils.recipients import (
|
|
|
|
|
|
InvalidEmailError,
|
|
|
|
|
|
InvalidPhoneError,
|
|
|
|
|
|
try_validate_and_format_phone_number,
|
|
|
|
|
|
validate_email_address,
|
|
|
|
|
|
validate_phone_number,
|
|
|
|
|
|
)
|
|
|
|
|
|
from notifications_utils.template import PlainTextEmailTemplate, SMSMessageTemplate
|
2016-04-14 15:09:59 +01:00
|
|
|
|
|
2017-10-27 17:59:51 +01:00
|
|
|
|
|
2016-01-08 17:51:46 +00:00
|
|
|
|
def filter_null_value_fields(obj):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return dict(filter(lambda x: x[1] is not None, obj.items()))
|
2016-01-08 17:51:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
2024-01-25 09:55:10 -05:00
|
|
|
|
_enum_column_names = {
|
|
|
|
|
|
AuthType: "auth_types",
|
|
|
|
|
|
BrandType: "brand_types",
|
|
|
|
|
|
OrganizationType: "organization_types",
|
|
|
|
|
|
ServicePermissionType: "service_permission_types",
|
|
|
|
|
|
RecipientType: "recipient_types",
|
|
|
|
|
|
CallbackType: "callback_types",
|
|
|
|
|
|
KeyType: "key_types",
|
|
|
|
|
|
TemplateType: "template_types",
|
|
|
|
|
|
TemplateProcessType: "template_process_types",
|
|
|
|
|
|
NotificationType: "notification_types",
|
|
|
|
|
|
JobStatus: "job_statuses",
|
|
|
|
|
|
CodeType: "code_types",
|
|
|
|
|
|
NotificationStatus: "notify_statuses",
|
|
|
|
|
|
InvitedUserStatus: "invited_user_statuses",
|
|
|
|
|
|
PermissionType: "permission_types",
|
|
|
|
|
|
AgreementType: "agreement_types",
|
|
|
|
|
|
AgreementStatus: "agreement_statuses",
|
2024-01-18 11:31:02 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-01-25 09:55:10 -05:00
|
|
|
|
def enum_column(enum_type, **kwargs):
|
|
|
|
|
|
return db.Column(
|
|
|
|
|
|
db.Enum(
|
2024-02-02 17:48:57 -05:00
|
|
|
|
enum_type,
|
2024-01-25 14:25:00 -05:00
|
|
|
|
name=_enum_column_names[enum_type],
|
2024-02-02 17:48:57 -05:00
|
|
|
|
values_callable=(lambda x: [i.value for i in x]),
|
2024-01-25 09:55:10 -05:00
|
|
|
|
),
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
2024-01-18 16:58:06 -05:00
|
|
|
|
|
2024-01-18 09:40:27 -05:00
|
|
|
|
|
2016-12-15 17:11:47 +00:00
|
|
|
|
class HistoryModel:
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_original(cls, original):
|
|
|
|
|
|
history = cls()
|
|
|
|
|
|
history.update_from_original(original)
|
|
|
|
|
|
return history
|
|
|
|
|
|
|
|
|
|
|
|
def update_from_original(self, original):
|
|
|
|
|
|
for c in self.__table__.columns:
|
2017-05-04 17:09:04 +01:00
|
|
|
|
# in some cases, columns may have different names to their underlying db column - so only copy those
|
|
|
|
|
|
# that we can, and leave it up to subclasses to deal with any oddities/properties etc.
|
|
|
|
|
|
if hasattr(original, c.name):
|
|
|
|
|
|
setattr(self, c.name, getattr(original, c.name))
|
|
|
|
|
|
else:
|
2023-08-29 14:54:30 -07:00
|
|
|
|
current_app.logger.debug(
|
|
|
|
|
|
"{} has no column {} to copy from".format(original, c.name)
|
|
|
|
|
|
)
|
2016-12-15 17:11:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
2016-01-07 17:31:17 +00:00
|
|
|
|
class User(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "users"
|
2016-01-07 17:31:17 +00:00
|
|
|
|
|
2016-04-08 13:34:46 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-01-19 11:38:29 +00:00
|
|
|
|
name = db.Column(db.String, nullable=False, index=True, unique=False)
|
2016-01-07 17:31:17 +00:00
|
|
|
|
email_address = db.Column(db.String(255), nullable=False, index=True, unique=True)
|
2024-03-08 08:44:27 -08:00
|
|
|
|
login_uuid = db.Column(db.Text, nullable=True, index=True, unique=True)
|
2016-01-11 15:07:13 +00:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-11 15:07:13 +00:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-19 11:38:29 +00:00
|
|
|
|
_password = db.Column(db.String, index=False, unique=False, nullable=False)
|
2017-11-09 14:18:47 +00:00
|
|
|
|
mobile_number = db.Column(db.String, index=False, unique=False, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
password_changed_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-19 11:38:29 +00:00
|
|
|
|
logged_in_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
failed_login_count = db.Column(db.Integer, nullable=False, default=0)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
state = db.Column(db.String, nullable=False, default="pending")
|
2016-03-17 10:37:24 +00:00
|
|
|
|
platform_admin = db.Column(db.Boolean, nullable=False, default=False)
|
2017-02-17 14:06:16 +00:00
|
|
|
|
current_session_id = db.Column(UUID(as_uuid=True), nullable=True)
|
2024-01-30 10:18:53 -05:00
|
|
|
|
auth_type = enum_column(AuthType, index=True, nullable=False, default=AuthType.SMS)
|
2020-02-04 16:45:09 +00:00
|
|
|
|
email_access_validated_at = db.Column(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2020-02-04 16:45:09 +00:00
|
|
|
|
)
|
2023-11-17 09:01:27 -08:00
|
|
|
|
preferred_timezone = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.Text,
|
|
|
|
|
|
nullable=True,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
default="US/Eastern",
|
2023-11-17 09:01:27 -08:00
|
|
|
|
)
|
2016-01-19 11:38:29 +00:00
|
|
|
|
|
2017-11-09 14:18:47 +00:00
|
|
|
|
# either email auth or a mobile number must be provided
|
2023-08-29 14:54:30 -07:00
|
|
|
|
CheckConstraint(
|
2024-01-29 16:04:22 -05:00
|
|
|
|
"auth_type in (AuthType.EMAIL, AuthType.WEBAUTHN) or mobile_number is not null"
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-11-09 14:18:47 +00:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
services = db.relationship("Service", secondary="user_to_service", backref="users")
|
2023-07-10 11:06:29 -07:00
|
|
|
|
organizations = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
"Organization",
|
|
|
|
|
|
secondary="user_to_organization",
|
|
|
|
|
|
backref="users",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-06-16 16:30:03 +01:00
|
|
|
|
|
2023-01-06 10:02:23 -05:00
|
|
|
|
@validates("mobile_number")
|
|
|
|
|
|
def validate_mobile_number(self, key, number):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if number is not None:
|
|
|
|
|
|
return validate_phone_number(number, international=True)
|
|
|
|
|
|
except InvalidPhoneError as err:
|
|
|
|
|
|
raise ValueError(str(err)) from err
|
|
|
|
|
|
|
2016-01-19 11:38:29 +00:00
|
|
|
|
@property
|
|
|
|
|
|
def password(self):
|
|
|
|
|
|
raise AttributeError("Password not readable")
|
|
|
|
|
|
|
2021-06-25 17:29:19 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def can_use_webauthn(self):
|
|
|
|
|
|
if self.platform_admin:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2024-01-29 16:04:22 -05:00
|
|
|
|
if self.auth_type == AuthType.WEBAUTHN:
|
2021-06-30 15:41:43 +01:00
|
|
|
|
return True
|
|
|
|
|
|
|
2021-06-25 17:29:19 +01:00
|
|
|
|
return any(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
str(service.id) == current_app.config["NOTIFY_SERVICE_ID"]
|
2021-06-25 17:29:19 +01:00
|
|
|
|
for service in self.services
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2016-01-19 11:38:29 +00:00
|
|
|
|
@password.setter
|
|
|
|
|
|
def password(self, password):
|
|
|
|
|
|
self._password = hashpw(password)
|
|
|
|
|
|
|
|
|
|
|
|
def check_password(self, password):
|
|
|
|
|
|
return check_hash(password, self._password)
|
2016-01-08 17:51:46 +00:00
|
|
|
|
|
2019-05-21 15:53:48 +01:00
|
|
|
|
def get_permissions(self, service_id=None):
|
2018-03-06 17:47:29 +00:00
|
|
|
|
from app.dao.permissions_dao import permission_dao
|
2019-05-21 15:53:48 +01:00
|
|
|
|
|
|
|
|
|
|
if service_id:
|
|
|
|
|
|
return [
|
2023-08-29 14:54:30 -07:00
|
|
|
|
x.permission
|
|
|
|
|
|
for x in permission_dao.get_permissions_by_user_id_and_service_id(
|
|
|
|
|
|
self.id, service_id
|
|
|
|
|
|
)
|
2019-05-21 15:53:48 +01:00
|
|
|
|
]
|
|
|
|
|
|
|
2018-03-06 17:47:29 +00:00
|
|
|
|
retval = {}
|
|
|
|
|
|
for x in permission_dao.get_permissions_by_user_id(self.id):
|
|
|
|
|
|
service_id = str(x.service_id)
|
|
|
|
|
|
if service_id not in retval:
|
|
|
|
|
|
retval[service_id] = []
|
|
|
|
|
|
retval[service_id].append(x.permission)
|
|
|
|
|
|
return retval
|
|
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
2023-12-05 07:29:18 -08:00
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"email_address": self.email_address,
|
|
|
|
|
|
"auth_type": self.auth_type,
|
|
|
|
|
|
"current_session_id": self.current_session_id,
|
|
|
|
|
|
"failed_login_count": self.failed_login_count,
|
|
|
|
|
|
"email_access_validated_at": self.email_access_validated_at.strftime(
|
|
|
|
|
|
DATETIME_FORMAT
|
|
|
|
|
|
),
|
|
|
|
|
|
"logged_in_at": get_dt_string_or_none(self.logged_in_at),
|
|
|
|
|
|
"mobile_number": self.mobile_number,
|
|
|
|
|
|
"organizations": [x.id for x in self.organizations if x.active],
|
|
|
|
|
|
"password_changed_at": self.password_changed_at.strftime(
|
|
|
|
|
|
DATETIME_FORMAT_NO_TIMEZONE
|
|
|
|
|
|
),
|
|
|
|
|
|
"permissions": self.get_permissions(),
|
|
|
|
|
|
"platform_admin": self.platform_admin,
|
|
|
|
|
|
"services": [x.id for x in self.services if x.active],
|
|
|
|
|
|
"can_use_webauthn": self.can_use_webauthn,
|
|
|
|
|
|
"state": self.state,
|
2023-12-04 14:53:29 -08:00
|
|
|
|
"preferred_timezone": self.preferred_timezone,
|
2018-03-06 17:47:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2019-08-19 13:31:29 +01:00
|
|
|
|
def serialize_for_users_list(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"email_address": self.email_address,
|
|
|
|
|
|
"mobile_number": self.mobile_number,
|
2019-08-19 13:31:29 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2016-01-07 17:31:17 +00:00
|
|
|
|
|
2019-02-20 16:18:48 +00:00
|
|
|
|
class ServiceUser(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "user_to_service"
|
|
|
|
|
|
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey("users.id"), primary_key=True)
|
|
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
primary_key=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2019-02-20 16:18:48 +00:00
|
|
|
|
|
2024-01-16 15:47:55 -05:00
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
|
"user_id",
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
name="uix_user_to_service",
|
|
|
|
|
|
),
|
2019-02-20 16:18:48 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2018-02-15 14:16:16 +00:00
|
|
|
|
|
2023-07-10 11:06:29 -07:00
|
|
|
|
user_to_organization = db.Table(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"user_to_organization",
|
2018-02-15 14:16:16 +00:00
|
|
|
|
db.Model.metadata,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.Column("user_id", UUID(as_uuid=True), db.ForeignKey("users.id")),
|
|
|
|
|
|
db.Column("organization_id", UUID(as_uuid=True), db.ForeignKey("organization.id")),
|
|
|
|
|
|
UniqueConstraint("user_id", "organization_id", name="uix_user_to_organization"),
|
2018-02-15 14:16:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-20 16:18:48 +00:00
|
|
|
|
user_folder_permissions = db.Table(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"user_folder_permissions",
|
2019-02-20 16:18:48 +00:00
|
|
|
|
db.Model.metadata,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.Column("user_id", UUID(as_uuid=True), primary_key=True),
|
|
|
|
|
|
db.Column(
|
|
|
|
|
|
"template_folder_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("template_folder.id"),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
),
|
|
|
|
|
|
db.Column("service_id", UUID(as_uuid=True), primary_key=True),
|
|
|
|
|
|
db.ForeignKeyConstraint(
|
|
|
|
|
|
["user_id", "service_id"],
|
|
|
|
|
|
["user_to_service.user_id", "user_to_service.service_id"],
|
|
|
|
|
|
),
|
|
|
|
|
|
db.ForeignKeyConstraint(
|
|
|
|
|
|
["template_folder_id", "service_id"],
|
|
|
|
|
|
["template_folder.id", "template_folder.service_id"],
|
|
|
|
|
|
),
|
2019-02-20 16:18:48 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2018-02-01 17:16:48 +00:00
|
|
|
|
class EmailBranding(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "email_branding"
|
2018-02-01 17:16:48 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
colour = db.Column(db.String(7), nullable=True)
|
|
|
|
|
|
logo = db.Column(db.String(255), nullable=True)
|
2019-04-09 14:33:38 +01:00
|
|
|
|
name = db.Column(db.String(255), unique=True, nullable=False)
|
2018-07-25 16:06:08 +01:00
|
|
|
|
text = db.Column(db.String(255), nullable=True)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
brand_type = enum_column(
|
|
|
|
|
|
BrandType,
|
2018-08-23 13:53:05 +01:00
|
|
|
|
index=True,
|
2018-09-19 10:49:11 +01:00
|
|
|
|
nullable=False,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=BrandType.ORG,
|
2018-08-23 13:53:05 +01:00
|
|
|
|
)
|
2018-02-01 17:16:48 +00:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
serialized = {
|
|
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"colour": self.colour,
|
|
|
|
|
|
"logo": self.logo,
|
|
|
|
|
|
"name": self.name,
|
2018-07-25 16:06:08 +01:00
|
|
|
|
"text": self.text,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"brand_type": self.brand_type,
|
2018-02-01 17:16:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return serialized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
service_email_branding = db.Table(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"service_email_branding",
|
2018-02-01 17:16:48 +00:00
|
|
|
|
db.Model.metadata,
|
|
|
|
|
|
# service_id is a primary key as you can only have one email branding per service
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.Column(
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
db.Column(
|
|
|
|
|
|
"email_branding_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("email_branding.id"),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
),
|
2018-02-01 17:16:48 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2019-02-19 11:47:30 +00:00
|
|
|
|
class Domain(db.Model):
|
|
|
|
|
|
__tablename__ = "domain"
|
|
|
|
|
|
domain = db.Column(db.String(255), primary_key=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
organization_id = db.Column(
|
|
|
|
|
|
"organization_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("organization.id"),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2019-02-19 11:47:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
2023-07-10 11:06:29 -07:00
|
|
|
|
class Organization(db.Model):
|
|
|
|
|
|
__tablename__ = "organization"
|
2023-08-29 14:54:30 -07:00
|
|
|
|
id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=False
|
|
|
|
|
|
)
|
2018-02-10 01:31:24 +00:00
|
|
|
|
name = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
|
|
|
|
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2019-02-19 11:47:30 +00:00
|
|
|
|
agreement_signed = db.Column(db.Boolean, nullable=True)
|
|
|
|
|
|
agreement_signed_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
agreement_signed_by_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.ForeignKey("users.id"),
|
2019-02-19 11:47:30 +00:00
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
agreement_signed_by = db.relationship("User")
|
2019-06-13 16:43:34 +01:00
|
|
|
|
agreement_signed_on_behalf_of_name = db.Column(db.String(255), nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
agreement_signed_on_behalf_of_email_address = db.Column(
|
|
|
|
|
|
db.String(255), nullable=True
|
|
|
|
|
|
)
|
2019-02-19 11:47:30 +00:00
|
|
|
|
agreement_signed_version = db.Column(db.Float, nullable=True)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
organization_type = enum_column(OrganizationType, unique=False, nullable=True)
|
2019-05-10 11:47:42 +01:00
|
|
|
|
request_to_go_live_notes = db.Column(db.Text)
|
2019-02-19 11:47:30 +00:00
|
|
|
|
|
2024-01-12 17:46:00 -05:00
|
|
|
|
domains = db.relationship("Domain")
|
2019-02-19 11:47:30 +00:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
email_branding = db.relationship("EmailBranding")
|
2019-02-19 11:47:30 +00:00
|
|
|
|
email_branding_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.ForeignKey("email_branding.id"),
|
2019-02-19 11:47:30 +00:00
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2021-02-01 14:43:25 +00:00
|
|
|
|
notes = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
purchase_order_number = db.Column(db.String(255), nullable=True)
|
|
|
|
|
|
billing_contact_names = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
billing_contact_email_addresses = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
billing_reference = db.Column(db.String(255), nullable=True)
|
|
|
|
|
|
|
2019-06-12 13:15:25 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def live_services(self):
|
|
|
|
|
|
return [
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service
|
|
|
|
|
|
for service in self.services
|
2019-06-12 13:15:25 +01:00
|
|
|
|
if service.active and not service.restricted
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2019-06-13 15:54:57 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def domain_list(self):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return [domain.domain for domain in self.domains]
|
2019-06-13 15:54:57 +01:00
|
|
|
|
|
2023-11-20 12:31:57 -05:00
|
|
|
|
@property
|
|
|
|
|
|
def agreement(self):
|
|
|
|
|
|
try:
|
|
|
|
|
|
active_agreements = [
|
|
|
|
|
|
agreement
|
|
|
|
|
|
for agreement in self.agreements
|
|
|
|
|
|
if agreement.status == AgreementStatus.ACTIVE
|
|
|
|
|
|
]
|
|
|
|
|
|
return active_agreements[0]
|
|
|
|
|
|
except IndexError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2023-11-20 09:43:40 -05:00
|
|
|
|
@property
|
2023-11-21 12:20:00 -05:00
|
|
|
|
def agreement_active(self):
|
2023-11-22 12:16:24 -05:00
|
|
|
|
try:
|
|
|
|
|
|
return self.agreement.status == AgreementStatus.ACTIVE
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
|
return False
|
2023-11-20 09:43:40 -05:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def has_mou(self):
|
2023-11-22 12:16:24 -05:00
|
|
|
|
try:
|
|
|
|
|
|
return self.agreement.type == AgreementType.MOU
|
|
|
|
|
|
except AttributeError:
|
|
|
|
|
|
return False
|
2023-11-20 09:43:40 -05:00
|
|
|
|
|
2018-02-10 01:31:24 +00:00
|
|
|
|
def serialize(self):
|
2019-02-19 11:47:30 +00:00
|
|
|
|
return {
|
2018-02-10 01:31:24 +00:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"active": self.active,
|
2023-07-10 11:06:29 -07:00
|
|
|
|
"organization_type": self.organization_type,
|
2019-02-19 11:47:30 +00:00
|
|
|
|
"email_branding_id": self.email_branding_id,
|
|
|
|
|
|
"agreement_signed": self.agreement_signed,
|
|
|
|
|
|
"agreement_signed_at": self.agreement_signed_at,
|
|
|
|
|
|
"agreement_signed_by_id": self.agreement_signed_by_id,
|
2019-06-13 16:43:34 +01:00
|
|
|
|
"agreement_signed_on_behalf_of_name": self.agreement_signed_on_behalf_of_name,
|
|
|
|
|
|
"agreement_signed_on_behalf_of_email_address": self.agreement_signed_on_behalf_of_email_address,
|
2019-02-19 11:47:30 +00:00
|
|
|
|
"agreement_signed_version": self.agreement_signed_version,
|
2019-06-13 15:54:57 +01:00
|
|
|
|
"domains": self.domain_list,
|
2019-05-10 11:47:42 +01:00
|
|
|
|
"request_to_go_live_notes": self.request_to_go_live_notes,
|
2019-06-12 13:15:25 +01:00
|
|
|
|
"count_of_live_services": len(self.live_services),
|
2021-02-04 17:33:46 +00:00
|
|
|
|
"notes": self.notes,
|
|
|
|
|
|
"purchase_order_number": self.purchase_order_number,
|
|
|
|
|
|
"billing_contact_names": self.billing_contact_names,
|
|
|
|
|
|
"billing_contact_email_addresses": self.billing_contact_email_addresses,
|
|
|
|
|
|
"billing_reference": self.billing_reference,
|
2018-02-10 01:31:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2019-06-13 15:54:57 +01:00
|
|
|
|
def serialize_for_list(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"active": self.active,
|
|
|
|
|
|
"count_of_live_services": len(self.live_services),
|
|
|
|
|
|
"domains": self.domain_list,
|
|
|
|
|
|
"organization_type": self.organization_type,
|
2019-06-13 15:54:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2018-02-10 01:31:24 +00:00
|
|
|
|
|
2016-04-14 15:09:59 +01:00
|
|
|
|
class Service(db.Model, Versioned):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "services"
|
2016-01-07 17:31:17 +00:00
|
|
|
|
|
2016-02-02 14:16:08 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-02-19 15:52:19 +00:00
|
|
|
|
name = db.Column(db.String(255), nullable=False, unique=True)
|
2016-01-11 15:07:13 +00:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-11 15:07:13 +00:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
active = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.Boolean,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
default=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-04-08 16:13:10 +01:00
|
|
|
|
message_limit = db.Column(db.BigInteger, index=False, unique=False, nullable=False)
|
2023-08-31 10:57:54 -04:00
|
|
|
|
total_message_limit = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.BigInteger,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2023-08-31 10:57:54 -04:00
|
|
|
|
)
|
2016-01-07 17:31:17 +00:00
|
|
|
|
restricted = db.Column(db.Boolean, index=False, unique=False, nullable=False)
|
2016-02-19 15:52:19 +00:00
|
|
|
|
email_from = db.Column(db.Text, index=False, unique=True, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_by = db.relationship("User", foreign_keys=[created_by_id])
|
2017-12-01 16:51:09 +00:00
|
|
|
|
prefix_sms = db.Column(db.Boolean, nullable=False, default=True)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
organization_type = enum_column(OrganizationType, unique=False, nullable=True)
|
2018-01-09 13:24:54 +00:00
|
|
|
|
rate_limit = db.Column(db.Integer, index=False, nullable=False, default=3000)
|
2018-05-31 15:13:31 +01:00
|
|
|
|
contact_link = db.Column(db.String(255), nullable=True, unique=False)
|
2019-02-14 11:32:50 +00:00
|
|
|
|
volume_sms = db.Column(db.Integer(), nullable=True, unique=False)
|
|
|
|
|
|
volume_email = db.Column(db.Integer(), nullable=True, unique=False)
|
2019-03-01 13:53:02 +00:00
|
|
|
|
consent_to_research = db.Column(db.Boolean, nullable=True)
|
2019-03-25 12:21:02 +00:00
|
|
|
|
count_as_live = db.Column(db.Boolean, nullable=False, default=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
go_live_user_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
nullable=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
go_live_user = db.relationship("User", foreign_keys=[go_live_user_id])
|
2019-04-15 17:01:12 +01:00
|
|
|
|
go_live_at = db.Column(db.DateTime, nullable=True)
|
2017-05-22 17:25:58 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
organization_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("organization.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
organization = db.relationship("Organization", backref="services")
|
2016-04-14 15:09:59 +01:00
|
|
|
|
|
2021-01-13 11:53:16 +00:00
|
|
|
|
notes = db.Column(db.Text, nullable=True)
|
2021-01-20 18:00:43 +00:00
|
|
|
|
purchase_order_number = db.Column(db.String(255), nullable=True)
|
2021-01-25 17:53:22 +00:00
|
|
|
|
billing_contact_names = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
billing_contact_email_addresses = db.Column(db.Text, nullable=True)
|
2021-01-20 18:00:43 +00:00
|
|
|
|
billing_reference = db.Column(db.String(255), nullable=True)
|
2021-01-13 11:53:16 +00:00
|
|
|
|
|
2018-02-01 17:16:48 +00:00
|
|
|
|
email_branding = db.relationship(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"EmailBranding",
|
2018-02-01 17:16:48 +00:00
|
|
|
|
secondary=service_email_branding,
|
|
|
|
|
|
uselist=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
backref=db.backref("services", lazy="dynamic"),
|
|
|
|
|
|
)
|
2018-02-01 17:16:48 +00:00
|
|
|
|
|
2017-05-24 16:27:12 +01:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_json(cls, data):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Assumption: data has been validated appropriately.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a Service object based on the provided data. Deserialises created_by to created_by_id as marshmallow
|
|
|
|
|
|
would.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# validate json with marshmallow
|
|
|
|
|
|
fields = data.copy()
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
fields["created_by_id"] = fields.pop("created_by")
|
2017-05-24 16:27:12 +01:00
|
|
|
|
|
|
|
|
|
|
return cls(**fields)
|
|
|
|
|
|
|
2017-08-14 19:47:09 +01:00
|
|
|
|
def get_inbound_number(self):
|
|
|
|
|
|
if self.inbound_number and self.inbound_number.active:
|
|
|
|
|
|
return self.inbound_number.number
|
2017-09-21 16:41:10 +01:00
|
|
|
|
|
|
|
|
|
|
def get_default_sms_sender(self):
|
2025-01-06 11:45:31 -08:00
|
|
|
|
# notify-api-1513 let's try a minimalistic fix
|
|
|
|
|
|
# to see if we can get the right numbers back
|
|
|
|
|
|
default_sms_sender = [
|
|
|
|
|
|
x
|
|
|
|
|
|
for x in self.service_sms_senders
|
|
|
|
|
|
if x.is_default and x.service_id == self.id
|
|
|
|
|
|
]
|
|
|
|
|
|
current_app.logger.info(
|
|
|
|
|
|
f"#notify-api-1513 senders for service {self.name} are {self.service_sms_senders}"
|
|
|
|
|
|
)
|
2023-11-06 12:03:42 -07:00
|
|
|
|
return default_sms_sender[0].sms_sender
|
|
|
|
|
|
|
2017-09-20 10:45:35 +01:00
|
|
|
|
def get_default_reply_to_email_address(self):
|
|
|
|
|
|
default_reply_to = [x for x in self.reply_to_email_addresses if x.is_default]
|
2017-10-04 14:51:02 +01:00
|
|
|
|
return default_reply_to[0].email_address if default_reply_to else None
|
2017-09-20 10:45:35 +01:00
|
|
|
|
|
2017-12-11 11:00:27 +00:00
|
|
|
|
def has_permission(self, permission):
|
|
|
|
|
|
return permission in [p.permission for p in self.permissions]
|
|
|
|
|
|
|
2018-02-13 09:28:48 +00:00
|
|
|
|
def serialize_for_org_dashboard(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"active": self.active,
|
|
|
|
|
|
"restricted": self.restricted,
|
2018-02-13 09:28:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-05-15 12:49:46 +01:00
|
|
|
|
|
2017-10-25 11:35:13 +01:00
|
|
|
|
class AnnualBilling(db.Model):
|
|
|
|
|
|
__tablename__ = "annual_billing"
|
2023-08-29 14:54:30 -07:00
|
|
|
|
id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
default=uuid.uuid4,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
financial_year_start = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.Integer,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
default=True,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
free_sms_fragment_limit = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.Integer,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
UniqueConstraint(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
"financial_year_start",
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
name="ix_annual_billing_service_id",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
Service,
|
|
|
|
|
|
backref=db.backref("annual_billing", uselist=True),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
"financial_year_start",
|
|
|
|
|
|
name="uix_service_id_financial_year_start",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2021-04-20 13:42:20 +01:00
|
|
|
|
|
2017-10-26 13:25:11 +01:00
|
|
|
|
def serialize_free_sms_items(self):
|
2017-10-25 11:35:13 +01:00
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"free_sms_fragment_limit": self.free_sms_fragment_limit,
|
|
|
|
|
|
"financial_year_start": self.financial_year_start,
|
2017-10-25 11:35:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-10-26 13:25:11 +01:00
|
|
|
|
def serialize(self):
|
|
|
|
|
|
def serialize_service():
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return {"id": str(self.service_id), "name": self.service.name}
|
2017-10-26 13:25:11 +01:00
|
|
|
|
|
2022-10-14 14:45:27 +00:00
|
|
|
|
return {
|
2017-10-26 13:25:11 +01:00
|
|
|
|
"id": str(self.id),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"free_sms_fragment_limit": self.free_sms_fragment_limit,
|
|
|
|
|
|
"service_id": self.service_id,
|
|
|
|
|
|
"financial_year_start": self.financial_year_start,
|
2017-10-26 13:25:11 +01:00
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-10-26 13:25:11 +01:00
|
|
|
|
"service": serialize_service() if self.service else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2017-10-25 11:35:13 +01:00
|
|
|
|
|
2017-08-03 14:05:13 +01:00
|
|
|
|
class InboundNumber(db.Model):
|
|
|
|
|
|
__tablename__ = "inbound_numbers"
|
|
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-04-12 13:30:13 -04:00
|
|
|
|
number = db.Column(db.String(255), unique=True, nullable=False)
|
2017-08-03 14:05:13 +01:00
|
|
|
|
provider = db.Column(db.String(), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=True,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
Service,
|
|
|
|
|
|
backref=db.backref("inbound_number", uselist=False),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
active = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.Boolean,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
default=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2024-01-12 17:46:00 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-08-03 14:05:13 +01:00
|
|
|
|
|
2017-08-04 12:13:10 +01:00
|
|
|
|
def serialize(self):
|
2017-08-04 19:06:37 +01:00
|
|
|
|
def serialize_service():
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return {"id": str(self.service_id), "name": self.service.name}
|
2017-08-04 19:06:37 +01:00
|
|
|
|
|
2017-08-04 16:05:03 +01:00
|
|
|
|
return {
|
2017-08-04 12:13:10 +01:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"number": self.number,
|
|
|
|
|
|
"provider": self.provider,
|
2017-08-04 19:06:37 +01:00
|
|
|
|
"service": serialize_service() if self.service else None,
|
2017-08-04 12:13:10 +01:00
|
|
|
|
"active": self.active,
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-08-04 12:13:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-08-03 14:05:13 +01:00
|
|
|
|
|
2017-09-05 17:53:47 +01:00
|
|
|
|
class ServiceSmsSender(db.Model):
|
|
|
|
|
|
__tablename__ = "service_sms_senders"
|
|
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
sms_sender = db.Column(db.String(11), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
Service,
|
|
|
|
|
|
backref=db.backref("service_sms_senders", uselist=True),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-09-05 17:53:47 +01:00
|
|
|
|
is_default = db.Column(db.Boolean, nullable=False, default=True)
|
2018-04-25 10:42:00 +01:00
|
|
|
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
inbound_number_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("inbound_numbers.id"),
|
|
|
|
|
|
unique=True,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
inbound_number = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
InboundNumber,
|
|
|
|
|
|
backref=db.backref("inbound_number", uselist=False),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2024-01-12 17:46:00 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-09-05 17:53:47 +01:00
|
|
|
|
|
2017-12-18 16:16:33 +00:00
|
|
|
|
def get_reply_to_text(self):
|
|
|
|
|
|
return try_validate_and_format_phone_number(self.sms_sender)
|
|
|
|
|
|
|
2017-10-19 09:58:23 +01:00
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"sms_sender": self.sms_sender,
|
2017-10-19 10:43:49 +01:00
|
|
|
|
"service_id": str(self.service_id),
|
2017-10-19 09:58:23 +01:00
|
|
|
|
"is_default": self.is_default,
|
2018-04-25 10:42:00 +01:00
|
|
|
|
"archived": self.archived,
|
2024-04-01 15:12:33 -07:00
|
|
|
|
"inbound_number_id": (
|
|
|
|
|
|
str(self.inbound_number_id) if self.inbound_number_id else None
|
|
|
|
|
|
),
|
2017-10-19 09:58:23 +01:00
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-10-19 09:58:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-09-05 17:53:47 +01:00
|
|
|
|
|
2017-05-22 17:25:58 +01:00
|
|
|
|
class ServicePermission(db.Model):
|
|
|
|
|
|
__tablename__ = "service_permissions"
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
permission = enum_column(
|
|
|
|
|
|
ServicePermissionType,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
index=True,
|
|
|
|
|
|
primary_key=True,
|
2024-01-12 17:27:31 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2024-01-12 17:46:00 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-05-22 17:25:58 +01:00
|
|
|
|
|
2017-05-23 14:24:07 +01:00
|
|
|
|
service_permission_types = db.relationship(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
Service,
|
|
|
|
|
|
backref=db.backref("permissions", cascade="all, delete-orphan"),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-05-22 17:25:58 +01:00
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return "<{} has service permission: {}>".format(
|
|
|
|
|
|
self.service_id, self.permission
|
|
|
|
|
|
)
|
2017-05-22 17:25:58 +01:00
|
|
|
|
|
|
|
|
|
|
|
2020-07-28 10:22:13 +01:00
|
|
|
|
class ServiceGuestList(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "service_whitelist"
|
2016-09-20 15:41:53 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref="guest_list")
|
2024-01-18 11:31:02 -05:00
|
|
|
|
recipient_type = enum_column(RecipientType, nullable=False)
|
2016-09-27 13:44:29 +01:00
|
|
|
|
recipient = db.Column(db.String(255), nullable=False)
|
2024-05-23 13:59:51 -07:00
|
|
|
|
created_at = db.Column(db.DateTime, default=utc_now())
|
2016-09-20 15:41:53 +01:00
|
|
|
|
|
2016-09-22 11:56:26 +01:00
|
|
|
|
@classmethod
|
2016-09-27 13:44:29 +01:00
|
|
|
|
def from_string(cls, service_id, recipient_type, recipient):
|
|
|
|
|
|
instance = cls(service_id=service_id, recipient_type=recipient_type)
|
|
|
|
|
|
|
2016-09-22 11:56:26 +01:00
|
|
|
|
try:
|
2024-01-12 17:27:31 -05:00
|
|
|
|
if recipient_type == RecipientType.MOBILE:
|
2023-08-29 14:54:30 -07:00
|
|
|
|
instance.recipient = validate_phone_number(
|
|
|
|
|
|
recipient, international=True
|
|
|
|
|
|
)
|
2024-01-12 17:27:31 -05:00
|
|
|
|
elif recipient_type == RecipientType.EMAIL:
|
2023-01-06 10:02:23 -05:00
|
|
|
|
instance.recipient = validate_email_address(recipient)
|
2016-09-27 13:44:29 +01:00
|
|
|
|
else:
|
2023-08-29 14:54:30 -07:00
|
|
|
|
raise ValueError("Invalid recipient type")
|
2016-09-27 13:44:29 +01:00
|
|
|
|
except InvalidPhoneError:
|
2020-07-28 10:23:22 +01:00
|
|
|
|
raise ValueError('Invalid guest list: "{}"'.format(recipient))
|
2016-09-22 11:56:26 +01:00
|
|
|
|
except InvalidEmailError:
|
2020-07-28 10:23:22 +01:00
|
|
|
|
raise ValueError('Invalid guest list: "{}"'.format(recipient))
|
2016-09-27 13:44:29 +01:00
|
|
|
|
else:
|
|
|
|
|
|
return instance
|
2016-09-22 11:56:26 +01:00
|
|
|
|
|
2016-11-25 16:58:46 +00:00
|
|
|
|
def __repr__(self):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return "Recipient {} of type: {}".format(self.recipient, self.recipient_type)
|
2016-09-20 15:41:53 +01:00
|
|
|
|
|
2016-09-22 17:18:52 +01:00
|
|
|
|
|
2017-06-15 11:32:51 +01:00
|
|
|
|
class ServiceInboundApi(db.Model, Versioned):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "service_inbound_api"
|
2017-06-13 15:27:13 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
unique=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref="inbound_api")
|
2017-06-19 12:25:05 +01:00
|
|
|
|
url = db.Column(db.String(), nullable=False)
|
2017-06-19 14:32:22 +01:00
|
|
|
|
_bearer_token = db.Column("bearer_token", db.String(), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2024-01-12 17:46:00 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-06-15 11:32:51 +01:00
|
|
|
|
updated_at = db.Column(db.DateTime, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
updated_by = db.relationship("User")
|
|
|
|
|
|
updated_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-06-13 15:27:13 +01:00
|
|
|
|
|
|
|
|
|
|
@property
|
2017-06-19 14:32:22 +01:00
|
|
|
|
def bearer_token(self):
|
2023-08-15 10:35:34 -07:00
|
|
|
|
return encryption.decrypt(self._bearer_token)
|
2017-06-19 14:32:22 +01:00
|
|
|
|
|
|
|
|
|
|
@bearer_token.setter
|
|
|
|
|
|
def bearer_token(self, bearer_token):
|
|
|
|
|
|
if bearer_token:
|
|
|
|
|
|
self._bearer_token = encryption.encrypt(str(bearer_token))
|
2017-06-13 15:27:13 +01:00
|
|
|
|
|
2017-06-15 11:32:51 +01:00
|
|
|
|
def serialize(self):
|
2017-11-28 15:25:15 +00:00
|
|
|
|
return {
|
|
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
|
|
|
|
|
"url": self.url,
|
|
|
|
|
|
"updated_by_id": str(self.updated_by_id),
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-11-28 15:25:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceCallbackApi(db.Model, Versioned):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "service_callback_api"
|
2017-11-28 15:25:15 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref="service_callback_api")
|
2017-11-28 15:25:15 +00:00
|
|
|
|
url = db.Column(db.String(), nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
callback_type = enum_column(CallbackType, nullable=True)
|
2017-11-28 15:25:15 +00:00
|
|
|
|
_bearer_token = db.Column("bearer_token", db.String(), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2024-01-12 17:46:00 -05:00
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-11-28 15:25:15 +00:00
|
|
|
|
updated_at = db.Column(db.DateTime, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
updated_by = db.relationship("User")
|
|
|
|
|
|
updated_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-11-28 15:25:15 +00:00
|
|
|
|
|
2018-07-25 14:12:13 +01:00
|
|
|
|
__table_args__ = (
|
2023-08-29 14:54:30 -07:00
|
|
|
|
UniqueConstraint(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
"service_id",
|
|
|
|
|
|
"callback_type",
|
|
|
|
|
|
name="uix_service_callback_type",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
),
|
2018-07-25 14:12:13 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2017-11-28 15:25:15 +00:00
|
|
|
|
@property
|
|
|
|
|
|
def bearer_token(self):
|
2023-08-15 10:35:34 -07:00
|
|
|
|
return encryption.decrypt(self._bearer_token)
|
2017-11-28 15:25:15 +00:00
|
|
|
|
|
|
|
|
|
|
@bearer_token.setter
|
|
|
|
|
|
def bearer_token(self, bearer_token):
|
|
|
|
|
|
if bearer_token:
|
|
|
|
|
|
self._bearer_token = encryption.encrypt(str(bearer_token))
|
|
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
2017-06-15 11:32:51 +01:00
|
|
|
|
return {
|
2017-06-15 16:19:12 +01:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
2017-06-15 11:32:51 +01:00
|
|
|
|
"url": self.url,
|
2017-06-15 16:19:12 +01:00
|
|
|
|
"updated_by_id": str(self.updated_by_id),
|
2017-06-15 11:32:51 +01:00
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-06-15 11:32:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-13 15:27:13 +01:00
|
|
|
|
|
2016-04-20 17:25:20 +01:00
|
|
|
|
class ApiKey(db.Model, Versioned):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "api_keys"
|
2016-01-13 09:25:46 +00:00
|
|
|
|
|
2016-04-08 13:34:46 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-01-19 12:07:00 +00:00
|
|
|
|
name = db.Column(db.String(255), nullable=False)
|
2017-06-19 14:32:22 +01:00
|
|
|
|
_secret = db.Column("secret", db.String(255), unique=True, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref="api_keys")
|
2024-01-18 11:31:02 -05:00
|
|
|
|
key_type = enum_column(KeyType, index=True, nullable=False)
|
2016-01-13 09:25:46 +00:00
|
|
|
|
expiry_date = db.Column(db.DateTime)
|
2016-04-20 17:25:20 +01:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-04-20 17:25:20 +01:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_by = db.relationship("User")
|
|
|
|
|
|
created_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-13 09:25:46 +00:00
|
|
|
|
|
2016-01-21 16:53:53 +00:00
|
|
|
|
__table_args__ = (
|
2023-08-29 14:54:30 -07:00
|
|
|
|
Index(
|
|
|
|
|
|
"uix_service_to_key_name",
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
"name",
|
|
|
|
|
|
unique=True,
|
|
|
|
|
|
postgresql_where=expiry_date.is_(None),
|
|
|
|
|
|
),
|
2016-01-21 16:53:53 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2016-06-29 14:15:32 +01:00
|
|
|
|
@property
|
2017-06-19 14:32:22 +01:00
|
|
|
|
def secret(self):
|
2023-08-15 10:35:34 -07:00
|
|
|
|
return encryption.decrypt(self._secret)
|
2017-06-19 14:32:22 +01:00
|
|
|
|
|
|
|
|
|
|
@secret.setter
|
|
|
|
|
|
def secret(self, secret):
|
|
|
|
|
|
if secret:
|
|
|
|
|
|
self._secret = encryption.encrypt(str(secret))
|
2016-06-29 14:15:32 +01:00
|
|
|
|
|
2016-01-13 09:25:46 +00:00
|
|
|
|
|
2018-10-26 16:01:31 +01:00
|
|
|
|
class TemplateFolder(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "template_folder"
|
2018-10-26 16:01:31 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-10-26 16:01:31 +01:00
|
|
|
|
name = db.Column(db.String, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
parent_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("template_folder.id"),
|
|
|
|
|
|
nullable=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-10-26 16:01:31 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service = db.relationship("Service", backref="all_template_folders")
|
|
|
|
|
|
parent = db.relationship("TemplateFolder", remote_side=[id], backref="subfolders")
|
2019-02-20 16:18:48 +00:00
|
|
|
|
users = db.relationship(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"ServiceUser",
|
2019-02-20 16:18:48 +00:00
|
|
|
|
uselist=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
backref=db.backref(
|
|
|
|
|
|
"folders", foreign_keys="user_folder_permissions.c.template_folder_id"
|
|
|
|
|
|
),
|
|
|
|
|
|
secondary="user_folder_permissions",
|
|
|
|
|
|
primaryjoin="TemplateFolder.id == user_folder_permissions.c.template_folder_id",
|
2019-02-20 16:18:48 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__table_args__ = (UniqueConstraint("id", "service_id", name="ix_id_service_id"), {})
|
2018-10-30 16:26:25 +00:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"parent_id": self.parent_id,
|
|
|
|
|
|
"service_id": self.service_id,
|
|
|
|
|
|
"users_with_permission": self.get_users_with_permission(),
|
2018-10-30 16:26:25 +00:00
|
|
|
|
}
|
2018-10-26 16:01:31 +01:00
|
|
|
|
|
2018-11-08 16:44:57 +00:00
|
|
|
|
def is_parent_of(self, other):
|
|
|
|
|
|
while other.parent is not None:
|
|
|
|
|
|
if other.parent == self:
|
|
|
|
|
|
return True
|
|
|
|
|
|
other = other.parent
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2019-02-22 13:26:20 +00:00
|
|
|
|
def get_users_with_permission(self):
|
|
|
|
|
|
service_users = self.users
|
2023-08-29 14:54:30 -07:00
|
|
|
|
users_with_permission = [
|
|
|
|
|
|
str(service_user.user_id) for service_user in service_users
|
|
|
|
|
|
]
|
2019-02-22 13:26:20 +00:00
|
|
|
|
|
|
|
|
|
|
return users_with_permission
|
|
|
|
|
|
|
2018-10-26 16:01:31 +01:00
|
|
|
|
|
|
|
|
|
|
template_folder_map = db.Table(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"template_folder_map",
|
2018-10-26 16:01:31 +01:00
|
|
|
|
db.Model.metadata,
|
|
|
|
|
|
# template_id is a primary key as a template can only belong in one folder
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.Column(
|
|
|
|
|
|
"template_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("templates.id"),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
db.Column(
|
|
|
|
|
|
"template_folder_id",
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("template_folder.id"),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
),
|
2018-10-26 16:01:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
class TemplateBase(db.Model):
|
|
|
|
|
|
__abstract__ = True
|
2016-01-13 11:04:13 +00:00
|
|
|
|
|
2017-11-22 15:55:11 +00:00
|
|
|
|
def __init__(self, **kwargs):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
if "template_type" in kwargs:
|
|
|
|
|
|
self.template_type = kwargs.pop("template_type")
|
2017-11-22 15:55:11 +00:00
|
|
|
|
|
|
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
|
|
|
2016-04-08 13:34:46 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-01-13 11:04:13 +00:00
|
|
|
|
name = db.Column(db.String(255), nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
template_type = enum_column(TemplateType, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2024-05-23 13:59:51 -07:00
|
|
|
|
updated_at = db.Column(db.DateTime, onupdate=utc_now())
|
2017-11-21 14:43:07 +00:00
|
|
|
|
content = db.Column(db.Text, nullable=False)
|
|
|
|
|
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
2018-02-22 11:53:42 +00:00
|
|
|
|
hidden = db.Column(db.Boolean, nullable=False, default=False)
|
2017-11-21 14:43:07 +00:00
|
|
|
|
subject = db.Column(db.Text)
|
2017-06-28 16:10:22 +01:00
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
@declared_attr
|
|
|
|
|
|
def service_id(cls):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("services.id"), index=True, nullable=False
|
|
|
|
|
|
)
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
|
|
|
|
|
@declared_attr
|
|
|
|
|
|
def created_by_id(cls):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("users.id"), index=True, nullable=False
|
|
|
|
|
|
)
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
|
|
|
|
|
@declared_attr
|
|
|
|
|
|
def created_by(cls):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return db.relationship("User")
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
|
|
|
|
|
@declared_attr
|
|
|
|
|
|
def process_type(cls):
|
2024-01-18 11:31:02 -05:00
|
|
|
|
return enum_column(
|
|
|
|
|
|
TemplateProcessType,
|
2017-11-21 14:43:07 +00:00
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=TemplateProcessType.NORMAL,
|
2016-11-28 11:13:11 +00:00
|
|
|
|
)
|
2016-11-18 17:36:11 +00:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
redact_personalisation = association_proxy(
|
|
|
|
|
|
"template_redacted", "redact_personalisation"
|
|
|
|
|
|
)
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
2023-03-02 20:20:31 -05:00
|
|
|
|
# TODO: possibly unnecessary after removing letters
|
2017-11-21 14:46:08 +00:00
|
|
|
|
@property
|
|
|
|
|
|
def reply_to(self):
|
2023-03-02 20:20:31 -05:00
|
|
|
|
return None
|
2017-11-21 14:46:08 +00:00
|
|
|
|
|
|
|
|
|
|
@reply_to.setter
|
|
|
|
|
|
def reply_to(self, value):
|
2023-03-02 20:20:31 -05:00
|
|
|
|
if value is None:
|
2017-11-21 14:46:08 +00:00
|
|
|
|
pass
|
|
|
|
|
|
else:
|
2023-08-29 14:54:30 -07:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Unable to set sender for {} template".format(self.template_type)
|
|
|
|
|
|
)
|
2017-11-21 14:46:08 +00:00
|
|
|
|
|
2017-12-15 17:10:45 +00:00
|
|
|
|
def get_reply_to_text(self):
|
2024-02-28 12:40:52 -05:00
|
|
|
|
if self.template_type == TemplateType.EMAIL:
|
2017-12-15 17:10:45 +00:00
|
|
|
|
return self.service.get_default_reply_to_email_address()
|
2024-02-28 12:40:52 -05:00
|
|
|
|
elif self.template_type == TemplateType.SMS:
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return try_validate_and_format_phone_number(
|
|
|
|
|
|
self.service.get_default_sms_sender()
|
|
|
|
|
|
)
|
2017-12-15 17:10:45 +00:00
|
|
|
|
else:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2017-09-20 10:27:18 +01:00
|
|
|
|
def _as_utils_template(self):
|
2024-02-28 12:40:52 -05:00
|
|
|
|
if self.template_type == TemplateType.EMAIL:
|
2020-04-06 12:50:22 +01:00
|
|
|
|
return PlainTextEmailTemplate(self.__dict__)
|
2024-02-08 13:14:19 -05:00
|
|
|
|
elif self.template_type == TemplateType.SMS:
|
2020-04-06 12:50:22 +01:00
|
|
|
|
return SMSMessageTemplate(self.__dict__)
|
2024-02-08 13:14:19 -05:00
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"{self.template_type} is an invalid template type.")
|
2017-09-20 10:27:18 +01:00
|
|
|
|
|
2020-04-06 14:25:43 +01:00
|
|
|
|
def _as_utils_template_with_personalisation(self, values):
|
|
|
|
|
|
template = self._as_utils_template()
|
|
|
|
|
|
template.values = values
|
|
|
|
|
|
return template
|
|
|
|
|
|
|
2020-07-06 16:41:53 +01:00
|
|
|
|
def serialize_for_v2(self):
|
2017-03-14 15:25:36 +00:00
|
|
|
|
serialized = {
|
2017-03-28 10:41:25 +01:00
|
|
|
|
"id": str(self.id),
|
2017-03-14 15:25:36 +00:00
|
|
|
|
"type": self.template_type,
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-03-14 15:25:36 +00:00
|
|
|
|
"created_by": self.created_by.email_address,
|
|
|
|
|
|
"version": self.version,
|
|
|
|
|
|
"body": self.content,
|
2024-04-01 15:12:33 -07:00
|
|
|
|
"subject": (
|
|
|
|
|
|
self.subject if self.template_type == TemplateType.EMAIL else None
|
|
|
|
|
|
),
|
2017-08-15 14:34:02 +01:00
|
|
|
|
"name": self.name,
|
2017-09-22 10:12:32 +01:00
|
|
|
|
"personalisation": {
|
|
|
|
|
|
key: {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"required": True,
|
2017-09-22 10:12:32 +01:00
|
|
|
|
}
|
|
|
|
|
|
for key in self._as_utils_template().placeholders
|
|
|
|
|
|
},
|
2017-03-14 15:25:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return serialized
|
|
|
|
|
|
|
2016-08-02 16:23:14 +01:00
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
class Template(TemplateBase):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "templates"
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service = db.relationship("Service", backref="templates")
|
2017-11-21 14:43:07 +00:00
|
|
|
|
version = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
|
|
|
2018-10-26 16:01:31 +01:00
|
|
|
|
folder = db.relationship(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"TemplateFolder",
|
2018-10-26 16:01:31 +01:00
|
|
|
|
secondary=template_folder_map,
|
|
|
|
|
|
uselist=False,
|
eager join folders from the Template object to keep versioning working
The `@version_class` decorator looks at every dirty (modified) model in
the session to work out which new history models to create. However, if
there are dirty items in the session, sqlalchemy might flush to the
database, clearing the whole session.
We ran into problems with the archive service function, which is
versioned for api keys, templates and services. When constructing the
TemplateHistory objects, `history_meta.py::create_history` would call
getattr on `Template.folders`, which would make a database call to join
across to the TemplateFolder objects - this would then flush the dirty
Service object from the session before the ServiceHistory object was
created.
To get around this, we eager load the Template.folder object, joining
on to it automatically when the Template is fetched. That way, it
doesn't make a SELECT mid-way through the version decorator, and the
history is preserved.
Note: This relationship is only on Template, not TemplateHistory - so
we're not doing this join every single time we send a message.
2018-10-29 11:57:24 +00:00
|
|
|
|
# eagerly load the folder whenever the template object is fetched
|
2023-08-29 14:54:30 -07:00
|
|
|
|
lazy="joined",
|
|
|
|
|
|
backref=db.backref("templates"),
|
2018-10-26 16:01:31 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
def get_link(self):
|
|
|
|
|
|
return url_for(
|
|
|
|
|
|
"template.get_template_by_id_and_service_id",
|
|
|
|
|
|
service_id=self.service_id,
|
|
|
|
|
|
template_id=self.id,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
_external=True,
|
2017-11-21 14:43:07 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2018-11-02 16:00:22 +00:00
|
|
|
|
@classmethod
|
2018-11-05 10:54:42 +00:00
|
|
|
|
def from_json(cls, data, folder):
|
2018-11-02 16:00:22 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Assumption: data has been validated appropriately.
|
|
|
|
|
|
Returns a Template object based on the provided data.
|
|
|
|
|
|
"""
|
|
|
|
|
|
fields = data.copy()
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
fields["created_by_id"] = fields.pop("created_by")
|
|
|
|
|
|
fields["service_id"] = fields.pop("service")
|
|
|
|
|
|
fields["folder"] = folder
|
2018-11-02 16:00:22 +00:00
|
|
|
|
return cls(**fields)
|
|
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
|
2017-06-28 10:26:25 +01:00
|
|
|
|
class TemplateRedacted(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "template_redacted"
|
2017-06-28 10:26:25 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
template_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("templates.id"),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2017-06-28 10:26:25 +01:00
|
|
|
|
redact_personalisation = db.Column(db.Boolean, nullable=False, default=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
index=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_by = db.relationship("User")
|
2017-06-28 10:26:25 +01:00
|
|
|
|
|
|
|
|
|
|
# uselist=False as this is a one-to-one relationship
|
2023-08-29 14:54:30 -07:00
|
|
|
|
template = db.relationship(
|
|
|
|
|
|
"Template",
|
|
|
|
|
|
uselist=False,
|
|
|
|
|
|
backref=db.backref("template_redacted", uselist=False),
|
|
|
|
|
|
)
|
2017-06-28 10:26:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
class TemplateHistory(TemplateBase):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "templates_history"
|
2016-08-02 16:23:14 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service = db.relationship("Service")
|
2016-10-04 10:47:34 +01:00
|
|
|
|
version = db.Column(db.Integer, primary_key=True, nullable=False)
|
2016-01-15 11:12:05 +00:00
|
|
|
|
|
2017-11-21 14:43:07 +00:00
|
|
|
|
@declared_attr
|
|
|
|
|
|
def template_redacted(cls):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return db.relationship(
|
|
|
|
|
|
"TemplateRedacted",
|
|
|
|
|
|
foreign_keys=[cls.id],
|
|
|
|
|
|
primaryjoin="TemplateRedacted.template_id == TemplateHistory.id",
|
|
|
|
|
|
)
|
2017-11-09 14:50:18 +00:00
|
|
|
|
|
2017-11-09 14:48:27 +00:00
|
|
|
|
def get_link(self):
|
|
|
|
|
|
return url_for(
|
2024-05-30 12:27:07 -07:00
|
|
|
|
"template.get_template_by_id_and_service_id",
|
2017-11-09 14:48:27 +00:00
|
|
|
|
template_id=self.id,
|
2024-05-30 12:27:07 -07:00
|
|
|
|
service_id=self.service.id,
|
2017-11-09 14:48:27 +00:00
|
|
|
|
version=self.version,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
_external=True,
|
2017-11-09 14:48:27 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2017-02-01 09:19:32 +00:00
|
|
|
|
|
2016-05-05 09:55:25 +01:00
|
|
|
|
class ProviderDetails(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "provider_details"
|
2016-05-05 09:55:25 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
display_name = db.Column(db.String, nullable=False)
|
|
|
|
|
|
identifier = db.Column(db.String, nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, nullable=False)
|
2016-12-15 17:11:47 +00:00
|
|
|
|
active = db.Column(db.Boolean, default=False, nullable=False)
|
2016-12-19 16:49:56 +00:00
|
|
|
|
version = db.Column(db.Integer, default=1, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
created_by = db.relationship("User")
|
2017-04-25 10:36:37 +01:00
|
|
|
|
supports_international = db.Column(db.Boolean, nullable=False, default=False)
|
2016-12-15 17:11:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProviderDetailsHistory(db.Model, HistoryModel):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "provider_details_history"
|
2016-12-15 17:11:47 +00:00
|
|
|
|
|
2016-12-19 16:49:56 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, nullable=False)
|
2016-12-15 17:11:47 +00:00
|
|
|
|
display_name = db.Column(db.String, nullable=False)
|
|
|
|
|
|
identifier = db.Column(db.String, nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, nullable=False)
|
2016-12-15 17:11:47 +00:00
|
|
|
|
active = db.Column(db.Boolean, nullable=False)
|
2016-12-19 16:49:56 +00:00
|
|
|
|
version = db.Column(db.Integer, primary_key=True, nullable=False)
|
2024-05-23 13:59:51 -07:00
|
|
|
|
updated_at = db.Column(db.DateTime, nullable=True, onupdate=utc_now())
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_by_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("users.id"), index=True, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
created_by = db.relationship("User")
|
2017-04-25 10:36:37 +01:00
|
|
|
|
supports_international = db.Column(db.Boolean, nullable=False, default=False)
|
2016-04-21 11:37:38 +01:00
|
|
|
|
|
|
|
|
|
|
|
2016-01-15 11:12:05 +00:00
|
|
|
|
class Job(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "jobs"
|
2016-01-15 11:12:05 +00:00
|
|
|
|
|
2017-07-26 15:57:30 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-01-15 11:12:05 +00:00
|
|
|
|
original_file_name = db.Column(db.String, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref=db.backref("jobs", lazy="dynamic"))
|
|
|
|
|
|
template_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("templates.id"), index=True, unique=False
|
|
|
|
|
|
)
|
|
|
|
|
|
template = db.relationship("Template", backref=db.backref("jobs", lazy="dynamic"))
|
2016-05-11 17:04:51 +01:00
|
|
|
|
template_version = db.Column(db.Integer, nullable=False)
|
2016-01-15 11:12:05 +00:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-15 11:12:05 +00:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-02-22 14:56:09 +00:00
|
|
|
|
notification_count = db.Column(db.Integer, nullable=False)
|
2016-03-04 14:25:28 +00:00
|
|
|
|
notifications_sent = db.Column(db.Integer, nullable=False, default=0)
|
2016-05-23 15:44:57 +01:00
|
|
|
|
notifications_delivered = db.Column(db.Integer, nullable=False, default=0)
|
|
|
|
|
|
notifications_failed = db.Column(db.Integer, nullable=False, default=0)
|
|
|
|
|
|
|
2016-02-25 09:59:50 +00:00
|
|
|
|
processing_started = db.Column(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.DateTime, index=False, unique=False, nullable=True
|
|
|
|
|
|
)
|
2016-02-25 09:59:50 +00:00
|
|
|
|
processing_finished = db.Column(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.DateTime, index=False, unique=False, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
created_by = db.relationship("User")
|
|
|
|
|
|
created_by_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("users.id"), index=True, nullable=True
|
|
|
|
|
|
)
|
|
|
|
|
|
scheduled_for = db.Column(db.DateTime, index=True, unique=False, nullable=True)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
job_status = enum_column(
|
|
|
|
|
|
JobStatus,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=JobStatus.PENDING,
|
2016-08-24 14:16:39 +01:00
|
|
|
|
)
|
2018-11-22 15:51:10 +00:00
|
|
|
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
2016-01-21 17:29:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VerifyCode(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "verify_codes"
|
2016-01-21 17:29:24 +00:00
|
|
|
|
|
2016-04-08 13:34:46 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
user_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("users.id"), index=True, nullable=False
|
|
|
|
|
|
)
|
|
|
|
|
|
user = db.relationship("User", backref=db.backref("verify_codes", lazy="dynamic"))
|
2016-01-21 17:29:24 +00:00
|
|
|
|
_code = db.Column(db.String, nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
code_type = enum_column(CodeType, index=False, unique=False, nullable=False)
|
2016-01-21 17:29:24 +00:00
|
|
|
|
expiry_datetime = db.Column(db.DateTime, nullable=False)
|
|
|
|
|
|
code_used = db.Column(db.Boolean, default=False)
|
|
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-01-21 17:29:24 +00:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def code(self):
|
|
|
|
|
|
raise AttributeError("Code not readable")
|
|
|
|
|
|
|
|
|
|
|
|
@code.setter
|
|
|
|
|
|
def code(self, cde):
|
|
|
|
|
|
self._code = hashpw(cde)
|
|
|
|
|
|
|
|
|
|
|
|
def check_code(self, cde):
|
|
|
|
|
|
return check_hash(cde, self._code)
|
2016-02-09 12:01:17 +00:00
|
|
|
|
|
2016-09-07 13:44:56 +01:00
|
|
|
|
|
Use notification view for status / billing tasks
This fixes a bug where (letter) notifications left in sending would
temporarily get excluded from billing and status calculations once
the service retention period had elapsed, and then get included once
again when they finally get marked as delivered.*
Status and billing tasks shouldn't need to have knowledge about which
table their data is in and getting this wrong is the fundamental cause
of the bug here. Adding a view across both tables abstracts this away
while keeping the query complexity the same.
Using a view also has the added benefit that we no longer need to care
when the status / billing tasks run in comparison to the deletion task,
since we will retrieve the same data irrespective (see below for a more
detailed discussion on data integrity).
*Such a scenario is rare but has happened.
A New View
==========
I've included all the columns that are shared between the two tables,
even though only a subset are actually needed. Having extra columns
has no impact and may be useful in future.
Although the view isn't actually a table, SQLAlchemy appears to wrap
it without any issues, noting that the package doesn't have any direct
support for "view models". Because we're never inserting data, we don't
need most of the kwargs when defining columns.*
*Note that the "default" kwarg doesn't affect data that's retrieved,
only data that's written (if no value is set).
Data Integrity
==============
The (new) tests cover the main scenarios.
We need to be careful with how the view interacts with the deletion /
archiving task. There are two concerns here:
- Duplicates. The deletion task inserts before it deletes [^1], so we
could end up double counting. It turns out this isn't a problem because
a Postgres UNION is an implicit "DISTINCT" [^2]. I've also verified this
manually, just to be on the safe side.
- No data. It's conceivable that the query will check the history table
just before the insertion, then check the notifications table just after
the deletion. It turns out this isn't a problem either because the whole
query sees the same DB snapshot [^3][^4].*
*I can't think of a way to test this as it's a race condition, but I'm
confident the Postgres docs are accurate.
Performance
===========
I copied the relevant (non-PII) columns from Production for data going
back to 2022-04-01. I then ran several tests.
Queries using the new view still make use of indices on a per-table basis,
as the following query plan illustrates:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=1130820.02..1135353.89 rows=46502 width=97) (actual time=629.863..756.703 rows=72 loops=1)
Group Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
-> Sort (cost=1130820.02..1131401.28 rows=232506 width=85) (actual time=629.756..708.914 rows=217563 loops=1)
Sort Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
Sort Method: external merge Disk: 9320kB
-> Subquery Scan on notifications_all_time_view (cost=1088506.43..1098969.20 rows=232506 width=85) (actual time=416.118..541.669 rows=217563 loops=1)
-> Unique (cost=1088506.43..1096644.14 rows=232506 width=725) (actual time=416.115..513.065 rows=217563 loops=1)
-> Sort (cost=1088506.43..1089087.70 rows=232506 width=725) (actual time=416.115..451.190 rows=217563 loops=1)
Sort Key: notifications_no_pii.id, notifications_no_pii.job_id, notifications_no_pii.service_id, notifications_no_pii.template_id, notifications_no_pii.key_type, notifications_no_pii.billable_units, notifications_no_pii.notification_type, notifications_no_pii.created_at, notifications_no_pii.sent_by, notifications_no_pii.notification_status, notifications_no_pii.international, notifications_no_pii.rate_multiplier, notifications_no_pii.postage
Sort Method: external merge Disk: 23936kB
-> Append (cost=114.42..918374.12 rows=232506 width=725) (actual time=2.051..298.229 rows=217563 loops=1)
-> Bitmap Heap Scan on notifications_no_pii (cost=114.42..8557.55 rows=2042 width=113) (actual time=1.405..1.442 rows=0 loops=1)
Recheck Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: ((key_type)::text = ANY ('{normal,team}'::text[]))
-> Bitmap Index Scan on ix_notifications_no_piiservice_id_composite (cost=0.00..113.91 rows=2202 width=0) (actual time=1.402..1.439 rows=0 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
-> Index Scan using ix_notifications_history_no_pii_service_id_composite on notifications_history_no_pii (cost=0.70..906328.97 rows=230464 width=113) (actual time=0.645..281.612 rows=217563 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND ((key_type)::text = ANY ('{normal,team}'::text[])) AND (notification_type = 'sms'::notification_type) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[]))
Planning Time: 18.032 ms
Execution Time: 759.001 ms
(21 rows)
Queries using the new view appear to be slower than without, but the
differences I've seen are minimal: the original queries execute in
seconds locally and in Production, so it's not a big issue.
Notes: Performance
==================
I downloaded a minimal set of columns for testing:
\copy (
select
id, notification_type, key_type, created_at, service_id,
template_id, sent_by, rate_multiplier, international,
billable_units, postage, job_id, notification_status
from notifications
) to 'notifications.csv' delimiter ',' csv header;
CREATE TABLE notifications_no_pii (
id uuid NOT NULL,
notification_type public.notification_type NOT NULL,
key_type character varying(255) NOT NULL,
created_at timestamp without time zone NOT NULL,
service_id uuid,
template_id uuid,
sent_by character varying,
rate_multiplier numeric,
international boolean,
billable_units integer NOT NULL,
postage character varying,
job_id uuid,
notification_status text
);
copy notifications_no_pii from '/Users/ben.thorner/Desktop/notifications.csv' delimiter ',' csv header;
CREATE INDEX ix_notifications_no_piicreated_at ON notifications_no_pii USING btree (created_at);
CREATE INDEX ix_notifications_no_piijob_id ON notifications_no_pii USING btree (job_id);
CREATE INDEX ix_notifications_no_piinotification_type_composite ON notifications_no_pii USING btree (notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piiservice_created_at ON notifications_no_pii USING btree (service_id, created_at);
CREATE INDEX ix_notifications_no_piiservice_id_composite ON notifications_no_pii USING btree (service_id, notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piitemplate_id ON notifications_no_pii USING btree (template_id);
And similarly for the history table. I then created a sepatate view
across both of these temporary tables using just these columns.
To test performance I created some queries that reflect what is run
by the billing [^5] and status [^6] tasks e.g.
explain analyze select template_id, sent_by, rate_multiplier, international, sum(billable_units), count(*)
from notifications_all_time_view
where
notification_status in ('sending', 'sent', 'delivered', 'pending', 'temporary-failure', 'permanent-failure')
and key_type in ('normal', 'team')
and created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
group by 1,2,3,4;
explain analyze select template_id, job_id, key_type, notification_status, count(*)
from notifications_all_time_view
where created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
and key_type in ('normal', 'team')
group by 1,2,3,4;
Between running queries I restarted my local database and also ran
a command to purge disk caches [^7].
I tested on a few services:
- c5956607-20b1-48b4-8983-85d11404e61f on 2022-05-02 (high volume)
- 0cc696c6-b792-409d-99e9-64232f461b0f on 2022-04-06 (highest volume)
- 01135db6-7819-4121-8b97-4aa2d741e372 on 2022-04-14 (very low volume)
All execution results are of the same magnitude using the view compared
to the worst case of either table on its own.
[^1]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/notifications_dao.py#L389
[^2]: https://stackoverflow.com/questions/49925/what-is-the-difference-between-union-and-union-all
[^3]: https://www.postgresql.org/docs/current/transaction-iso.html
[^4]: https://dba.stackexchange.com/questions/210485/can-sub-selects-change-in-one-single-query-in-a-read-committed-transaction
[^5]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_billing_dao.py#L471
[^6]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_notification_status_dao.py#L58
[^7]: https://stackoverflow.com/questions/28845524/echo-3-proc-sys-vm-drop-caches-on-mac-osx
2022-05-19 11:28:44 +01:00
|
|
|
|
class NotificationAllTimeView(db.Model):
|
|
|
|
|
|
"""
|
|
|
|
|
|
WARNING: this view is a union of rows in "notifications" and
|
|
|
|
|
|
"notification_history". Any query on this view will query both
|
|
|
|
|
|
tables and therefore rely on *both* sets of indices.
|
|
|
|
|
|
"""
|
2023-08-29 14:54:30 -07:00
|
|
|
|
|
|
|
|
|
|
__tablename__ = "notifications_all_time_view"
|
Use notification view for status / billing tasks
This fixes a bug where (letter) notifications left in sending would
temporarily get excluded from billing and status calculations once
the service retention period had elapsed, and then get included once
again when they finally get marked as delivered.*
Status and billing tasks shouldn't need to have knowledge about which
table their data is in and getting this wrong is the fundamental cause
of the bug here. Adding a view across both tables abstracts this away
while keeping the query complexity the same.
Using a view also has the added benefit that we no longer need to care
when the status / billing tasks run in comparison to the deletion task,
since we will retrieve the same data irrespective (see below for a more
detailed discussion on data integrity).
*Such a scenario is rare but has happened.
A New View
==========
I've included all the columns that are shared between the two tables,
even though only a subset are actually needed. Having extra columns
has no impact and may be useful in future.
Although the view isn't actually a table, SQLAlchemy appears to wrap
it without any issues, noting that the package doesn't have any direct
support for "view models". Because we're never inserting data, we don't
need most of the kwargs when defining columns.*
*Note that the "default" kwarg doesn't affect data that's retrieved,
only data that's written (if no value is set).
Data Integrity
==============
The (new) tests cover the main scenarios.
We need to be careful with how the view interacts with the deletion /
archiving task. There are two concerns here:
- Duplicates. The deletion task inserts before it deletes [^1], so we
could end up double counting. It turns out this isn't a problem because
a Postgres UNION is an implicit "DISTINCT" [^2]. I've also verified this
manually, just to be on the safe side.
- No data. It's conceivable that the query will check the history table
just before the insertion, then check the notifications table just after
the deletion. It turns out this isn't a problem either because the whole
query sees the same DB snapshot [^3][^4].*
*I can't think of a way to test this as it's a race condition, but I'm
confident the Postgres docs are accurate.
Performance
===========
I copied the relevant (non-PII) columns from Production for data going
back to 2022-04-01. I then ran several tests.
Queries using the new view still make use of indices on a per-table basis,
as the following query plan illustrates:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=1130820.02..1135353.89 rows=46502 width=97) (actual time=629.863..756.703 rows=72 loops=1)
Group Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
-> Sort (cost=1130820.02..1131401.28 rows=232506 width=85) (actual time=629.756..708.914 rows=217563 loops=1)
Sort Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
Sort Method: external merge Disk: 9320kB
-> Subquery Scan on notifications_all_time_view (cost=1088506.43..1098969.20 rows=232506 width=85) (actual time=416.118..541.669 rows=217563 loops=1)
-> Unique (cost=1088506.43..1096644.14 rows=232506 width=725) (actual time=416.115..513.065 rows=217563 loops=1)
-> Sort (cost=1088506.43..1089087.70 rows=232506 width=725) (actual time=416.115..451.190 rows=217563 loops=1)
Sort Key: notifications_no_pii.id, notifications_no_pii.job_id, notifications_no_pii.service_id, notifications_no_pii.template_id, notifications_no_pii.key_type, notifications_no_pii.billable_units, notifications_no_pii.notification_type, notifications_no_pii.created_at, notifications_no_pii.sent_by, notifications_no_pii.notification_status, notifications_no_pii.international, notifications_no_pii.rate_multiplier, notifications_no_pii.postage
Sort Method: external merge Disk: 23936kB
-> Append (cost=114.42..918374.12 rows=232506 width=725) (actual time=2.051..298.229 rows=217563 loops=1)
-> Bitmap Heap Scan on notifications_no_pii (cost=114.42..8557.55 rows=2042 width=113) (actual time=1.405..1.442 rows=0 loops=1)
Recheck Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: ((key_type)::text = ANY ('{normal,team}'::text[]))
-> Bitmap Index Scan on ix_notifications_no_piiservice_id_composite (cost=0.00..113.91 rows=2202 width=0) (actual time=1.402..1.439 rows=0 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
-> Index Scan using ix_notifications_history_no_pii_service_id_composite on notifications_history_no_pii (cost=0.70..906328.97 rows=230464 width=113) (actual time=0.645..281.612 rows=217563 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND ((key_type)::text = ANY ('{normal,team}'::text[])) AND (notification_type = 'sms'::notification_type) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[]))
Planning Time: 18.032 ms
Execution Time: 759.001 ms
(21 rows)
Queries using the new view appear to be slower than without, but the
differences I've seen are minimal: the original queries execute in
seconds locally and in Production, so it's not a big issue.
Notes: Performance
==================
I downloaded a minimal set of columns for testing:
\copy (
select
id, notification_type, key_type, created_at, service_id,
template_id, sent_by, rate_multiplier, international,
billable_units, postage, job_id, notification_status
from notifications
) to 'notifications.csv' delimiter ',' csv header;
CREATE TABLE notifications_no_pii (
id uuid NOT NULL,
notification_type public.notification_type NOT NULL,
key_type character varying(255) NOT NULL,
created_at timestamp without time zone NOT NULL,
service_id uuid,
template_id uuid,
sent_by character varying,
rate_multiplier numeric,
international boolean,
billable_units integer NOT NULL,
postage character varying,
job_id uuid,
notification_status text
);
copy notifications_no_pii from '/Users/ben.thorner/Desktop/notifications.csv' delimiter ',' csv header;
CREATE INDEX ix_notifications_no_piicreated_at ON notifications_no_pii USING btree (created_at);
CREATE INDEX ix_notifications_no_piijob_id ON notifications_no_pii USING btree (job_id);
CREATE INDEX ix_notifications_no_piinotification_type_composite ON notifications_no_pii USING btree (notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piiservice_created_at ON notifications_no_pii USING btree (service_id, created_at);
CREATE INDEX ix_notifications_no_piiservice_id_composite ON notifications_no_pii USING btree (service_id, notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piitemplate_id ON notifications_no_pii USING btree (template_id);
And similarly for the history table. I then created a sepatate view
across both of these temporary tables using just these columns.
To test performance I created some queries that reflect what is run
by the billing [^5] and status [^6] tasks e.g.
explain analyze select template_id, sent_by, rate_multiplier, international, sum(billable_units), count(*)
from notifications_all_time_view
where
notification_status in ('sending', 'sent', 'delivered', 'pending', 'temporary-failure', 'permanent-failure')
and key_type in ('normal', 'team')
and created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
group by 1,2,3,4;
explain analyze select template_id, job_id, key_type, notification_status, count(*)
from notifications_all_time_view
where created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
and key_type in ('normal', 'team')
group by 1,2,3,4;
Between running queries I restarted my local database and also ran
a command to purge disk caches [^7].
I tested on a few services:
- c5956607-20b1-48b4-8983-85d11404e61f on 2022-05-02 (high volume)
- 0cc696c6-b792-409d-99e9-64232f461b0f on 2022-04-06 (highest volume)
- 01135db6-7819-4121-8b97-4aa2d741e372 on 2022-04-14 (very low volume)
All execution results are of the same magnitude using the view compared
to the worst case of either table on its own.
[^1]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/notifications_dao.py#L389
[^2]: https://stackoverflow.com/questions/49925/what-is-the-difference-between-union-and-union-all
[^3]: https://www.postgresql.org/docs/current/transaction-iso.html
[^4]: https://dba.stackexchange.com/questions/210485/can-sub-selects-change-in-one-single-query-in-a-read-committed-transaction
[^5]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_billing_dao.py#L471
[^6]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_notification_status_dao.py#L58
[^7]: https://stackoverflow.com/questions/28845524/echo-3-proc-sys-vm-drop-caches-on-mac-osx
2022-05-19 11:28:44 +01:00
|
|
|
|
|
2023-03-02 20:20:31 -05:00
|
|
|
|
# Tell alembic not to create this as a table. We have a migration where we manually set this up as a view.
|
|
|
|
|
|
# This is custom logic we apply - not built-in logic. See `migrations/env.py`
|
|
|
|
|
|
__table_args__ = {"info": {"managed_by_alembic": False}}
|
|
|
|
|
|
|
Use notification view for status / billing tasks
This fixes a bug where (letter) notifications left in sending would
temporarily get excluded from billing and status calculations once
the service retention period had elapsed, and then get included once
again when they finally get marked as delivered.*
Status and billing tasks shouldn't need to have knowledge about which
table their data is in and getting this wrong is the fundamental cause
of the bug here. Adding a view across both tables abstracts this away
while keeping the query complexity the same.
Using a view also has the added benefit that we no longer need to care
when the status / billing tasks run in comparison to the deletion task,
since we will retrieve the same data irrespective (see below for a more
detailed discussion on data integrity).
*Such a scenario is rare but has happened.
A New View
==========
I've included all the columns that are shared between the two tables,
even though only a subset are actually needed. Having extra columns
has no impact and may be useful in future.
Although the view isn't actually a table, SQLAlchemy appears to wrap
it without any issues, noting that the package doesn't have any direct
support for "view models". Because we're never inserting data, we don't
need most of the kwargs when defining columns.*
*Note that the "default" kwarg doesn't affect data that's retrieved,
only data that's written (if no value is set).
Data Integrity
==============
The (new) tests cover the main scenarios.
We need to be careful with how the view interacts with the deletion /
archiving task. There are two concerns here:
- Duplicates. The deletion task inserts before it deletes [^1], so we
could end up double counting. It turns out this isn't a problem because
a Postgres UNION is an implicit "DISTINCT" [^2]. I've also verified this
manually, just to be on the safe side.
- No data. It's conceivable that the query will check the history table
just before the insertion, then check the notifications table just after
the deletion. It turns out this isn't a problem either because the whole
query sees the same DB snapshot [^3][^4].*
*I can't think of a way to test this as it's a race condition, but I'm
confident the Postgres docs are accurate.
Performance
===========
I copied the relevant (non-PII) columns from Production for data going
back to 2022-04-01. I then ran several tests.
Queries using the new view still make use of indices on a per-table basis,
as the following query plan illustrates:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=1130820.02..1135353.89 rows=46502 width=97) (actual time=629.863..756.703 rows=72 loops=1)
Group Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
-> Sort (cost=1130820.02..1131401.28 rows=232506 width=85) (actual time=629.756..708.914 rows=217563 loops=1)
Sort Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
Sort Method: external merge Disk: 9320kB
-> Subquery Scan on notifications_all_time_view (cost=1088506.43..1098969.20 rows=232506 width=85) (actual time=416.118..541.669 rows=217563 loops=1)
-> Unique (cost=1088506.43..1096644.14 rows=232506 width=725) (actual time=416.115..513.065 rows=217563 loops=1)
-> Sort (cost=1088506.43..1089087.70 rows=232506 width=725) (actual time=416.115..451.190 rows=217563 loops=1)
Sort Key: notifications_no_pii.id, notifications_no_pii.job_id, notifications_no_pii.service_id, notifications_no_pii.template_id, notifications_no_pii.key_type, notifications_no_pii.billable_units, notifications_no_pii.notification_type, notifications_no_pii.created_at, notifications_no_pii.sent_by, notifications_no_pii.notification_status, notifications_no_pii.international, notifications_no_pii.rate_multiplier, notifications_no_pii.postage
Sort Method: external merge Disk: 23936kB
-> Append (cost=114.42..918374.12 rows=232506 width=725) (actual time=2.051..298.229 rows=217563 loops=1)
-> Bitmap Heap Scan on notifications_no_pii (cost=114.42..8557.55 rows=2042 width=113) (actual time=1.405..1.442 rows=0 loops=1)
Recheck Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: ((key_type)::text = ANY ('{normal,team}'::text[]))
-> Bitmap Index Scan on ix_notifications_no_piiservice_id_composite (cost=0.00..113.91 rows=2202 width=0) (actual time=1.402..1.439 rows=0 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
-> Index Scan using ix_notifications_history_no_pii_service_id_composite on notifications_history_no_pii (cost=0.70..906328.97 rows=230464 width=113) (actual time=0.645..281.612 rows=217563 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND ((key_type)::text = ANY ('{normal,team}'::text[])) AND (notification_type = 'sms'::notification_type) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[]))
Planning Time: 18.032 ms
Execution Time: 759.001 ms
(21 rows)
Queries using the new view appear to be slower than without, but the
differences I've seen are minimal: the original queries execute in
seconds locally and in Production, so it's not a big issue.
Notes: Performance
==================
I downloaded a minimal set of columns for testing:
\copy (
select
id, notification_type, key_type, created_at, service_id,
template_id, sent_by, rate_multiplier, international,
billable_units, postage, job_id, notification_status
from notifications
) to 'notifications.csv' delimiter ',' csv header;
CREATE TABLE notifications_no_pii (
id uuid NOT NULL,
notification_type public.notification_type NOT NULL,
key_type character varying(255) NOT NULL,
created_at timestamp without time zone NOT NULL,
service_id uuid,
template_id uuid,
sent_by character varying,
rate_multiplier numeric,
international boolean,
billable_units integer NOT NULL,
postage character varying,
job_id uuid,
notification_status text
);
copy notifications_no_pii from '/Users/ben.thorner/Desktop/notifications.csv' delimiter ',' csv header;
CREATE INDEX ix_notifications_no_piicreated_at ON notifications_no_pii USING btree (created_at);
CREATE INDEX ix_notifications_no_piijob_id ON notifications_no_pii USING btree (job_id);
CREATE INDEX ix_notifications_no_piinotification_type_composite ON notifications_no_pii USING btree (notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piiservice_created_at ON notifications_no_pii USING btree (service_id, created_at);
CREATE INDEX ix_notifications_no_piiservice_id_composite ON notifications_no_pii USING btree (service_id, notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piitemplate_id ON notifications_no_pii USING btree (template_id);
And similarly for the history table. I then created a sepatate view
across both of these temporary tables using just these columns.
To test performance I created some queries that reflect what is run
by the billing [^5] and status [^6] tasks e.g.
explain analyze select template_id, sent_by, rate_multiplier, international, sum(billable_units), count(*)
from notifications_all_time_view
where
notification_status in ('sending', 'sent', 'delivered', 'pending', 'temporary-failure', 'permanent-failure')
and key_type in ('normal', 'team')
and created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
group by 1,2,3,4;
explain analyze select template_id, job_id, key_type, notification_status, count(*)
from notifications_all_time_view
where created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
and key_type in ('normal', 'team')
group by 1,2,3,4;
Between running queries I restarted my local database and also ran
a command to purge disk caches [^7].
I tested on a few services:
- c5956607-20b1-48b4-8983-85d11404e61f on 2022-05-02 (high volume)
- 0cc696c6-b792-409d-99e9-64232f461b0f on 2022-04-06 (highest volume)
- 01135db6-7819-4121-8b97-4aa2d741e372 on 2022-04-14 (very low volume)
All execution results are of the same magnitude using the view compared
to the worst case of either table on its own.
[^1]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/notifications_dao.py#L389
[^2]: https://stackoverflow.com/questions/49925/what-is-the-difference-between-union-and-union-all
[^3]: https://www.postgresql.org/docs/current/transaction-iso.html
[^4]: https://dba.stackexchange.com/questions/210485/can-sub-selects-change-in-one-single-query-in-a-read-committed-transaction
[^5]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_billing_dao.py#L471
[^6]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_notification_status_dao.py#L58
[^7]: https://stackoverflow.com/questions/28845524/echo-3-proc-sys-vm-drop-caches-on-mac-osx
2022-05-19 11:28:44 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True)
|
|
|
|
|
|
job_id = db.Column(UUID(as_uuid=True))
|
|
|
|
|
|
job_row_number = db.Column(db.Integer)
|
|
|
|
|
|
service_id = db.Column(UUID(as_uuid=True))
|
|
|
|
|
|
template_id = db.Column(UUID(as_uuid=True))
|
|
|
|
|
|
template_version = db.Column(db.Integer)
|
|
|
|
|
|
api_key_id = db.Column(UUID(as_uuid=True))
|
|
|
|
|
|
key_type = db.Column(db.String)
|
|
|
|
|
|
billable_units = db.Column(db.Integer)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType)
|
Use notification view for status / billing tasks
This fixes a bug where (letter) notifications left in sending would
temporarily get excluded from billing and status calculations once
the service retention period had elapsed, and then get included once
again when they finally get marked as delivered.*
Status and billing tasks shouldn't need to have knowledge about which
table their data is in and getting this wrong is the fundamental cause
of the bug here. Adding a view across both tables abstracts this away
while keeping the query complexity the same.
Using a view also has the added benefit that we no longer need to care
when the status / billing tasks run in comparison to the deletion task,
since we will retrieve the same data irrespective (see below for a more
detailed discussion on data integrity).
*Such a scenario is rare but has happened.
A New View
==========
I've included all the columns that are shared between the two tables,
even though only a subset are actually needed. Having extra columns
has no impact and may be useful in future.
Although the view isn't actually a table, SQLAlchemy appears to wrap
it without any issues, noting that the package doesn't have any direct
support for "view models". Because we're never inserting data, we don't
need most of the kwargs when defining columns.*
*Note that the "default" kwarg doesn't affect data that's retrieved,
only data that's written (if no value is set).
Data Integrity
==============
The (new) tests cover the main scenarios.
We need to be careful with how the view interacts with the deletion /
archiving task. There are two concerns here:
- Duplicates. The deletion task inserts before it deletes [^1], so we
could end up double counting. It turns out this isn't a problem because
a Postgres UNION is an implicit "DISTINCT" [^2]. I've also verified this
manually, just to be on the safe side.
- No data. It's conceivable that the query will check the history table
just before the insertion, then check the notifications table just after
the deletion. It turns out this isn't a problem either because the whole
query sees the same DB snapshot [^3][^4].*
*I can't think of a way to test this as it's a race condition, but I'm
confident the Postgres docs are accurate.
Performance
===========
I copied the relevant (non-PII) columns from Production for data going
back to 2022-04-01. I then ran several tests.
Queries using the new view still make use of indices on a per-table basis,
as the following query plan illustrates:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=1130820.02..1135353.89 rows=46502 width=97) (actual time=629.863..756.703 rows=72 loops=1)
Group Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
-> Sort (cost=1130820.02..1131401.28 rows=232506 width=85) (actual time=629.756..708.914 rows=217563 loops=1)
Sort Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
Sort Method: external merge Disk: 9320kB
-> Subquery Scan on notifications_all_time_view (cost=1088506.43..1098969.20 rows=232506 width=85) (actual time=416.118..541.669 rows=217563 loops=1)
-> Unique (cost=1088506.43..1096644.14 rows=232506 width=725) (actual time=416.115..513.065 rows=217563 loops=1)
-> Sort (cost=1088506.43..1089087.70 rows=232506 width=725) (actual time=416.115..451.190 rows=217563 loops=1)
Sort Key: notifications_no_pii.id, notifications_no_pii.job_id, notifications_no_pii.service_id, notifications_no_pii.template_id, notifications_no_pii.key_type, notifications_no_pii.billable_units, notifications_no_pii.notification_type, notifications_no_pii.created_at, notifications_no_pii.sent_by, notifications_no_pii.notification_status, notifications_no_pii.international, notifications_no_pii.rate_multiplier, notifications_no_pii.postage
Sort Method: external merge Disk: 23936kB
-> Append (cost=114.42..918374.12 rows=232506 width=725) (actual time=2.051..298.229 rows=217563 loops=1)
-> Bitmap Heap Scan on notifications_no_pii (cost=114.42..8557.55 rows=2042 width=113) (actual time=1.405..1.442 rows=0 loops=1)
Recheck Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: ((key_type)::text = ANY ('{normal,team}'::text[]))
-> Bitmap Index Scan on ix_notifications_no_piiservice_id_composite (cost=0.00..113.91 rows=2202 width=0) (actual time=1.402..1.439 rows=0 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
-> Index Scan using ix_notifications_history_no_pii_service_id_composite on notifications_history_no_pii (cost=0.70..906328.97 rows=230464 width=113) (actual time=0.645..281.612 rows=217563 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND ((key_type)::text = ANY ('{normal,team}'::text[])) AND (notification_type = 'sms'::notification_type) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[]))
Planning Time: 18.032 ms
Execution Time: 759.001 ms
(21 rows)
Queries using the new view appear to be slower than without, but the
differences I've seen are minimal: the original queries execute in
seconds locally and in Production, so it's not a big issue.
Notes: Performance
==================
I downloaded a minimal set of columns for testing:
\copy (
select
id, notification_type, key_type, created_at, service_id,
template_id, sent_by, rate_multiplier, international,
billable_units, postage, job_id, notification_status
from notifications
) to 'notifications.csv' delimiter ',' csv header;
CREATE TABLE notifications_no_pii (
id uuid NOT NULL,
notification_type public.notification_type NOT NULL,
key_type character varying(255) NOT NULL,
created_at timestamp without time zone NOT NULL,
service_id uuid,
template_id uuid,
sent_by character varying,
rate_multiplier numeric,
international boolean,
billable_units integer NOT NULL,
postage character varying,
job_id uuid,
notification_status text
);
copy notifications_no_pii from '/Users/ben.thorner/Desktop/notifications.csv' delimiter ',' csv header;
CREATE INDEX ix_notifications_no_piicreated_at ON notifications_no_pii USING btree (created_at);
CREATE INDEX ix_notifications_no_piijob_id ON notifications_no_pii USING btree (job_id);
CREATE INDEX ix_notifications_no_piinotification_type_composite ON notifications_no_pii USING btree (notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piiservice_created_at ON notifications_no_pii USING btree (service_id, created_at);
CREATE INDEX ix_notifications_no_piiservice_id_composite ON notifications_no_pii USING btree (service_id, notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piitemplate_id ON notifications_no_pii USING btree (template_id);
And similarly for the history table. I then created a sepatate view
across both of these temporary tables using just these columns.
To test performance I created some queries that reflect what is run
by the billing [^5] and status [^6] tasks e.g.
explain analyze select template_id, sent_by, rate_multiplier, international, sum(billable_units), count(*)
from notifications_all_time_view
where
notification_status in ('sending', 'sent', 'delivered', 'pending', 'temporary-failure', 'permanent-failure')
and key_type in ('normal', 'team')
and created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
group by 1,2,3,4;
explain analyze select template_id, job_id, key_type, notification_status, count(*)
from notifications_all_time_view
where created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
and key_type in ('normal', 'team')
group by 1,2,3,4;
Between running queries I restarted my local database and also ran
a command to purge disk caches [^7].
I tested on a few services:
- c5956607-20b1-48b4-8983-85d11404e61f on 2022-05-02 (high volume)
- 0cc696c6-b792-409d-99e9-64232f461b0f on 2022-04-06 (highest volume)
- 01135db6-7819-4121-8b97-4aa2d741e372 on 2022-04-14 (very low volume)
All execution results are of the same magnitude using the view compared
to the worst case of either table on its own.
[^1]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/notifications_dao.py#L389
[^2]: https://stackoverflow.com/questions/49925/what-is-the-difference-between-union-and-union-all
[^3]: https://www.postgresql.org/docs/current/transaction-iso.html
[^4]: https://dba.stackexchange.com/questions/210485/can-sub-selects-change-in-one-single-query-in-a-read-committed-transaction
[^5]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_billing_dao.py#L471
[^6]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_notification_status_dao.py#L58
[^7]: https://stackoverflow.com/questions/28845524/echo-3-proc-sys-vm-drop-caches-on-mac-osx
2022-05-19 11:28:44 +01:00
|
|
|
|
created_at = db.Column(db.DateTime)
|
|
|
|
|
|
sent_at = db.Column(db.DateTime)
|
|
|
|
|
|
sent_by = db.Column(db.String)
|
|
|
|
|
|
updated_at = db.Column(db.DateTime)
|
2024-01-25 14:25:00 -05:00
|
|
|
|
status = enum_column(
|
|
|
|
|
|
NotificationStatus,
|
|
|
|
|
|
name="notification_status",
|
|
|
|
|
|
nullable=True,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=NotificationStatus.CREATED,
|
2024-01-25 14:25:00 -05:00
|
|
|
|
key="status",
|
|
|
|
|
|
)
|
Use notification view for status / billing tasks
This fixes a bug where (letter) notifications left in sending would
temporarily get excluded from billing and status calculations once
the service retention period had elapsed, and then get included once
again when they finally get marked as delivered.*
Status and billing tasks shouldn't need to have knowledge about which
table their data is in and getting this wrong is the fundamental cause
of the bug here. Adding a view across both tables abstracts this away
while keeping the query complexity the same.
Using a view also has the added benefit that we no longer need to care
when the status / billing tasks run in comparison to the deletion task,
since we will retrieve the same data irrespective (see below for a more
detailed discussion on data integrity).
*Such a scenario is rare but has happened.
A New View
==========
I've included all the columns that are shared between the two tables,
even though only a subset are actually needed. Having extra columns
has no impact and may be useful in future.
Although the view isn't actually a table, SQLAlchemy appears to wrap
it without any issues, noting that the package doesn't have any direct
support for "view models". Because we're never inserting data, we don't
need most of the kwargs when defining columns.*
*Note that the "default" kwarg doesn't affect data that's retrieved,
only data that's written (if no value is set).
Data Integrity
==============
The (new) tests cover the main scenarios.
We need to be careful with how the view interacts with the deletion /
archiving task. There are two concerns here:
- Duplicates. The deletion task inserts before it deletes [^1], so we
could end up double counting. It turns out this isn't a problem because
a Postgres UNION is an implicit "DISTINCT" [^2]. I've also verified this
manually, just to be on the safe side.
- No data. It's conceivable that the query will check the history table
just before the insertion, then check the notifications table just after
the deletion. It turns out this isn't a problem either because the whole
query sees the same DB snapshot [^3][^4].*
*I can't think of a way to test this as it's a race condition, but I'm
confident the Postgres docs are accurate.
Performance
===========
I copied the relevant (non-PII) columns from Production for data going
back to 2022-04-01. I then ran several tests.
Queries using the new view still make use of indices on a per-table basis,
as the following query plan illustrates:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=1130820.02..1135353.89 rows=46502 width=97) (actual time=629.863..756.703 rows=72 loops=1)
Group Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
-> Sort (cost=1130820.02..1131401.28 rows=232506 width=85) (actual time=629.756..708.914 rows=217563 loops=1)
Sort Key: notifications_all_time_view.template_id, notifications_all_time_view.sent_by, notifications_all_time_view.rate_multiplier, notifications_all_time_view.international
Sort Method: external merge Disk: 9320kB
-> Subquery Scan on notifications_all_time_view (cost=1088506.43..1098969.20 rows=232506 width=85) (actual time=416.118..541.669 rows=217563 loops=1)
-> Unique (cost=1088506.43..1096644.14 rows=232506 width=725) (actual time=416.115..513.065 rows=217563 loops=1)
-> Sort (cost=1088506.43..1089087.70 rows=232506 width=725) (actual time=416.115..451.190 rows=217563 loops=1)
Sort Key: notifications_no_pii.id, notifications_no_pii.job_id, notifications_no_pii.service_id, notifications_no_pii.template_id, notifications_no_pii.key_type, notifications_no_pii.billable_units, notifications_no_pii.notification_type, notifications_no_pii.created_at, notifications_no_pii.sent_by, notifications_no_pii.notification_status, notifications_no_pii.international, notifications_no_pii.rate_multiplier, notifications_no_pii.postage
Sort Method: external merge Disk: 23936kB
-> Append (cost=114.42..918374.12 rows=232506 width=725) (actual time=2.051..298.229 rows=217563 loops=1)
-> Bitmap Heap Scan on notifications_no_pii (cost=114.42..8557.55 rows=2042 width=113) (actual time=1.405..1.442 rows=0 loops=1)
Recheck Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: ((key_type)::text = ANY ('{normal,team}'::text[]))
-> Bitmap Index Scan on ix_notifications_no_piiservice_id_composite (cost=0.00..113.91 rows=2202 width=0) (actual time=1.402..1.439 rows=0 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND (notification_type = 'sms'::notification_type) AND (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[])) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
-> Index Scan using ix_notifications_history_no_pii_service_id_composite on notifications_history_no_pii (cost=0.70..906328.97 rows=230464 width=113) (actual time=0.645..281.612 rows=217563 loops=1)
Index Cond: ((service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'::uuid) AND ((key_type)::text = ANY ('{normal,team}'::text[])) AND (notification_type = 'sms'::notification_type) AND (created_at >= '2022-05-01 23:00:00'::timestamp without time zone) AND (created_at < '2022-05-02 23:00:00'::timestamp without time zone))
Filter: (notification_status = ANY ('{sending,sent,delivered,pending,temporary-failure,permanent-failure}'::text[]))
Planning Time: 18.032 ms
Execution Time: 759.001 ms
(21 rows)
Queries using the new view appear to be slower than without, but the
differences I've seen are minimal: the original queries execute in
seconds locally and in Production, so it's not a big issue.
Notes: Performance
==================
I downloaded a minimal set of columns for testing:
\copy (
select
id, notification_type, key_type, created_at, service_id,
template_id, sent_by, rate_multiplier, international,
billable_units, postage, job_id, notification_status
from notifications
) to 'notifications.csv' delimiter ',' csv header;
CREATE TABLE notifications_no_pii (
id uuid NOT NULL,
notification_type public.notification_type NOT NULL,
key_type character varying(255) NOT NULL,
created_at timestamp without time zone NOT NULL,
service_id uuid,
template_id uuid,
sent_by character varying,
rate_multiplier numeric,
international boolean,
billable_units integer NOT NULL,
postage character varying,
job_id uuid,
notification_status text
);
copy notifications_no_pii from '/Users/ben.thorner/Desktop/notifications.csv' delimiter ',' csv header;
CREATE INDEX ix_notifications_no_piicreated_at ON notifications_no_pii USING btree (created_at);
CREATE INDEX ix_notifications_no_piijob_id ON notifications_no_pii USING btree (job_id);
CREATE INDEX ix_notifications_no_piinotification_type_composite ON notifications_no_pii USING btree (notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piiservice_created_at ON notifications_no_pii USING btree (service_id, created_at);
CREATE INDEX ix_notifications_no_piiservice_id_composite ON notifications_no_pii USING btree (service_id, notification_type, notification_status, created_at);
CREATE INDEX ix_notifications_no_piitemplate_id ON notifications_no_pii USING btree (template_id);
And similarly for the history table. I then created a sepatate view
across both of these temporary tables using just these columns.
To test performance I created some queries that reflect what is run
by the billing [^5] and status [^6] tasks e.g.
explain analyze select template_id, sent_by, rate_multiplier, international, sum(billable_units), count(*)
from notifications_all_time_view
where
notification_status in ('sending', 'sent', 'delivered', 'pending', 'temporary-failure', 'permanent-failure')
and key_type in ('normal', 'team')
and created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
group by 1,2,3,4;
explain analyze select template_id, job_id, key_type, notification_status, count(*)
from notifications_all_time_view
where created_at >= '2022-05-01 23:00'
and created_at < '2022-05-02 23:00'
and notification_type = 'sms'
and service_id = 'c5956607-20b1-48b4-8983-85d11404e61f'
and key_type in ('normal', 'team')
group by 1,2,3,4;
Between running queries I restarted my local database and also ran
a command to purge disk caches [^7].
I tested on a few services:
- c5956607-20b1-48b4-8983-85d11404e61f on 2022-05-02 (high volume)
- 0cc696c6-b792-409d-99e9-64232f461b0f on 2022-04-06 (highest volume)
- 01135db6-7819-4121-8b97-4aa2d741e372 on 2022-04-14 (very low volume)
All execution results are of the same magnitude using the view compared
to the worst case of either table on its own.
[^1]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/notifications_dao.py#L389
[^2]: https://stackoverflow.com/questions/49925/what-is-the-difference-between-union-and-union-all
[^3]: https://www.postgresql.org/docs/current/transaction-iso.html
[^4]: https://dba.stackexchange.com/questions/210485/can-sub-selects-change-in-one-single-query-in-a-read-committed-transaction
[^5]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_billing_dao.py#L471
[^6]: https://github.com/alphagov/notifications-api/blob/00a04ebf54c97fc695f013de0a497e5490ddb558/app/dao/fact_notification_status_dao.py#L58
[^7]: https://stackoverflow.com/questions/28845524/echo-3-proc-sys-vm-drop-caches-on-mac-osx
2022-05-19 11:28:44 +01:00
|
|
|
|
reference = db.Column(db.String)
|
|
|
|
|
|
client_reference = db.Column(db.String)
|
|
|
|
|
|
international = db.Column(db.Boolean)
|
|
|
|
|
|
phone_prefix = db.Column(db.String)
|
|
|
|
|
|
rate_multiplier = db.Column(db.Numeric(asdecimal=False))
|
|
|
|
|
|
created_by_id = db.Column(UUID(as_uuid=True))
|
|
|
|
|
|
document_download_count = db.Column(db.Integer)
|
|
|
|
|
|
|
|
|
|
|
|
|
2016-02-09 12:01:17 +00:00
|
|
|
|
class Notification(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "notifications"
|
2016-02-09 12:01:17 +00:00
|
|
|
|
|
2016-02-09 18:28:10 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2016-02-09 12:01:17 +00:00
|
|
|
|
to = db.Column(db.String, nullable=False)
|
2017-05-23 10:43:48 +01:00
|
|
|
|
normalised_to = db.Column(db.String, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
job_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("jobs.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
job = db.relationship("Job", backref=db.backref("notifications", lazy="dynamic"))
|
2016-05-19 10:46:03 +01:00
|
|
|
|
job_row_number = db.Column(db.Integer, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service")
|
2017-11-08 10:32:45 +00:00
|
|
|
|
template_id = db.Column(UUID(as_uuid=True), index=True, unique=False)
|
2016-05-11 17:04:51 +01:00
|
|
|
|
template_version = db.Column(db.Integer, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
template = db.relationship("TemplateHistory")
|
|
|
|
|
|
api_key_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("api_keys.id"),
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
api_key = db.relationship("ApiKey")
|
2024-01-18 11:31:02 -05:00
|
|
|
|
key_type = enum_column(KeyType, unique=False, nullable=False)
|
2016-08-03 14:27:58 +01:00
|
|
|
|
billable_units = db.Column(db.Integer, nullable=False, default=0)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(db.DateTime, index=True, unique=False, nullable=False)
|
|
|
|
|
|
sent_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
|
2016-02-25 09:59:50 +00:00
|
|
|
|
sent_by = db.Column(db.String, nullable=True)
|
2025-02-28 08:39:13 -08:00
|
|
|
|
message_cost = db.Column(db.Float, nullable=True, default=0.0)
|
2016-02-09 12:01:17 +00:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
status = enum_column(
|
|
|
|
|
|
NotificationStatus,
|
2024-01-18 16:58:06 -05:00
|
|
|
|
name="notification_status",
|
2017-05-04 17:09:04 +01:00
|
|
|
|
nullable=True,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=NotificationStatus.CREATED,
|
2024-01-25 09:55:10 -05:00
|
|
|
|
key="status",
|
2017-05-04 17:09:04 +01:00
|
|
|
|
)
|
2016-03-11 09:40:35 +00:00
|
|
|
|
reference = db.Column(db.String, nullable=True, index=True)
|
2016-11-17 13:42:34 +00:00
|
|
|
|
client_reference = db.Column(db.String, index=True, nullable=True)
|
2016-06-20 16:23:56 +01:00
|
|
|
|
_personalisation = db.Column(db.String, nullable=True)
|
|
|
|
|
|
|
2017-04-26 10:22:20 +01:00
|
|
|
|
international = db.Column(db.Boolean, nullable=False, default=False)
|
|
|
|
|
|
phone_prefix = db.Column(db.String, nullable=True)
|
2021-06-14 14:43:34 +01:00
|
|
|
|
rate_multiplier = db.Column(db.Numeric(asdecimal=False), nullable=True)
|
2017-04-26 10:22:20 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_by = db.relationship("User")
|
|
|
|
|
|
created_by_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True), db.ForeignKey("users.id"), nullable=True
|
|
|
|
|
|
)
|
2017-06-13 15:33:33 +01:00
|
|
|
|
|
2017-11-27 13:39:35 +00:00
|
|
|
|
reply_to_text = db.Column(db.String, nullable=True)
|
|
|
|
|
|
|
2020-02-12 14:38:09 +00:00
|
|
|
|
document_download_count = db.Column(db.Integer, nullable=True)
|
|
|
|
|
|
|
2022-09-15 14:59:13 -07:00
|
|
|
|
provider_response = db.Column(db.Text, nullable=True)
|
2023-10-24 11:35:52 -07:00
|
|
|
|
carrier = db.Column(db.Text, nullable=True)
|
2024-12-13 11:54:46 -08:00
|
|
|
|
message_id = db.Column(db.Text, nullable=True)
|
2023-10-24 11:35:52 -07:00
|
|
|
|
|
2022-09-15 14:59:13 -07:00
|
|
|
|
# queue_name = db.Column(db.Text, nullable=True)
|
2018-09-19 10:49:11 +01:00
|
|
|
|
|
2017-11-08 10:32:45 +00:00
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
db.ForeignKeyConstraint(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
["template_id", "template_version"],
|
|
|
|
|
|
["templates_history.id", "templates_history.version"],
|
|
|
|
|
|
),
|
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
|
"job_id", "job_row_number", name="uq_notifications_job_row_number"
|
2017-11-08 10:32:45 +00:00
|
|
|
|
),
|
2021-06-21 12:06:38 +01:00
|
|
|
|
Index(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"ix_notifications_notification_type_composite",
|
|
|
|
|
|
"notification_type",
|
|
|
|
|
|
"status",
|
|
|
|
|
|
"created_at",
|
2021-06-21 12:06:38 +01:00
|
|
|
|
),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
Index("ix_notifications_service_created_at", "service_id", "created_at"),
|
2021-06-21 12:06:38 +01:00
|
|
|
|
Index(
|
|
|
|
|
|
"ix_notifications_service_id_composite",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"service_id",
|
|
|
|
|
|
"notification_type",
|
|
|
|
|
|
"status",
|
|
|
|
|
|
"created_at",
|
|
|
|
|
|
),
|
2017-11-08 10:32:45 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2016-06-20 16:23:56 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def personalisation(self):
|
|
|
|
|
|
if self._personalisation:
|
2022-12-13 14:18:34 -05:00
|
|
|
|
try:
|
|
|
|
|
|
return encryption.decrypt(self._personalisation)
|
|
|
|
|
|
except EncryptionError:
|
2024-09-11 09:39:18 -07:00
|
|
|
|
current_app.logger.exception(
|
2024-08-15 10:31:02 -07:00
|
|
|
|
"Error decrypting notification.personalisation, returning empty dict",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-07-03 16:04:21 +01:00
|
|
|
|
return {}
|
2016-06-20 16:23:56 +01:00
|
|
|
|
|
|
|
|
|
|
@personalisation.setter
|
|
|
|
|
|
def personalisation(self, personalisation):
|
2017-07-03 16:04:21 +01:00
|
|
|
|
self._personalisation = encryption.encrypt(personalisation or {})
|
2016-02-23 16:21:47 +00:00
|
|
|
|
|
2016-11-18 17:36:11 +00:00
|
|
|
|
def completed_at(self):
|
2024-01-16 15:47:55 -05:00
|
|
|
|
if self.status in NotificationStatus.completed_types():
|
2016-11-18 17:36:11 +00:00
|
|
|
|
return self.updated_at.strftime(DATETIME_FORMAT)
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2016-11-25 14:55:29 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def substitute_status(status_or_statuses):
|
|
|
|
|
|
"""
|
|
|
|
|
|
static function that takes a status or list of statuses and substitutes our new failure types if it finds
|
|
|
|
|
|
the deprecated one
|
|
|
|
|
|
|
|
|
|
|
|
> IN
|
|
|
|
|
|
'failed'
|
|
|
|
|
|
|
|
|
|
|
|
< OUT
|
|
|
|
|
|
['technical-failure', 'temporary-failure', 'permanent-failure']
|
|
|
|
|
|
|
|
|
|
|
|
-
|
|
|
|
|
|
|
|
|
|
|
|
> IN
|
2017-09-20 15:21:05 +01:00
|
|
|
|
['failed', 'created', 'accepted']
|
2016-11-25 14:55:29 +00:00
|
|
|
|
|
|
|
|
|
|
< OUT
|
2017-09-20 15:21:05 +01:00
|
|
|
|
['technical-failure', 'temporary-failure', 'permanent-failure', 'created', 'sending']
|
2016-11-25 14:55:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
2017-10-23 15:57:00 +01:00
|
|
|
|
-
|
|
|
|
|
|
|
|
|
|
|
|
> IN
|
|
|
|
|
|
'delivered'
|
|
|
|
|
|
|
|
|
|
|
|
< OUT
|
|
|
|
|
|
['received']
|
|
|
|
|
|
|
2016-11-25 14:55:29 +00:00
|
|
|
|
:param status_or_statuses: a single status or list of statuses
|
|
|
|
|
|
:return: a single status or list with the current failure statuses substituted for 'failure'
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def _substitute_status_str(_status):
|
2017-09-20 15:21:05 +01:00
|
|
|
|
return (
|
2024-01-16 15:47:55 -05:00
|
|
|
|
NotificationStatus.failed_types()
|
2024-01-18 10:28:15 -05:00
|
|
|
|
if _status == NotificationStatus.FAILED
|
2023-08-29 14:54:30 -07:00
|
|
|
|
else [_status]
|
2017-09-20 15:21:05 +01:00
|
|
|
|
)
|
2016-11-25 14:55:29 +00:00
|
|
|
|
|
|
|
|
|
|
def _substitute_status_seq(_statuses):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return list(
|
|
|
|
|
|
set(
|
|
|
|
|
|
itertools.chain.from_iterable(
|
|
|
|
|
|
_substitute_status_str(status) for status in _statuses
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2016-11-25 14:55:29 +00:00
|
|
|
|
|
|
|
|
|
|
if isinstance(status_or_statuses, str):
|
|
|
|
|
|
return _substitute_status_str(status_or_statuses)
|
|
|
|
|
|
return _substitute_status_seq(status_or_statuses)
|
|
|
|
|
|
|
2016-12-15 16:19:55 +00:00
|
|
|
|
@property
|
|
|
|
|
|
def content(self):
|
2020-04-13 13:48:23 +01:00
|
|
|
|
return self.template._as_utils_template_with_personalisation(
|
|
|
|
|
|
self.personalisation
|
|
|
|
|
|
).content_with_placeholders_filled_in
|
2016-12-15 16:19:55 +00:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def subject(self):
|
2020-04-13 13:48:23 +01:00
|
|
|
|
template_object = self.template._as_utils_template_with_personalisation(
|
|
|
|
|
|
self.personalisation
|
|
|
|
|
|
)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return getattr(template_object, "subject", None)
|
2016-12-15 16:19:55 +00:00
|
|
|
|
|
2017-04-20 11:52:00 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def formatted_status(self):
|
|
|
|
|
|
return {
|
2024-02-20 17:05:34 -05:00
|
|
|
|
NotificationType.EMAIL: {
|
|
|
|
|
|
NotificationStatus.FAILED: "Failed",
|
|
|
|
|
|
NotificationStatus.TECHNICAL_FAILURE: "Technical failure",
|
|
|
|
|
|
NotificationStatus.TEMPORARY_FAILURE: "Inbox not accepting messages right now",
|
|
|
|
|
|
NotificationStatus.PERMANENT_FAILURE: "Email address doesn’t exist",
|
|
|
|
|
|
NotificationStatus.DELIVERED: "Delivered",
|
|
|
|
|
|
NotificationStatus.SENDING: "Sending",
|
|
|
|
|
|
NotificationStatus.CREATED: "Sending",
|
|
|
|
|
|
NotificationStatus.SENT: "Delivered",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
},
|
2024-02-20 17:05:34 -05:00
|
|
|
|
NotificationType.SMS: {
|
|
|
|
|
|
NotificationStatus.FAILED: "Failed",
|
|
|
|
|
|
NotificationStatus.TECHNICAL_FAILURE: "Technical failure",
|
|
|
|
|
|
NotificationStatus.TEMPORARY_FAILURE: "Unable to find carrier response -- still looking",
|
|
|
|
|
|
NotificationStatus.PERMANENT_FAILURE: "Unable to find carrier response.",
|
|
|
|
|
|
NotificationStatus.DELIVERED: "Delivered",
|
|
|
|
|
|
NotificationStatus.PENDING: "Pending",
|
|
|
|
|
|
NotificationStatus.SENDING: "Sending",
|
|
|
|
|
|
NotificationStatus.CREATED: "Sending",
|
|
|
|
|
|
NotificationStatus.SENT: "Sent internationally",
|
2017-04-20 11:52:00 +01:00
|
|
|
|
},
|
|
|
|
|
|
}[self.template.template_type].get(self.status, self.status)
|
|
|
|
|
|
|
2018-07-16 13:12:17 +01:00
|
|
|
|
def get_created_by_name(self):
|
|
|
|
|
|
if self.created_by:
|
|
|
|
|
|
return self.created_by.name
|
|
|
|
|
|
else:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2018-12-06 11:53:54 +00:00
|
|
|
|
def get_created_by_email_address(self):
|
|
|
|
|
|
if self.created_by:
|
|
|
|
|
|
return self.created_by.email_address
|
|
|
|
|
|
else:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2025-01-10 11:21:39 -08:00
|
|
|
|
def serialize_for_redis(self, obj):
|
|
|
|
|
|
if isinstance(obj.__class__, DeclarativeMeta):
|
|
|
|
|
|
fields = {}
|
|
|
|
|
|
for column in obj.__table__.columns:
|
|
|
|
|
|
if column.name == "notification_status":
|
|
|
|
|
|
new_name = "status"
|
|
|
|
|
|
value = getattr(obj, new_name)
|
|
|
|
|
|
elif column.name == "created_at":
|
2025-01-13 11:10:03 -08:00
|
|
|
|
if isinstance(obj.created_at, str):
|
|
|
|
|
|
value = obj.created_at
|
|
|
|
|
|
else:
|
|
|
|
|
|
value = (obj.created_at.strftime("%Y-%m-%d %H:%M:%S"),)
|
2025-01-10 11:21:39 -08:00
|
|
|
|
elif column.name in ["sent_at", "completed_at"]:
|
|
|
|
|
|
value = None
|
|
|
|
|
|
elif column.name.endswith("_id"):
|
|
|
|
|
|
value = getattr(obj, column.name)
|
|
|
|
|
|
value = str(value)
|
|
|
|
|
|
else:
|
|
|
|
|
|
value = getattr(obj, column.name)
|
|
|
|
|
|
if column.name in ["message_id", "api_key_id"]:
|
|
|
|
|
|
pass # do nothing because we don't have the message id yet
|
|
|
|
|
|
else:
|
|
|
|
|
|
fields[column.name] = value
|
2025-01-10 13:51:35 -08:00
|
|
|
|
|
2025-01-10 11:21:39 -08:00
|
|
|
|
return fields
|
|
|
|
|
|
raise ValueError("Provided object is not a SQLAlchemy instance")
|
|
|
|
|
|
|
2017-04-20 11:52:00 +01:00
|
|
|
|
def serialize_for_csv(self):
|
|
|
|
|
|
serialized = {
|
2024-04-01 15:12:33 -07:00
|
|
|
|
"row_number": (
|
|
|
|
|
|
"" if self.job_row_number is None else self.job_row_number + 1
|
|
|
|
|
|
),
|
2017-04-20 11:52:00 +01:00
|
|
|
|
"recipient": self.to,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"client_reference": self.client_reference or "",
|
2017-04-20 11:52:00 +01:00
|
|
|
|
"template_name": self.template.name,
|
|
|
|
|
|
"template_type": self.template.template_type,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"job_name": self.job.original_file_name if self.job else "",
|
2023-11-03 10:27:45 -07:00
|
|
|
|
"carrier": self.carrier,
|
|
|
|
|
|
"provider_response": self.provider_response,
|
2017-04-20 11:52:00 +01:00
|
|
|
|
"status": self.formatted_status,
|
2023-05-10 08:39:50 -07:00
|
|
|
|
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
2018-09-07 10:22:45 +01:00
|
|
|
|
"created_by_name": self.get_created_by_name(),
|
2018-12-06 11:53:54 +00:00
|
|
|
|
"created_by_email_address": self.get_created_by_email_address(),
|
2017-04-20 11:52:00 +01:00
|
|
|
|
}
|
2016-11-18 17:36:11 +00:00
|
|
|
|
|
2017-04-20 11:52:00 +01:00
|
|
|
|
return serialized
|
|
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
2016-11-18 17:36:11 +00:00
|
|
|
|
template_dict = {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"version": self.template.version,
|
|
|
|
|
|
"id": self.template.id,
|
|
|
|
|
|
"uri": self.template.get_link(),
|
2016-11-18 17:36:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
serialized = {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"reference": self.client_reference,
|
2024-04-01 15:12:33 -07:00
|
|
|
|
"email_address": (
|
|
|
|
|
|
self.to if self.notification_type == NotificationType.EMAIL else None
|
|
|
|
|
|
),
|
|
|
|
|
|
"phone_number": (
|
|
|
|
|
|
self.to if self.notification_type == NotificationType.SMS else None
|
|
|
|
|
|
),
|
2016-11-18 17:36:11 +00:00
|
|
|
|
"line_1": None,
|
|
|
|
|
|
"line_2": None,
|
|
|
|
|
|
"line_3": None,
|
|
|
|
|
|
"line_4": None,
|
|
|
|
|
|
"line_5": None,
|
|
|
|
|
|
"line_6": None,
|
|
|
|
|
|
"postcode": None,
|
|
|
|
|
|
"type": self.notification_type,
|
2023-03-02 20:20:31 -05:00
|
|
|
|
"status": self.status,
|
2022-09-15 14:59:13 -07:00
|
|
|
|
"provider_response": self.provider_response,
|
2023-10-24 11:35:52 -07:00
|
|
|
|
"carrier": self.carrier,
|
2016-11-18 17:36:11 +00:00
|
|
|
|
"template": template_dict,
|
2016-12-15 16:19:55 +00:00
|
|
|
|
"body": self.content,
|
|
|
|
|
|
"subject": self.subject,
|
2016-11-18 17:36:11 +00:00
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2018-07-16 13:12:17 +01:00
|
|
|
|
"created_by_name": self.get_created_by_name(),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"sent_at": get_dt_string_or_none(self.sent_at),
|
2017-05-15 17:27:38 +01:00
|
|
|
|
"completed_at": self.completed_at(),
|
2020-06-24 07:34:58 +01:00
|
|
|
|
"scheduled_for": None,
|
2016-11-18 17:36:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return serialized
|
|
|
|
|
|
|
2016-02-23 16:21:47 +00:00
|
|
|
|
|
2016-12-15 17:11:47 +00:00
|
|
|
|
class NotificationHistory(db.Model, HistoryModel):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "notification_history"
|
2016-07-07 16:30:22 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
job_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("jobs.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
job = db.relationship("Job")
|
2016-07-07 16:30:22 +01:00
|
|
|
|
job_row_number = db.Column(db.Integer, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service")
|
2021-06-14 14:43:34 +01:00
|
|
|
|
template_id = db.Column(UUID(as_uuid=True), unique=False)
|
2016-07-07 16:30:22 +01:00
|
|
|
|
template_version = db.Column(db.Integer, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
api_key_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("api_keys.id"),
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
api_key = db.relationship("ApiKey")
|
2024-01-18 11:31:02 -05:00
|
|
|
|
key_type = enum_column(KeyType, unique=False, nullable=False)
|
2016-08-03 14:27:58 +01:00
|
|
|
|
billable_units = db.Column(db.Integer, nullable=False, default=0)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, nullable=False)
|
2021-06-14 14:43:34 +01:00
|
|
|
|
created_at = db.Column(db.DateTime, unique=False, nullable=False)
|
2016-07-07 16:30:22 +01:00
|
|
|
|
sent_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
|
|
|
|
|
|
sent_by = db.Column(db.String, nullable=True)
|
2025-02-28 08:39:13 -08:00
|
|
|
|
message_cost = db.Column(db.Float, nullable=True, default=0.0)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
updated_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
status = enum_column(
|
|
|
|
|
|
NotificationStatus,
|
2024-01-18 16:58:06 -05:00
|
|
|
|
name="notification_status",
|
2017-05-04 17:09:04 +01:00
|
|
|
|
nullable=True,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=NotificationStatus.CREATED,
|
2024-01-25 09:55:10 -05:00
|
|
|
|
key="status",
|
2017-05-04 17:09:04 +01:00
|
|
|
|
)
|
2016-07-07 16:30:22 +01:00
|
|
|
|
reference = db.Column(db.String, nullable=True, index=True)
|
2016-11-17 13:42:34 +00:00
|
|
|
|
client_reference = db.Column(db.String, nullable=True)
|
2016-07-07 16:30:22 +01:00
|
|
|
|
|
2021-06-14 14:43:34 +01:00
|
|
|
|
international = db.Column(db.Boolean, nullable=True, default=False)
|
2017-04-26 10:22:20 +01:00
|
|
|
|
phone_prefix = db.Column(db.String, nullable=True)
|
2021-06-14 14:43:34 +01:00
|
|
|
|
rate_multiplier = db.Column(db.Numeric(asdecimal=False), nullable=True)
|
2017-04-26 10:22:20 +01:00
|
|
|
|
|
2019-03-07 16:29:09 +00:00
|
|
|
|
created_by_id = db.Column(UUID(as_uuid=True), nullable=True)
|
2017-06-23 15:56:47 +01:00
|
|
|
|
|
2020-02-12 14:38:09 +00:00
|
|
|
|
document_download_count = db.Column(db.Integer, nullable=True)
|
|
|
|
|
|
|
2017-11-09 16:04:43 +00:00
|
|
|
|
__table_args__ = (
|
|
|
|
|
|
db.ForeignKeyConstraint(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
["template_id", "template_version"],
|
|
|
|
|
|
["templates_history.id", "templates_history.version"],
|
2017-11-09 16:04:43 +00:00
|
|
|
|
),
|
2021-06-21 12:06:38 +01:00
|
|
|
|
Index(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"ix_notification_history_service_id_composite",
|
|
|
|
|
|
"service_id",
|
|
|
|
|
|
"key_type",
|
|
|
|
|
|
"notification_type",
|
|
|
|
|
|
"created_at",
|
|
|
|
|
|
),
|
2017-11-09 16:04:43 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2016-07-08 16:19:34 +01:00
|
|
|
|
@classmethod
|
2016-12-15 17:11:47 +00:00
|
|
|
|
def from_original(cls, notification):
|
|
|
|
|
|
history = super().from_original(notification)
|
2017-05-04 17:09:04 +01:00
|
|
|
|
history.status = notification.status
|
2016-08-25 11:55:38 +01:00
|
|
|
|
return history
|
2016-07-08 16:19:34 +01:00
|
|
|
|
|
2017-05-09 11:09:16 +01:00
|
|
|
|
def update_from_original(self, original):
|
|
|
|
|
|
super().update_from_original(original)
|
|
|
|
|
|
self.status = original.status
|
|
|
|
|
|
|
2016-07-07 16:30:22 +01:00
|
|
|
|
|
2016-02-23 16:21:47 +00:00
|
|
|
|
class InvitedUser(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "invited_users"
|
2016-02-23 16:21:47 +00:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
email_address = db.Column(db.String(255), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
user_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
from_user = db.relationship("User")
|
|
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service")
|
2016-02-23 16:21:47 +00:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
status = enum_column(
|
|
|
|
|
|
InvitedUserStatus,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
nullable=False,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=InvitedUserStatus.PENDING,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-02-29 09:49:12 +00:00
|
|
|
|
permissions = db.Column(db.String, nullable=False)
|
2024-01-30 10:18:53 -05:00
|
|
|
|
auth_type = enum_column(AuthType, index=True, nullable=False, default=AuthType.SMS)
|
2024-01-30 16:10:28 -05:00
|
|
|
|
folder_permissions = db.Column(
|
|
|
|
|
|
JSONB(none_as_null=True), nullable=False, default=list
|
|
|
|
|
|
)
|
2016-02-29 09:49:12 +00:00
|
|
|
|
|
|
|
|
|
|
# would like to have used properties for this but haven't found a way to make them
|
|
|
|
|
|
# play nice with marshmallow yet
|
|
|
|
|
|
def get_permissions(self):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
return self.permissions.split(",")
|
2016-02-26 12:00:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
2023-07-10 11:06:29 -07:00
|
|
|
|
class InvitedOrganizationUser(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "invited_organization_users"
|
2018-02-15 14:16:16 +00:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
email_address = db.Column(db.String(255), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
invited_by_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
invited_by = db.relationship("User")
|
|
|
|
|
|
organization_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("organization.id"),
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
organization = db.relationship("Organization")
|
|
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-02-15 14:16:16 +00:00
|
|
|
|
|
2024-01-18 11:31:02 -05:00
|
|
|
|
status = enum_column(
|
|
|
|
|
|
InvitedUserStatus,
|
2018-02-15 14:16:16 +00:00
|
|
|
|
nullable=False,
|
2024-01-30 10:18:53 -05:00
|
|
|
|
default=InvitedUserStatus.PENDING,
|
2018-02-15 14:16:16 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2018-02-16 10:56:12 +00:00
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"email_address": self.email_address,
|
|
|
|
|
|
"invited_by": str(self.invited_by_id),
|
|
|
|
|
|
"organization": str(self.organization_id),
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"status": self.status,
|
2018-02-16 10:56:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2018-02-15 14:16:16 +00:00
|
|
|
|
|
2016-02-26 12:00:16 +00:00
|
|
|
|
class Permission(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "permissions"
|
2016-02-26 12:00:16 +00:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
# Service id is optional, if the service is omitted we will assume the permission is not service specific.
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service")
|
|
|
|
|
|
user_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("users.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
user = db.relationship("User")
|
2024-01-18 11:31:02 -05:00
|
|
|
|
permission = enum_column(PermissionType, index=False, unique=False, nullable=False)
|
2016-02-26 12:00:16 +00:00
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-02-26 12:00:16 +00:00
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
2023-08-29 14:54:30 -07:00
|
|
|
|
UniqueConstraint(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
"service_id",
|
|
|
|
|
|
"user_id",
|
|
|
|
|
|
"permission",
|
|
|
|
|
|
name="uix_service_user_permission",
|
2023-08-29 14:54:30 -07:00
|
|
|
|
),
|
2016-02-26 12:00:16 +00:00
|
|
|
|
)
|
2016-03-31 15:57:50 +01:00
|
|
|
|
|
|
|
|
|
|
|
2016-04-27 10:27:05 +01:00
|
|
|
|
class Event(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "events"
|
2016-04-27 10:27:05 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
event_type = db.Column(db.String(255), nullable=False)
|
|
|
|
|
|
created_at = db.Column(
|
|
|
|
|
|
db.DateTime,
|
|
|
|
|
|
index=False,
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2016-04-27 10:27:05 +01:00
|
|
|
|
data = db.Column(JSON, nullable=False)
|
2017-04-24 16:20:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
2017-04-25 09:53:43 +01:00
|
|
|
|
class Rate(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "rates"
|
2017-04-24 16:20:03 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
valid_from = db.Column(db.DateTime, nullable=False)
|
2017-04-27 15:43:57 +01:00
|
|
|
|
rate = db.Column(db.Float(asdecimal=False), nullable=False)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, index=True, nullable=False)
|
2017-05-09 11:22:05 +01:00
|
|
|
|
|
2017-05-24 08:57:11 +01:00
|
|
|
|
def __str__(self):
|
2024-02-02 11:40:25 -05:00
|
|
|
|
return f"{self.rate} {self.notification_type} {self.valid_from}"
|
2017-05-24 08:57:11 +01:00
|
|
|
|
|
2017-05-09 11:22:05 +01:00
|
|
|
|
|
2017-05-22 11:26:47 +01:00
|
|
|
|
class InboundSms(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "inbound_sms"
|
2017-05-22 11:26:47 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service", backref="inbound_sms")
|
|
|
|
|
|
|
|
|
|
|
|
notify_number = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.String,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
) # the service's number, that the msg was sent to
|
|
|
|
|
|
user_number = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.String,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
index=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
) # the end user's number, that the msg was sent from
|
2017-05-22 11:26:47 +01:00
|
|
|
|
provider_date = db.Column(db.DateTime)
|
|
|
|
|
|
provider_reference = db.Column(db.String)
|
2017-06-02 16:51:27 +01:00
|
|
|
|
provider = db.Column(db.String, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
_content = db.Column("content", db.String, nullable=False)
|
2017-05-22 11:26:47 +01:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def content(self):
|
|
|
|
|
|
return encryption.decrypt(self._content)
|
|
|
|
|
|
|
|
|
|
|
|
@content.setter
|
|
|
|
|
|
def content(self, content):
|
|
|
|
|
|
self._content = encryption.encrypt(content)
|
2017-06-02 14:47:28 +01:00
|
|
|
|
|
2017-05-31 14:49:14 +01:00
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
|
|
|
|
|
"notify_number": self.notify_number,
|
|
|
|
|
|
"user_number": self.user_number,
|
|
|
|
|
|
"content": self.content,
|
2017-05-31 14:49:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-06-02 14:47:28 +01:00
|
|
|
|
|
2019-12-16 17:54:47 +00:00
|
|
|
|
class InboundSmsHistory(db.Model, HistoryModel):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "inbound_sms_history"
|
2019-12-16 17:54:47 +00:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True)
|
2019-12-20 12:34:59 +00:00
|
|
|
|
created_at = db.Column(db.DateTime, index=True, unique=False, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship("Service")
|
2019-12-16 17:54:47 +00:00
|
|
|
|
notify_number = db.Column(db.String, nullable=False)
|
|
|
|
|
|
provider_date = db.Column(db.DateTime)
|
|
|
|
|
|
provider_reference = db.Column(db.String)
|
|
|
|
|
|
provider = db.Column(db.String, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
2017-09-07 15:41:23 +01:00
|
|
|
|
class ServiceEmailReplyTo(db.Model):
|
|
|
|
|
|
__tablename__ = "service_email_reply_to"
|
|
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2017-09-12 09:50:52 +01:00
|
|
|
|
service = db.relationship(Service, backref=db.backref("reply_to_email_addresses"))
|
2017-09-07 15:41:23 +01:00
|
|
|
|
|
|
|
|
|
|
email_address = db.Column(db.Text, nullable=False, index=False, unique=False)
|
|
|
|
|
|
is_default = db.Column(db.Boolean, nullable=False, default=True)
|
2018-04-25 10:42:00 +01:00
|
|
|
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2017-09-13 15:27:00 +01:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
|
|
|
|
|
"email_address": self.email_address,
|
|
|
|
|
|
"is_default": self.is_default,
|
|
|
|
|
|
"archived": self.archived,
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2017-09-13 15:27:00 +01:00
|
|
|
|
}
|
2017-09-21 16:08:49 +01:00
|
|
|
|
|
|
|
|
|
|
|
2018-03-12 18:19:26 +00:00
|
|
|
|
class FactBilling(db.Model):
|
|
|
|
|
|
__tablename__ = "ft_billing"
|
|
|
|
|
|
|
2022-11-21 11:49:59 -05:00
|
|
|
|
local_date = db.Column(db.Date, nullable=False, primary_key=True, index=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
template_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
index=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
index=True,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-03-21 14:14:16 +00:00
|
|
|
|
notification_type = db.Column(db.Text, nullable=False, primary_key=True)
|
2018-09-24 14:20:39 +01:00
|
|
|
|
provider = db.Column(db.Text, nullable=False, primary_key=True)
|
|
|
|
|
|
rate_multiplier = db.Column(db.Integer(), nullable=False, primary_key=True)
|
|
|
|
|
|
international = db.Column(db.Boolean, nullable=False, primary_key=True)
|
|
|
|
|
|
rate = db.Column(db.Numeric(), nullable=False, primary_key=True)
|
2018-05-10 15:35:58 +01:00
|
|
|
|
billable_units = db.Column(db.Integer(), nullable=True)
|
2018-03-12 18:19:26 +00:00
|
|
|
|
notifications_sent = db.Column(db.Integer(), nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-03-12 18:19:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-05-09 14:06:46 +01:00
|
|
|
|
class FactNotificationStatus(db.Model):
|
|
|
|
|
|
__tablename__ = "ft_notification_status"
|
|
|
|
|
|
|
2022-11-21 11:49:59 -05:00
|
|
|
|
local_date = db.Column(db.Date, index=True, primary_key=True, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
template_id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2018-05-09 14:06:46 +01:00
|
|
|
|
job_id = db.Column(UUID(as_uuid=True), primary_key=True, index=True, nullable=False)
|
2024-01-31 08:03:17 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, primary_key=True, nullable=False)
|
|
|
|
|
|
key_type = enum_column(KeyType, primary_key=True, nullable=False)
|
2024-02-02 11:40:25 -05:00
|
|
|
|
notification_status = enum_column(
|
|
|
|
|
|
NotificationStatus,
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2018-05-09 14:06:46 +01:00
|
|
|
|
notification_count = db.Column(db.Integer(), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-05-31 14:43:49 +01:00
|
|
|
|
|
|
|
|
|
|
|
2021-02-22 15:42:29 +00:00
|
|
|
|
class FactProcessingTime(db.Model):
|
|
|
|
|
|
__tablename__ = "ft_processing_time"
|
|
|
|
|
|
|
2022-11-21 11:49:59 -05:00
|
|
|
|
local_date = db.Column(db.Date, index=True, primary_key=True, nullable=False)
|
2021-02-22 15:42:29 +00:00
|
|
|
|
messages_total = db.Column(db.Integer(), nullable=False)
|
|
|
|
|
|
messages_within_10_secs = db.Column(db.Integer(), nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2021-02-22 15:42:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
2018-05-31 14:43:49 +01:00
|
|
|
|
class Complaint(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "complaints"
|
2018-06-05 14:25:24 +01:00
|
|
|
|
|
2018-05-31 14:43:49 +01:00
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2019-12-09 12:19:18 +00:00
|
|
|
|
notification_id = db.Column(UUID(as_uuid=True), index=True, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
service = db.relationship(Service, backref=db.backref("complaints"))
|
2018-05-31 14:43:49 +01:00
|
|
|
|
ses_feedback_id = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
complaint_type = db.Column(db.Text, nullable=True)
|
|
|
|
|
|
complaint_date = db.Column(db.DateTime, nullable=True)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-06-05 14:25:24 +01:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"notification_id": str(self.notification_id),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
|
|
|
|
|
"service_name": self.service.name,
|
|
|
|
|
|
"ses_feedback_id": str(self.ses_feedback_id),
|
|
|
|
|
|
"complaint_type": self.complaint_type,
|
|
|
|
|
|
"complaint_date": get_dt_string_or_none(self.complaint_date),
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2018-06-05 14:25:24 +01:00
|
|
|
|
}
|
2018-07-10 11:35:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceDataRetention(db.Model):
|
2023-08-29 14:54:30 -07:00
|
|
|
|
__tablename__ = "service_data_retention"
|
2018-07-10 11:35:20 +01:00
|
|
|
|
|
|
|
|
|
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
service_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
db.ForeignKey("services.id"),
|
|
|
|
|
|
unique=False,
|
|
|
|
|
|
index=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
)
|
2019-08-15 15:03:49 +01:00
|
|
|
|
service = db.relationship(
|
|
|
|
|
|
Service,
|
|
|
|
|
|
backref=db.backref(
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"data_retention",
|
|
|
|
|
|
collection_class=attribute_mapped_collection("notification_type"),
|
|
|
|
|
|
),
|
2019-08-15 15:03:49 +01:00
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
notification_type = enum_column(NotificationType, nullable=False)
|
2018-07-10 11:35:20 +01:00
|
|
|
|
days_of_retention = db.Column(db.Integer, nullable=False)
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2018-07-10 13:54:44 +01:00
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
2023-08-29 14:54:30 -07:00
|
|
|
|
UniqueConstraint(
|
|
|
|
|
|
"service_id", "notification_type", name="uix_service_data_retention"
|
|
|
|
|
|
),
|
2018-07-10 13:54:44 +01:00
|
|
|
|
)
|
2018-07-11 17:02:49 +01:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"service_id": str(self.service_id),
|
|
|
|
|
|
"service_name": self.service.name,
|
|
|
|
|
|
"notification_type": self.notification_type,
|
|
|
|
|
|
"days_of_retention": self.days_of_retention,
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
2020-07-27 15:17:19 +01:00
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2018-07-11 17:02:49 +01:00
|
|
|
|
}
|
2019-12-09 12:19:18 +00:00
|
|
|
|
|
|
|
|
|
|
|
2021-05-07 16:08:34 +01:00
|
|
|
|
class WebauthnCredential(db.Model):
|
|
|
|
|
|
"""
|
|
|
|
|
|
A table that stores data for registered webauthn credentials.
|
|
|
|
|
|
"""
|
2023-08-29 14:54:30 -07:00
|
|
|
|
|
2021-05-07 16:08:34 +01:00
|
|
|
|
__tablename__ = "webauthn_credential"
|
|
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
nullable=False,
|
|
|
|
|
|
default=uuid.uuid4,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2021-05-07 16:08:34 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey("users.id"), nullable=False)
|
2021-05-10 16:36:00 +01:00
|
|
|
|
user = db.relationship(User, backref=db.backref("webauthn_credentials"))
|
2021-05-07 16:08:34 +01:00
|
|
|
|
|
2021-05-10 16:36:00 +01:00
|
|
|
|
name = db.Column(db.String, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
# base64 encoded CBOR. used for logging in. https://w3c.github.io/webauthn/#sctn-attested-credential-data
|
|
|
|
|
|
credential_data = db.Column(db.String, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
# base64 encoded CBOR. used for auditing. https://www.w3.org/TR/webauthn-2/#authenticatorattestationresponse
|
|
|
|
|
|
registration_response = db.Column(db.String, nullable=False)
|
2021-05-07 16:08:34 +01:00
|
|
|
|
|
2023-08-29 14:54:30 -07:00
|
|
|
|
created_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=False,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
default=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
|
|
|
|
|
updated_at = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
db.DateTime,
|
|
|
|
|
|
nullable=True,
|
2024-05-23 13:59:51 -07:00
|
|
|
|
onupdate=utc_now(),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2021-05-10 22:09:07 +01:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"user_id": str(self.user_id),
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"credential_data": self.credential_data,
|
|
|
|
|
|
"created_at": self.created_at.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"updated_at": get_dt_string_or_none(self.updated_at),
|
2021-05-10 22:09:07 +01:00
|
|
|
|
}
|
2023-07-14 11:13:36 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Agreement(db.Model):
|
|
|
|
|
|
__tablename__ = "agreements"
|
2023-08-29 14:54:30 -07:00
|
|
|
|
id = db.Column(
|
2024-01-12 17:46:00 -05:00
|
|
|
|
UUID(as_uuid=True),
|
|
|
|
|
|
primary_key=True,
|
|
|
|
|
|
default=uuid.uuid4,
|
|
|
|
|
|
unique=False,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
type = enum_column(AgreementType, index=False, unique=False, nullable=False)
|
2023-07-14 11:13:36 -07:00
|
|
|
|
partner_name = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
2024-01-18 11:31:02 -05:00
|
|
|
|
status = enum_column(AgreementStatus, index=False, unique=False, nullable=False)
|
2023-07-14 11:13:36 -07:00
|
|
|
|
start_time = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
end_time = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
url = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
|
|
|
|
|
budget_amount = db.Column(db.Float, nullable=True)
|
|
|
|
|
|
organization_id = db.Column(
|
|
|
|
|
|
UUID(as_uuid=True),
|
2023-08-29 14:54:30 -07:00
|
|
|
|
db.ForeignKey("organization.id"),
|
2023-07-14 11:13:36 -07:00
|
|
|
|
nullable=True,
|
|
|
|
|
|
)
|
2023-11-17 15:37:38 -05:00
|
|
|
|
organization = db.relationship("Organization", backref="agreements")
|
2021-05-10 22:09:07 +01:00
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
|
return {
|
2023-07-14 11:13:36 -07:00
|
|
|
|
"id": str(self.id),
|
|
|
|
|
|
"type": self.type,
|
|
|
|
|
|
"partner_name": self.partner_name,
|
|
|
|
|
|
"status": self.status,
|
|
|
|
|
|
"start_time": self.start_time.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"end_time": self.end_time.strftime(DATETIME_FORMAT),
|
|
|
|
|
|
"budget_amount": self.budget_amount,
|
2023-08-29 14:54:30 -07:00
|
|
|
|
"organization_id": self.organization_id,
|
2021-05-10 22:09:07 +01:00
|
|
|
|
}
|