feat(drafts): add bot_drafts schema, automatic table bootstrap, and sync REST endpoints

This commit is contained in:
2026-09-21 20:06:28 +03:30
parent d69ec7f7a9
commit 6ee0822a96
7 changed files with 183 additions and 0 deletions
+30
View File
@@ -432,3 +432,33 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("audit_log table ensured")
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
ddl = """
CREATE TABLE IF NOT EXISTS bot_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
missing JSONB,
status VARCHAR(16) NOT NULL DEFAULT 'active',
source VARCHAR(16),
review_message_id BIGINT,
last_bale_update_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status)",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("bot_drafts table ensured")
+5
View File
@@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import (
ensure_attachment_table,
ensure_audit_table,
ensure_bot_drafts_table,
ensure_drop_category_tables,
ensure_dashboard_tables,
ensure_material_topic_column,
@@ -107,6 +108,7 @@ async def lifespan(_app: FastAPI):
await ensure_v26(engine)
await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try:
yield
@@ -233,12 +235,15 @@ from app.routers.auth import router as _auth_router # noqa: E402
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
# routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402
from app.routers.drafts import router as _drafts_router # noqa: E402
for router in ROUTERS:
if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _drafts_router:
app.include_router(router, prefix=API_V1_PREFIX)
else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from uuid import UUID
from sqlalchemy import BigInteger, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from app.db.base import Base
class BotDraft(Base):
__tablename__ = "bot_drafts"
id = Column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
chat_id = Column(BigInteger, nullable=False)
kind = Column(String(16), nullable=False)
payload = Column(JSONB, nullable=False, default=dict)
missing = Column(JSONB)
status = Column(String(16), nullable=False, default="active")
source = Column(String(16))
review_message_id = Column(BigInteger)
last_bale_update_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+2
View File
@@ -4,6 +4,7 @@ from app.routers import (
auth,
brand_materials,
brands,
drafts,
files,
inventory,
item_decisions,
@@ -51,4 +52,5 @@ ROUTERS = [
inventory.router,
pm.router,
users.router,
drafts.router,
]
+106
View File
@@ -0,0 +1,106 @@
"""Drafts router — exposes bot_drafts to frontend for Wizard pre-population and bi-directional sync."""
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.draft import BotDraft
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/drafts", tags=["drafts"])
class DraftUpdateIn(BaseModel):
payload: dict[str, Any] | None = None
status: str | None = None
class DraftCommitIn(BaseModel):
quote_id: str | None = None
def _serialize_draft(draft: BotDraft) -> dict:
return {
"id": str(draft.id),
"chat_id": draft.chat_id,
"kind": draft.kind,
"status": draft.status,
"payload": draft.payload or {},
"missing": draft.missing or [],
"source": draft.source,
"review_message_id": draft.review_message_id,
"last_bale_update_at": draft.last_bale_update_at.isoformat() if draft.last_bale_update_at else None,
"created_at": draft.created_at.isoformat() if draft.created_at else None,
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
}
@router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
return {"data": _serialize_draft(draft)}
@router.patch("/{draft_id}")
async def update_draft(draft_id: UUID, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
if body.payload is not None:
merged = dict(draft.payload or {})
merged.update(body.payload)
draft.payload = merged
if body.status is not None:
draft.status = body.status
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/{draft_id}/commit")
async def commit_draft(draft_id: UUID, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "committed"
payload = dict(draft.payload or {})
if body.quote_id:
payload["committed_quote_id"] = body.quote_id
draft.payload = payload
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/{draft_id}/cancel")
async def cancel_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "cancelled"
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}