Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
a642ba91fa
21 files changed
+1587
-47
No files matched your search
@@ -245,6 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` → re-import is a no-op). **`view`**: a public, read-only result page — see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3「结果绘制」report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.gateway.routers import (
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
runs,
|
||||
@@ -925,6 +926,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
|
||||
app.include_router(thread_shares.public_router)
|
||||
|
||||
# 圆桌会商草稿分享(import 复制副本 / view 只读结果报告页)
|
||||
app.include_router(roundtable_draft_shares.router)
|
||||
# Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
|
||||
app.include_router(roundtable_draft_shares.public_router)
|
||||
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
|
||||
|
||||
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
@@ -330,6 +334,14 @@ def get_thread_share_store(request: Request):
|
||||
return val
|
||||
|
||||
|
||||
def get_roundtable_draft_share_store(request: Request):
|
||||
"""Return the roundtable-draft share-link store."""
|
||||
val = getattr(request.app.state, "roundtable_draft_share_store", None)
|
||||
if val is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
|
||||
return val
|
||||
|
||||
|
||||
def get_embed_session_store(request: Request):
|
||||
"""Return the iframe embed session → thread mapping store."""
|
||||
val = getattr(request.app.state, "embed_session_store", None)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Roundtable-draft sharing API.
|
||||
|
||||
A user creates a share code for *one* of their roundtable-planning drafts (a full
|
||||
会商 session). Two modes:
|
||||
|
||||
- ``import`` — redeeming the code copies the draft into the redeemer's account as
|
||||
an independent copy they fully own (fresh draft id, deduped per recipient).
|
||||
- ``view`` — a public, read-only page that renders the draft's Step 3 result
|
||||
report (the self-contained 方案总览 HTML). Anyone with the link can open it
|
||||
without logging in.
|
||||
|
||||
Mirrors ``app/gateway/routers/thread_shares.py`` but the shared resource is a
|
||||
``roundtable_drafts`` row (read via ``app.state.roundtable_draft_store``) rather
|
||||
than a conversation thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_roundtable_draft_share_store
|
||||
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/roundtable-draft-shares", tags=["roundtable-draft-shares"])
|
||||
|
||||
# Public, unauthenticated router. Its prefix is whitelisted in
|
||||
# ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``) — only read-only,
|
||||
# intentionally world-readable endpoints belong here.
|
||||
public_router = APIRouter(prefix="/api/public/roundtable-shares", tags=["roundtable-draft-shares-public"])
|
||||
|
||||
_UNTITLED = "未命名会商"
|
||||
_SHARE_MODES = ("import", "view")
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_draft_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_draft_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
|
||||
return store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateShareRequest(BaseModel):
|
||||
draft_id: str = Field(..., description="The roundtable draft to share")
|
||||
mode: str = Field(
|
||||
default="import",
|
||||
description='"import" — recipient copies the draft; "view" — public read-only result page',
|
||||
)
|
||||
|
||||
|
||||
class ShareResponse(BaseModel):
|
||||
share_code: str = Field(..., description="Short code that recipients enter to import")
|
||||
owner_user_id: str = Field(..., description="User who created the share")
|
||||
draft_id: str = Field(..., description="The shared draft")
|
||||
revoked: bool = Field(default=False, description="Whether the code has been revoked")
|
||||
mode: str = Field(default="import", description='"import" or "view"')
|
||||
created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)")
|
||||
|
||||
|
||||
class SharesListResponse(BaseModel):
|
||||
shares: list[ShareResponse]
|
||||
|
||||
|
||||
class SharePreviewResponse(BaseModel):
|
||||
share_code: str
|
||||
shared_by: str = Field(..., description="Owner user id of the shared draft")
|
||||
title: str = Field(..., description="Title of the shared draft")
|
||||
|
||||
|
||||
class ImportResultResponse(BaseModel):
|
||||
imported: int = Field(..., description="1 when the draft was copied, else 0")
|
||||
skipped: int = Field(..., description="1 when it was already imported, else 0")
|
||||
draft_id: str | None = Field(default=None, description="The new local draft id (when imported)")
|
||||
|
||||
|
||||
class PublicDraftShareSnapshotResponse(BaseModel):
|
||||
"""Read-only roundtable-result snapshot served to anonymous viewers."""
|
||||
|
||||
share_code: str
|
||||
title: str = Field(..., description="Draft title")
|
||||
mode: str = Field(default="view")
|
||||
# The Step 3「结果绘制」report — a self-contained HTML document. Empty when the
|
||||
# shared draft never reached / generated a result report.
|
||||
html: str = Field(default="", description="Self-contained Step 3 result report HTML")
|
||||
summary: str | None = Field(default=None, description="The report's closing one-liner")
|
||||
generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)")
|
||||
has_report: bool = Field(default=False, description="Whether a Step 3 report exists")
|
||||
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner side — create / list / revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=ShareResponse, summary="Create a share code for one roundtable draft")
|
||||
async def create_share(
|
||||
body: CreateShareRequest,
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> ShareResponse:
|
||||
me = _current_user_id(request)
|
||||
mode = body.mode if body.mode in _SHARE_MODES else "import"
|
||||
# Only the draft's owner may share it.
|
||||
draft = await _get_draft_store(request).get_draft(body.draft_id, me)
|
||||
if draft is None:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
# Reuse an existing active code (of the same mode) so re-sharing is idempotent.
|
||||
existing = await share_store.get_active_for_draft(me, body.draft_id, mode=mode)
|
||||
if existing is not None:
|
||||
return ShareResponse(**existing)
|
||||
record = await share_store.create(me, body.draft_id, mode=mode)
|
||||
return ShareResponse(**record)
|
||||
|
||||
|
||||
@router.get("", response_model=SharesListResponse, summary="List my roundtable-draft share codes")
|
||||
async def list_shares(
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> SharesListResponse:
|
||||
records = await share_store.list_for_owner(_current_user_id(request))
|
||||
return SharesListResponse(shares=[ShareResponse(**r) for r in records])
|
||||
|
||||
|
||||
@router.delete("/{share_code}", summary="Revoke a roundtable-draft share code")
|
||||
async def revoke_share(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> dict[str, bool]:
|
||||
ok = await share_store.revoke(share_code, _current_user_id(request))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Share code not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recipient side — preview / import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _resolve_active_share(share_code: str, share_store: RoundtableDraftShareStore) -> dict:
|
||||
share = await share_store.get(share_code)
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Share code not found")
|
||||
if share.get("revoked"):
|
||||
raise HTTPException(status_code=410, detail="This share code has been revoked")
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared roundtable draft")
|
||||
async def preview_share(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> SharePreviewResponse:
|
||||
share = await _resolve_active_share(share_code, share_store)
|
||||
owner = share["owner_user_id"]
|
||||
draft = await _get_draft_store(request).get_draft(share["draft_id"], owner)
|
||||
if draft is None:
|
||||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||||
return SharePreviewResponse(
|
||||
share_code=share_code,
|
||||
shared_by=owner,
|
||||
title=draft.get("title") or _UNTITLED,
|
||||
)
|
||||
|
||||
|
||||
def _imported_draft_id(share_code: str, recipient: str) -> str:
|
||||
"""Deterministic per-(code, recipient) draft id so re-import is idempotent.
|
||||
|
||||
``roundtable_drafts.id`` is a global PK, so the id must be unique across
|
||||
users — hence the recipient is folded into the hash rather than reusing a
|
||||
code-only id (which would collide when two people import the same code).
|
||||
"""
|
||||
digest = hashlib.sha1(f"{share_code}:{recipient}".encode()).hexdigest()
|
||||
return f"imp_{digest[:40]}"
|
||||
|
||||
|
||||
@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared roundtable draft")
|
||||
async def import_share(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> ImportResultResponse:
|
||||
share = await _resolve_active_share(share_code, share_store)
|
||||
if share.get("mode") == "view":
|
||||
raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing")
|
||||
owner = share["owner_user_id"]
|
||||
origin_id = share["draft_id"]
|
||||
recipient = _current_user_id(request)
|
||||
if owner == recipient:
|
||||
raise HTTPException(status_code=400, detail="You cannot import your own share code")
|
||||
|
||||
draft_store = _get_draft_store(request)
|
||||
src = await draft_store.get_draft(origin_id, owner)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||||
|
||||
# Dedup: a deterministic id per (code, recipient) makes re-import a no-op.
|
||||
new_id = _imported_draft_id(share_code, recipient)
|
||||
if await draft_store.get_draft(new_id, recipient) is not None:
|
||||
return ImportResultResponse(imported=0, skipped=1, draft_id=new_id)
|
||||
|
||||
await draft_store.create_draft(
|
||||
recipient,
|
||||
{
|
||||
"id": new_id,
|
||||
"title": src.get("title") or _UNTITLED,
|
||||
"furthest_step": src.get("furthest_step") or 1,
|
||||
"step1": src.get("step1"),
|
||||
"step2": src.get("step2"),
|
||||
"step3": src.get("step3"),
|
||||
},
|
||||
)
|
||||
logger.info("Roundtable share %s imported by %s as draft %s", share_code, recipient, new_id)
|
||||
return ImportResultResponse(imported=1, skipped=0, draft_id=new_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public read-only view — no authentication required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@public_router.get(
|
||||
"/{share_code}",
|
||||
response_model=PublicDraftShareSnapshotResponse,
|
||||
summary="Public read-only snapshot of a view-shared roundtable draft",
|
||||
)
|
||||
async def get_public_draft_share_snapshot(
|
||||
share_code: str,
|
||||
request: Request,
|
||||
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
|
||||
) -> PublicDraftShareSnapshotResponse:
|
||||
"""Serve a frozen, read-only roundtable result. Anyone with the link can read it.
|
||||
|
||||
Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode
|
||||
codes all return 404 so the endpoint never reveals which case applies.
|
||||
"""
|
||||
share = await share_store.get(share_code)
|
||||
if share is None or share.get("revoked") or share.get("mode") != "view":
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
draft = await _get_draft_store(request).get_draft(share["draft_id"], share["owner_user_id"])
|
||||
if draft is None:
|
||||
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
|
||||
|
||||
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else None
|
||||
html = (step3 or {}).get("html") or ""
|
||||
return PublicDraftShareSnapshotResponse(
|
||||
share_code=share_code,
|
||||
title=draft.get("title") or _UNTITLED,
|
||||
mode="view",
|
||||
html=html,
|
||||
summary=(step3 or {}).get("summary"),
|
||||
generated_at=(step3 or {}).get("generatedAt"),
|
||||
has_report=bool(html),
|
||||
created_at=share.get("created_at"),
|
||||
)
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"""Create roundtable_draft_shares table.
|
||||
|
||||
Per-draft share links for the roundtable-planning page, mirroring
|
||||
``thread_shares`` but pointing at a ``roundtable_drafts`` row. ``import`` codes
|
||||
copy the draft into the recipient's account; ``view`` codes back a public
|
||||
read-only result-report page. ``create_all`` makes the table for brand-new DBs;
|
||||
this revision patches DBs that already exist.
|
||||
|
||||
Revision ID: 20260606_01
|
||||
Revises: 20260605_02
|
||||
Create Date: 2026-06-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260606_01"
|
||||
down_revision = "20260605_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_draft_shares"):
|
||||
return
|
||||
op.create_table(
|
||||
"roundtable_draft_shares",
|
||||
sa.Column("share_code", sa.String(length=32), primary_key=True),
|
||||
sa.Column("owner_user_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("draft_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("mode", sa.String(length=16), nullable=False, server_default="import"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_roundtable_draft_shares_owner_user_id",
|
||||
"roundtable_draft_shares",
|
||||
["owner_user_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_roundtable_draft_shares_draft_id",
|
||||
"roundtable_draft_shares",
|
||||
["draft_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("roundtable_draft_shares"):
|
||||
op.drop_table("roundtable_draft_shares")
|
||||
+2
@@ -23,6 +23,7 @@ from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||
from deerflow.persistence.roundtable_drafts.model import (
|
||||
RoundtableDraftRow,
|
||||
RoundtableRecommendHistoryRow,
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"RecommendedQuestionRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableDraftShareRow",
|
||||
"RoundtableJobRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RunEventRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Persistence layer for roundtable-draft share links."""
|
||||
|
||||
from deerflow.persistence.roundtable_draft_shares.base import (
|
||||
RoundtableDraftShareStore,
|
||||
generate_share_code,
|
||||
)
|
||||
from deerflow.persistence.roundtable_draft_shares.memory import MemoryRoundtableDraftShareStore
|
||||
from deerflow.persistence.roundtable_draft_shares.sql import RoundtableDraftShareRepository
|
||||
|
||||
__all__ = [
|
||||
"RoundtableDraftShareStore",
|
||||
"MemoryRoundtableDraftShareStore",
|
||||
"RoundtableDraftShareRepository",
|
||||
"generate_share_code",
|
||||
"make_roundtable_draft_share_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_draft_share_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryRoundtableDraftShareStore()
|
||||
return RoundtableDraftShareRepository(session_factory)
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""Abstract persistence API for roundtable-draft share links.
|
||||
|
||||
Mirrors ``thread_shares`` but shares one *roundtable-planning draft* (a full
|
||||
会商 session: Step 1 intent + Step 2 roundtable + optional Step 3 report)
|
||||
instead of a conversation thread. Redeeming an ``import`` code copies the draft
|
||||
into the recipient's account; a ``view`` code is a public read-only page that
|
||||
renders the draft's Step 3 result report.
|
||||
|
||||
The share-code alphabet / generator is reused from ``thread_shares.base`` so
|
||||
codes are interchangeable in shape (unambiguous, human-readable).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
# Reuse the same unambiguous-alphabet code generator as conversation shares.
|
||||
from deerflow.persistence.thread_shares.base import generate_share_code
|
||||
|
||||
__all__ = ["RoundtableDraftShareStore", "generate_share_code"]
|
||||
|
||||
|
||||
class RoundtableDraftShareStore(ABC):
|
||||
@abstractmethod
|
||||
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
|
||||
"""Create a new share link for one draft and return its record.
|
||||
|
||||
``mode`` is ``"import"`` (recipient copies the draft) or ``"view"``
|
||||
(a public read-only result-report page).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, share_code: str) -> dict[str, Any] | None:
|
||||
"""Look up a share by code regardless of owner (None if missing)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
|
||||
"""Return the owner's non-revoked share of ``mode`` for ``draft_id``, if any.
|
||||
|
||||
Used so re-sharing the same draft in the same mode reuses its existing
|
||||
code instead of minting a new one each time.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
|
||||
"""List all share links created by ``owner_user_id`` (newest first)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
|
||||
"""Revoke a share link. Returns False if missing or not owned."""
|
||||
raise NotImplementedError
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"""In-memory ``RoundtableDraftShareStore`` for tests / memory backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
|
||||
|
||||
|
||||
class MemoryRoundtableDraftShareStore(RoundtableDraftShareStore):
|
||||
def __init__(self) -> None:
|
||||
self._shares: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
|
||||
code = generate_share_code()
|
||||
while code in self._shares:
|
||||
code = generate_share_code()
|
||||
record = {
|
||||
"share_code": code,
|
||||
"owner_user_id": owner_user_id,
|
||||
"draft_id": draft_id,
|
||||
"revoked": False,
|
||||
"mode": mode,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
self._shares[code] = record
|
||||
return copy.deepcopy(record)
|
||||
|
||||
async def get(self, share_code: str) -> dict[str, Any] | None:
|
||||
record = self._shares.get(share_code)
|
||||
return copy.deepcopy(record) if record is not None else None
|
||||
|
||||
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
|
||||
matches = [
|
||||
r
|
||||
for r in self._shares.values()
|
||||
if r["owner_user_id"] == owner_user_id
|
||||
and r["draft_id"] == draft_id
|
||||
and r.get("mode", "import") == mode
|
||||
and not r["revoked"]
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return copy.deepcopy(matches[0])
|
||||
|
||||
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
|
||||
rows = [r for r in self._shares.values() if r["owner_user_id"] == owner_user_id]
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return [copy.deepcopy(r) for r in rows]
|
||||
|
||||
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
|
||||
record = self._shares.get(share_code)
|
||||
if record is None or record["owner_user_id"] != owner_user_id:
|
||||
return False
|
||||
record["revoked"] = True
|
||||
return True
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"""ORM model for roundtable-draft share links.
|
||||
|
||||
A share link is a short, revocable code created by a user for *one* of their
|
||||
roundtable-planning drafts. Redeeming it copies that draft into the redeemer's
|
||||
account (``import``) or exposes its Step 3 result report on a public read-only
|
||||
page (``view``). The table only stores the link — the draft snapshot is read
|
||||
lazily at import / view time, so a revoked link instantly stops working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class RoundtableDraftShareRow(Base):
|
||||
__tablename__ = "roundtable_draft_shares"
|
||||
|
||||
# Short, URL-safe, unguessable code embedded in the share link.
|
||||
share_code: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
# The user who created the share.
|
||||
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# The single roundtable draft this link shares.
|
||||
draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# Soft-disable: a revoked link can no longer be previewed, imported, or viewed.
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# Share mode:
|
||||
# "import" — recipient (a logged-in user) copies the draft into their own
|
||||
# account and can keep working on the copy.
|
||||
# "view" — a public, read-only page anyone can open without logging in;
|
||||
# it renders the draft's Step 3 result report (HTML).
|
||||
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="import", server_default="import")
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"""SQLAlchemy-backed roundtable-draft share-link repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
|
||||
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
|
||||
|
||||
|
||||
def _row_to_dict(row: RoundtableDraftShareRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
created = data.get("created_at")
|
||||
if isinstance(created, datetime):
|
||||
data["created_at"] = created.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class RoundtableDraftShareRepository(RoundtableDraftShareStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
|
||||
# Retry on the (astronomically unlikely) code collision.
|
||||
async with self._sf() as session:
|
||||
for _ in range(8):
|
||||
code = generate_share_code()
|
||||
if await session.get(RoundtableDraftShareRow, code) is not None:
|
||||
continue
|
||||
row = RoundtableDraftShareRow(
|
||||
share_code=code,
|
||||
owner_user_id=owner_user_id,
|
||||
draft_id=draft_id,
|
||||
revoked=False,
|
||||
mode=mode,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
raise RuntimeError("Failed to allocate a unique share code")
|
||||
|
||||
async def get(self, share_code: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftShareRow, share_code)
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
|
||||
stmt = (
|
||||
select(RoundtableDraftShareRow)
|
||||
.where(
|
||||
RoundtableDraftShareRow.owner_user_id == owner_user_id,
|
||||
RoundtableDraftShareRow.draft_id == draft_id,
|
||||
RoundtableDraftShareRow.mode == mode,
|
||||
RoundtableDraftShareRow.revoked.is_(False),
|
||||
)
|
||||
.order_by(RoundtableDraftShareRow.created_at.desc())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
|
||||
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(RoundtableDraftShareRow)
|
||||
.where(RoundtableDraftShareRow.owner_user_id == owner_user_id)
|
||||
.order_by(RoundtableDraftShareRow.created_at.desc())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableDraftShareRow, share_code)
|
||||
if row is None or row.owner_user_id != owner_user_id:
|
||||
return False
|
||||
row.revoked = True
|
||||
await session.commit()
|
||||
return True
|
||||
Reference in new issue
Block a user