Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.db import Base, create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
database_url = os.environ["SMS_DATABASE_URL"]
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
version_table_schema="sms",
|
||||
include_schemas=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
version_table_schema="sms",
|
||||
include_schemas=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = create_postgres_engine(database_url)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Create SMS journal schema objects.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
SCHEMA = "sms"
|
||||
channel = postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA, create_type=False)
|
||||
send_status = postgresql.ENUM(
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"failed",
|
||||
"uncertain",
|
||||
"skipped",
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
create_type=False,
|
||||
)
|
||||
delivery_status = postgresql.ENUM(
|
||||
"unknown",
|
||||
"sent",
|
||||
"delivered",
|
||||
"undelivered",
|
||||
"unsent",
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
postgresql.ENUM("SMS", name="sms_channel", schema=SCHEMA).create(bind)
|
||||
postgresql.ENUM(
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"failed",
|
||||
"uncertain",
|
||||
"skipped",
|
||||
name="sms_send_status",
|
||||
schema=SCHEMA,
|
||||
).create(bind)
|
||||
postgresql.ENUM(
|
||||
"unknown",
|
||||
"sent",
|
||||
"delivered",
|
||||
"undelivered",
|
||||
"unsent",
|
||||
name="sms_delivery_status",
|
||||
schema=SCHEMA,
|
||||
).create(bind)
|
||||
|
||||
op.create_table(
|
||||
"sms_template",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("code", sa.String(64), nullable=False),
|
||||
sa.Column("channel", channel, nullable=False),
|
||||
sa.Column("locale", sa.String(16), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("body_template", sa.Text(), nullable=False),
|
||||
sa.Column("placeholders", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("sender_name", sa.String(64)),
|
||||
sa.Column("max_parts", sa.SmallInteger(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("created_by", sa.String(64), nullable=False),
|
||||
sa.CheckConstraint("version > 0", name="ck_template_version_positive"),
|
||||
sa.CheckConstraint("max_parts BETWEEN 1 AND 10", name="ck_template_max_parts"),
|
||||
sa.UniqueConstraint("code", "channel", "locale", "version", name="uq_template_version"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_template_active",
|
||||
"sms_template",
|
||||
["code", "channel", "locale"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("is_active"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_setting",
|
||||
sa.Column("setting_key", sa.String(128), primary_key=True),
|
||||
sa.Column("setting_value", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("value_type", sa.String(16), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"value_type IN ('string','integer','boolean')", name="ck_setting_value_type"
|
||||
),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_outbound_message",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("requester_service", sa.String(64), nullable=False),
|
||||
sa.Column("process", sa.String(64), nullable=False),
|
||||
sa.Column("channel", sa.String(16), nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column("phone_e164", sa.String(16), nullable=False),
|
||||
sa.Column("phone_digits", sa.String(15), nullable=False),
|
||||
sa.Column("phone_masked", sa.String(32), nullable=False),
|
||||
sa.Column(
|
||||
"template_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey(f"{SCHEMA}.sms_template.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("template_code", sa.String(64), nullable=False),
|
||||
sa.Column("body_rendered", sa.Text(), nullable=False),
|
||||
sa.Column("substitutions", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("send_status", send_status, nullable=False),
|
||||
sa.Column("delivery_status", delivery_status, nullable=False),
|
||||
sa.Column("provider_message_id", sa.String(128)),
|
||||
sa.Column("provider_external_id", sa.String(128)),
|
||||
sa.Column("customer_ref", sa.String(128)),
|
||||
sa.Column("idempotency_key", sa.String(192), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(64), nullable=False),
|
||||
sa.Column("request_id", sa.String(128)),
|
||||
sa.Column("traceparent", sa.String(55)),
|
||||
sa.Column("provider_http_status", sa.Integer()),
|
||||
sa.Column("provider_error_code", sa.String(64)),
|
||||
sa.Column("provider_error_message", sa.String(256)),
|
||||
sa.Column("sender_name", sa.String(64), nullable=False),
|
||||
sa.Column("message_ttl_sec", sa.Integer()),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("worker_locked_until", sa.DateTime(timezone=True)),
|
||||
sa.Column("parts", sa.Integer()),
|
||||
sa.Column("price", sa.Numeric(14, 4)),
|
||||
sa.Column("currency", sa.String(3)),
|
||||
sa.Column("callback_last_at", sa.DateTime(timezone=True)),
|
||||
sa.CheckConstraint("channel = 'SMS'", name="ck_outbound_channel"),
|
||||
sa.CheckConstraint("provider = 'idgtl'", name="ck_outbound_provider"),
|
||||
sa.CheckConstraint("process = 'auth_otp'", name="ck_outbound_process"),
|
||||
sa.CheckConstraint("message_ttl_sec BETWEEN 60 AND 86400", name="ck_outbound_ttl"),
|
||||
sa.CheckConstraint("attempt_count >= 0", name="ck_outbound_attempts"),
|
||||
sa.UniqueConstraint("requester_service", "idempotency_key", name="uq_outbound_idempotency"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_outbound_provider_message",
|
||||
"sms_outbound_message",
|
||||
["provider", "provider_message_id"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("provider_message_id IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_phone_created",
|
||||
"sms_outbound_message",
|
||||
["phone_e164", sa.text("created_at DESC")],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_requester_process_created",
|
||||
"sms_outbound_message",
|
||||
["requester_service", "process", sa.text("created_at DESC")],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_customer_ref", "sms_outbound_message", ["customer_ref"], schema=SCHEMA
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_send_created",
|
||||
"sms_outbound_message",
|
||||
["send_status", "created_at"],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbound_delivery_updated",
|
||||
"sms_outbound_message",
|
||||
["delivery_status", "updated_at"],
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sms_callback_event",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("message_uuid", sa.String(128), nullable=False),
|
||||
sa.Column("callback_event", sa.String(32), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("status_time", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"received_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"message_uuid", "callback_event", "status", "status_time", name="uq_callback_event"
|
||||
),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sms_callback_event", schema=SCHEMA)
|
||||
op.drop_table("sms_outbound_message", schema=SCHEMA)
|
||||
op.drop_table("sms_setting", schema=SCHEMA)
|
||||
op.drop_table("sms_template", schema=SCHEMA)
|
||||
delivery_status.drop(op.get_bind())
|
||||
send_status.drop(op.get_bind())
|
||||
channel.drop(op.get_bind())
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Seed versioned technical settings and OTP template placeholder.
|
||||
|
||||
Revision ID: 0002_seed
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_seed"
|
||||
down_revision = "0001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TEMPLATE_ID = uuid.UUID("5ac2a77e-590c-4b24-87d8-baa0f1240cd1")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO sms.sms_template (
|
||||
id, code, channel, locale, version, body_template, placeholders,
|
||||
sender_name, max_parts, is_active, approved_at, created_by
|
||||
) VALUES (
|
||||
:id, 'auth_otp', 'SMS', 'ru', 1,
|
||||
'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
|
||||
'["code","ttl_min"]'::jsonb, NULL, 1, true, NULL, 'migration'
|
||||
)
|
||||
ON CONFLICT (code, channel, locale, version) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"id": TEMPLATE_ID},
|
||||
)
|
||||
settings = (
|
||||
(
|
||||
"provider.idgtl.default_sender_name",
|
||||
'"__SET_ME_AFTER_PROVIDER_APPROVAL__"',
|
||||
"string",
|
||||
"Provider-approved default sender name",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.connect_timeout_ms",
|
||||
"3000",
|
||||
"integer",
|
||||
"Direct connection timeout in milliseconds",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.request_timeout_ms",
|
||||
"70000",
|
||||
"integer",
|
||||
"Direct total request timeout in milliseconds",
|
||||
),
|
||||
(
|
||||
"provider.idgtl.callback_enabled",
|
||||
"true",
|
||||
"boolean",
|
||||
"Include delivery callback in provider requests",
|
||||
),
|
||||
(
|
||||
"worker.poll_interval_ms",
|
||||
"500",
|
||||
"integer",
|
||||
"Queue polling interval in milliseconds",
|
||||
),
|
||||
(
|
||||
"worker.lease_seconds",
|
||||
"90",
|
||||
"integer",
|
||||
"Exclusive provider-call lease duration",
|
||||
),
|
||||
)
|
||||
for key, value, value_type, description in settings:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO sms.sms_setting (
|
||||
setting_key, setting_value, value_type, description
|
||||
) VALUES (:key, CAST(:value AS jsonb), :value_type, :description)
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value_type": value_type,
|
||||
"description": description,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM sms.sms_template WHERE id = :id").bindparams(id=TEMPLATE_ID))
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM sms.sms_setting
|
||||
WHERE setting_key IN (
|
||||
'provider.idgtl.default_sender_name',
|
||||
'provider.idgtl.connect_timeout_ms',
|
||||
'provider.idgtl.request_timeout_ms',
|
||||
'provider.idgtl.callback_enabled',
|
||||
'worker.poll_interval_ms',
|
||||
'worker.lease_seconds'
|
||||
)
|
||||
"""
|
||||
)
|
||||
Reference in New Issue
Block a user