DealDocumentScreening/src/contract_check/core/db/models.py

401 lines
14 KiB
Python

"""SQLAlchemy 2 declarative models — the 7 production tables.
Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to
gen_random_uuid() server-side (built into Postgres 13+, no extension needed
for pg16).
"""
from __future__ import annotations
import datetime as dt
import uuid
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Declarative base for all models."""
def _now() -> dt.datetime:
return dt.datetime.now(tz=dt.UTC)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
email: Mapped[str | None] = mapped_column(Text, unique=True)
name: Mapped[str | None] = mapped_column(Text)
password_hash: Mapped[str | None] = mapped_column(Text)
password_reset_token_hash: Mapped[str | None] = mapped_column(Text)
password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
magic_link_token_hash: Mapped[str | None] = mapped_column(Text)
magic_link_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
role: Mapped[str] = mapped_column(
String, nullable=False, default="user", server_default=text("'user'")
)
telegram_profile_json: Mapped[str | None] = mapped_column(Text)
telegram_verified: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
credits_left: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
CheckConstraint("credits_left >= 0", name="users_credits_nonneg"),
# A user must have at least one identity anchor.
CheckConstraint(
"telegram_id IS NOT NULL OR email IS NOT NULL",
name="users_identity_present",
),
CheckConstraint("role IN ('user', 'admin')", name="users_role_check"),
)
documents: Mapped[list[Document]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
api_keys: Mapped[list[ApiKey]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
passkey_credentials: Mapped[list[PasskeyCredential]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class PasskeyCredential(Base):
"""Registered WebAuthn credential (passkey) bound to a user.
`credential_id`/`public_key` are base64url-encoded bytes as produced by
the webauthn helpers; the raw bytes never touch the DB.
"""
__tablename__ = "passkey_credentials"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
credential_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
public_key: Mapped[str] = mapped_column(Text, nullable=False)
sign_count: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=0, server_default=text("0")
)
device_name: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(back_populates="passkey_credentials")
__table_args__ = (
CheckConstraint("sign_count >= 0", name="passkey_credentials_sign_count_nonneg"),
Index("passkey_credentials_user_idx", "user_id"),
)
class Document(Base):
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
s3_key: Mapped[str] = mapped_column(Text, nullable=False)
extracted_s3_key: Mapped[str | None] = mapped_column(Text)
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime: Mapped[str] = mapped_column(Text, nullable=False)
bytes_: Mapped[int] = mapped_column("bytes", BigInteger, nullable=False, default=0)
status: Mapped[str] = mapped_column(
String,
nullable=False,
default="queued",
server_default=text("'queued'"),
)
stage: Mapped[str | None] = mapped_column(Text)
refunded: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now
)
user: Mapped[User] = relationship(back_populates="documents")
report: Mapped[Report | None] = relationship(
back_populates="document", cascade="all, delete-orphan", uselist=False
)
jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan")
api_key_requests: Mapped[list[ApiKeyRequest]] = relationship(
back_populates="document", cascade="all, delete-orphan"
)
__table_args__ = (
CheckConstraint(
"status IN ('queued','extracting','ocr','analyzing','done','failed')",
name="documents_status_check",
),
Index("documents_user_created_idx", "user_id", "created_at"),
Index("documents_status_idx", "status"),
)
class Report(Base):
__tablename__ = "reports"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
content_json: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
markdown: Mapped[str] = mapped_column(Text, nullable=False)
model_used: Mapped[str | None] = mapped_column(Text)
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
eval_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
document: Mapped[Document] = relationship(back_populates="report")
class Job(Base):
__tablename__ = "jobs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
)
correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
queue: Mapped[str] = mapped_column(String, nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
last_failure_class: Mapped[str | None] = mapped_column(Text)
last_error: Mapped[str | None] = mapped_column(Text)
dlq: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
status: Mapped[str] = mapped_column(
String,
nullable=False,
default="pending",
server_default=text("'pending'"),
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now
)
document: Mapped[Document] = relationship(back_populates="jobs")
__table_args__ = (
CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"),
CheckConstraint(
"status IN ('pending','running','retrying','dlq','done')",
name="jobs_status_check",
),
Index("jobs_correlation_idx", "correlation_id"),
Index("jobs_document_idx", "document_id"),
)
class ServiceToken(Base):
__tablename__ = "service_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
token_hash: Mapped[str] = mapped_column(Text, nullable=False)
adapter: Mapped[str] = mapped_column(String, nullable=False)
revoked: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
CheckConstraint("adapter IN ('bot','web','cli')", name="service_tokens_adapter_check"),
)
class ApiKey(Base):
"""B2B API key: opaque bearer secret hashed via SHA-256."""
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(Text, nullable=False)
key_hash: Mapped[str] = mapped_column(Text, nullable=False)
rate_limit_rps: Mapped[int] = mapped_column(
Integer, nullable=False, default=3, server_default=text("3")
)
monthly_quota: Mapped[int | None] = mapped_column(Integer)
monthly_used: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0")
)
resets_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(back_populates="api_keys")
requests: Mapped[list[ApiKeyRequest]] = relationship(
back_populates="api_key", cascade="all, delete-orphan"
)
__table_args__ = (
CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"),
CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"),
CheckConstraint(
"monthly_quota IS NULL OR monthly_quota >= 0",
name="api_keys_monthly_quota_nonneg",
),
Index("api_keys_user_idx", "user_id"),
Index("api_keys_hash_idx", "key_hash"),
UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"),
)
class ApiKeyRequest(Base):
"""One B2B API call = one request row for usage/dashboard."""
__tablename__ = "api_key_requests"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
api_key_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
)
document_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
api_key: Mapped[ApiKey] = relationship(back_populates="requests")
document: Mapped[Document] = relationship(back_populates="api_key_requests")
__table_args__ = (
Index("api_key_requests_key_created_idx", "api_key_id", text("created_at DESC")),
)
class Invoice(Base):
"""STUB — schema forward-compatible for ЮKassa. No payment logic yet."""
__tablename__ = "invoices"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
amount: Mapped[int] = mapped_column(Integer, nullable=False) # kopecks
status: Mapped[str] = mapped_column(
String, nullable=False, default="draft", server_default=text("'draft'")
)
provider: Mapped[str | None] = mapped_column(Text)
external_id: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
paid_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
CheckConstraint(
"status IN ('draft','pending','succeeded','cancelled','refunded')",
name="invoices_status_check",
),
Index("invoices_user_idx", "user_id", "created_at"),
)