Files
han-app/VM1_app/codebase/backend/sms-service/app/db.py
T

254 lines
9.2 KiB
Python

from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
import asyncpg
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "sms"
class Channel(StrEnum):
SMS = "SMS"
class SendStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
FAILED = "failed"
UNCERTAIN = "uncertain"
SKIPPED = "skipped"
class DeliveryStatus(StrEnum):
UNKNOWN = "unknown"
SENT = "sent"
DELIVERED = "delivered"
UNDELIVERED = "undelivered"
UNSENT = "unsent"
class Base(DeclarativeBase):
pass
class SmsTemplate(Base):
__tablename__ = "sms_template"
__table_args__ = (
UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
Index(
"uq_template_active",
"code",
"channel",
"locale",
unique=True,
postgresql_where=text("is_active"),
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[Channel] = mapped_column(
Enum(
Channel,
name="sms_channel",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
)
)
locale: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
body_template: Mapped[str] = mapped_column(Text, nullable=False)
placeholders: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
sender_name: Mapped[str | None] = mapped_column(String(64))
max_parts: Mapped[int] = mapped_column(SmallInteger, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_by: Mapped[str] = mapped_column(String(64), nullable=False)
class SmsSetting(Base):
__tablename__ = "sms_setting"
__table_args__ = {"schema": SCHEMA}
setting_key: Mapped[str] = mapped_column(String(128), primary_key=True)
setting_value: Mapped[object] = mapped_column(JSONB, nullable=False)
value_type: Mapped[str] = mapped_column(String(16), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class SmsOutboundMessage(Base):
__tablename__ = "sms_outbound_message"
__table_args__ = (
UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
Index(
"uq_outbound_provider_message",
"provider",
"provider_message_id",
unique=True,
postgresql_where=text("provider_message_id IS NOT NULL"),
),
Index("ix_outbound_phone_created", "phone_e164", text("created_at DESC")),
Index(
"ix_outbound_requester_process_created",
"requester_service",
"process",
text("created_at DESC"),
),
Index("ix_outbound_customer_ref", "customer_ref"),
Index("ix_outbound_send_created", "send_status", "created_at"),
Index("ix_outbound_delivery_updated", "delivery_status", "updated_at"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
requester_service: Mapped[str] = mapped_column(String(64), nullable=False)
process: Mapped[str] = mapped_column(String(64), nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
phone_e164: Mapped[str] = mapped_column(String(16), nullable=False)
phone_digits: Mapped[str] = mapped_column(String(15), nullable=False)
phone_masked: Mapped[str] = mapped_column(String(32), nullable=False)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.sms_template.id"), nullable=False
)
template_code: Mapped[str] = mapped_column(String(64), nullable=False)
body_rendered: Mapped[str] = mapped_column(Text, nullable=False)
substitutions: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
send_status: Mapped[SendStatus] = mapped_column(
Enum(
SendStatus,
name="sms_send_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
delivery_status: Mapped[DeliveryStatus] = mapped_column(
Enum(
DeliveryStatus,
name="sms_delivery_status",
schema=SCHEMA,
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
provider_message_id: Mapped[str | None] = mapped_column(String(128))
provider_external_id: Mapped[str | None] = mapped_column(String(128))
customer_ref: Mapped[str | None] = mapped_column(String(128))
idempotency_key: Mapped[str] = mapped_column(String(192), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
request_id: Mapped[str | None] = mapped_column(String(128))
traceparent: Mapped[str | None] = mapped_column(String(55))
provider_http_status: Mapped[int | None] = mapped_column(Integer)
provider_error_code: Mapped[str | None] = mapped_column(String(64))
provider_error_message: Mapped[str | None] = mapped_column(String(256))
sender_name: Mapped[str] = mapped_column(String(64), nullable=False)
message_ttl_sec: Mapped[int | None] = mapped_column(Integer)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
worker_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
parts: Mapped[int | None] = mapped_column(Integer)
price: Mapped[Decimal | None] = mapped_column(Numeric(14, 4))
currency: Mapped[str | None] = mapped_column(String(3))
callback_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SmsCallbackEvent(Base):
__tablename__ = "sms_callback_event"
__table_args__ = (
UniqueConstraint(
"message_uuid",
"callback_event",
"status",
"status_time",
name="uq_callback_event",
),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
message_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
callback_event: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
status_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def asyncpg_dsn(url: str) -> str:
return url.replace("postgresql+asyncpg://", "postgresql://", 1)
def create_postgres_engine(url: str) -> AsyncEngine:
dsn = asyncpg_dsn(url)
async def connect() -> asyncpg.Connection:
return await asyncpg.connect(dsn=dsn)
return create_async_engine(
"postgresql+asyncpg://", async_creator=connect, pool_pre_ping=True
)
class Database:
def __init__(self, url: str) -> None:
self.engine = create_postgres_engine(url)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessions() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()