feat(drafts): add bot_drafts schema, automatic table bootstrap, and sync REST endpoints
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user