Files
han-app/codebase/backend/api-backend/app/db.py
T

359 lines
17 KiB
Python

import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
SCHEMA = "han_app"
class Base(DeclarativeBase):
type_annotation_map = {dict[str, Any]: JSON}
class Common:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status_change_reason: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
class UserIdentity(Common, Base):
__tablename__ = "user_identities"
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
phone_number: Mapped[str] = mapped_column(String(32))
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class ClientProfile(Common, Base):
__tablename__ = "client_profiles"
__table_args__ = ({"schema": SCHEMA},)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
)
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
full_name: Mapped[str | None] = mapped_column(String(255))
citizenship: Mapped[str | None] = mapped_column(String(128))
russian_phone: Mapped[str | None] = mapped_column(String(32))
foreign_phone: Mapped[str | None] = mapped_column(String(32))
email: Mapped[str | None] = mapped_column(String(320))
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class UserConsent(Common, Base):
__tablename__ = "user_consents"
__table_args__ = (
UniqueConstraint("user_id", "consent_type", "document_version"),
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
consent_type: Mapped[str] = mapped_column(String(32))
document_version: Mapped[str] = mapped_column(String(64))
accepted: Mapped[bool] = mapped_column(Boolean)
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
client_ip: Mapped[str | None] = mapped_column(INET)
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
class UxSession(Common, Base):
__tablename__ = "ux_sessions"
__table_args__ = (
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
start_reason: Mapped[str] = mapped_column(String(32))
platform: Mapped[str] = mapped_column(String(32))
app_version: Mapped[str] = mapped_column(String(64))
device_id_hash: Mapped[str | None] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class Dialog(Common, Base):
__tablename__ = "dialogs"
__table_args__ = (
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
{"schema": SCHEMA},
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
)
status: Mapped[str] = mapped_column(String(32), default="open")
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Message(Common, Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint("sender_type IN ('client','company')"),
CheckConstraint("content_kind IN ('text','file')"),
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
CheckConstraint(
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
),
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
UniqueConstraint("dialog_id", "client_idempotency_key"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
)
sender_type: Mapped[str] = mapped_column(String(16))
content_kind: Mapped[str] = mapped_column(String(16))
text: Mapped[str] = mapped_column(Text, default="")
safety_status: Mapped[str] = mapped_column(String(16))
delivery_status: Mapped[str] = mapped_column(String(16))
external_message_id: Mapped[str | None] = mapped_column(String(255))
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class MessageAttachment(Common, Base):
__tablename__ = "message_attachments"
__table_args__ = (
CheckConstraint("direction IN ('client_upload','company_inbound')"),
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
CheckConstraint("size_bytes > 0"),
{"schema": SCHEMA},
)
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
message_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
)
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
direction: Mapped[str] = mapped_column(String(32))
original_file_name: Mapped[str] = mapped_column(String(255))
safe_file_name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Document(Common, Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
name: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64))
storage_bucket: Mapped[str] = mapped_column(String(255))
object_key: Mapped[str] = mapped_column(String(1024))
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class SafetyTask(Base):
__tablename__ = "safety_tasks"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_id: Mapped[str] = mapped_column(String(255), unique=True)
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
status: Mapped[str] = mapped_column(String(16))
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DeliveryOutbox(Base):
__tablename__ = "delivery_outbox"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class IdempotencyRecord(Base):
__tablename__ = "idempotency_records"
__table_args__ = (
UniqueConstraint("scope", "user_id", "idempotency_key"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
scope: Mapped[str] = mapped_column(String(128))
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
idempotency_key: Mapped[str] = mapped_column(String(128))
request_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
response_status: Mapped[int | None] = mapped_column(Integer)
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class OpenLinesInboxReceipt(Base):
__tablename__ = "openlines_inbox_receipts"
__table_args__ = (
UniqueConstraint("event_id"),
UniqueConstraint("external_chat_id", "bitrix_message_id"),
{"schema": SCHEMA},
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id: Mapped[str] = mapped_column(String(255))
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
event_type: Mapped[str] = mapped_column(String(32))
payload_fingerprint: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16))
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
last_error_code: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AuditEvent(Base):
__tablename__ = "audit_events"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_type: Mapped[str] = mapped_column(String(128))
actor_type: Mapped[str] = mapped_column(String(32))
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
request_id: Mapped[str] = mapped_column(String(64))
trace_id: Mapped[str | None] = mapped_column(String(64))
resource_type: Mapped[str | None] = mapped_column(String(64))
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
ip: Mapped[str | None] = mapped_column(INET)
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
outcome: Mapped[str] = mapped_column(String(32))
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AppSetting(Base):
__tablename__ = "app_settings"
__table_args__ = ({"schema": SCHEMA},)
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
setting_value: Mapped[str] = mapped_column(Text)
value_type: Mapped[str] = mapped_column(String(32))
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
record_status: Mapped[str] = mapped_column(String(1), default="A")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class TextResource(Common, Base):
__tablename__ = "text_resources"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
text_value: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class PopularQuestion(Common, Base):
__tablename__ = "popular_questions"
__table_args__ = (
UniqueConstraint("mnemonic", "locale"),
{"schema": SCHEMA},
)
mnemonic: Mapped[str] = mapped_column(String(255))
locale: Mapped[str] = mapped_column(String(16), default="ru")
question_text: Mapped[str] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class SyncQueue(Base):
__tablename__ = "sync_queue"
__table_args__ = ({"schema": SCHEMA},)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
task_type: Mapped[str] = mapped_column(String(64))
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EntityExternalMapping(Base):
__tablename__ = "entity_external_mapping"
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entity_type: Mapped[str] = mapped_column(String(64))
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
external_id: Mapped[str] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Database:
def __init__(self, url: str) -> None:
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
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()