401 lines
14 KiB
Python
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"),
|
|
)
|