153 lines
6.0 KiB
Python
153 lines
6.0 KiB
Python
"""CRUD API for recommended questions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["recommended-questions"])
|
|
|
|
|
|
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()
|
|
|
|
|
|
class RecommendedQuestionResponse(BaseModel):
|
|
id: str
|
|
title: str
|
|
content: str
|
|
sort_order: int = 0
|
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this question (按岗位分发标签)")
|
|
created_at: datetime | str | None = None
|
|
updated_at: datetime | str | None = None
|
|
|
|
|
|
class RecommendedQuestionsListResponse(BaseModel):
|
|
questions: list[RecommendedQuestionResponse]
|
|
|
|
|
|
class RecommendedQuestionCreateRequest(BaseModel):
|
|
title: str = Field(..., min_length=1, max_length=256)
|
|
content: str = Field(..., min_length=1)
|
|
sort_order: int = Field(default=0)
|
|
|
|
|
|
class RecommendedQuestionUpdateRequest(BaseModel):
|
|
title: str | None = Field(default=None, max_length=256)
|
|
content: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
class ReorderRequest(BaseModel):
|
|
ids: list[str] = Field(..., description="Question IDs in the desired order")
|
|
|
|
|
|
def _get_store(request: Request):
|
|
store = getattr(request.app.state, "recommended_question_store", None)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="Recommended question store not available")
|
|
return store
|
|
|
|
|
|
@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse)
|
|
async def list_recommended_questions(
|
|
request: Request,
|
|
all: bool = Query(default=False, description="Admin management view: return every question, unfiltered by 岗位."),
|
|
tag_ids: list[str] | None = Query(default=None, description="Keep questions carrying any of these tags (OR)."),
|
|
) -> RecommendedQuestionsListResponse:
|
|
store = _get_store(request)
|
|
rows = await store.list_all()
|
|
responses = [RecommendedQuestionResponse(**r) for r in rows]
|
|
|
|
# Bulk-attach the tags assigned to each question so the management list can
|
|
# display them and the 岗位 filter below can tell tagged from untagged.
|
|
# Best-effort: a missing/failing tag store never breaks the question list.
|
|
tag_store = getattr(request.app.state, "tag_store", None)
|
|
if tag_store is not None and responses:
|
|
try:
|
|
assignments = await tag_store.list_assignments("recommended_question", [r.id for r in responses])
|
|
except Exception:
|
|
logger.debug("recommended-question tag attach failed", exc_info=True)
|
|
assignments = {}
|
|
for resp in responses:
|
|
resp.tags = assignments.get(resp.id, [])
|
|
|
|
if not all:
|
|
# Filter by the caller's 岗位 (position) visibility:
|
|
# * ``set`` — the position binds recommended_question 标签 → show only
|
|
# the questions carrying those bound tags.
|
|
# * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions.
|
|
# 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。
|
|
sync = getattr(request.app.state, "position_sync", None)
|
|
if sync is not None:
|
|
try:
|
|
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
|
|
except Exception:
|
|
logger.debug("recommended-question visibility resolve failed", exc_info=True)
|
|
visible = None
|
|
if visible is not None:
|
|
responses = [r for r in responses if r.id in visible]
|
|
else:
|
|
responses = [r for r in responses if not r.tags]
|
|
|
|
if tag_ids:
|
|
wanted = set(tag_ids)
|
|
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
|
|
|
return RecommendedQuestionsListResponse(questions=responses)
|
|
|
|
|
|
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
|
|
async def create_recommended_question(request: Request, body: RecommendedQuestionCreateRequest) -> RecommendedQuestionResponse:
|
|
store = _get_store(request)
|
|
data = {
|
|
"id": str(uuid4()),
|
|
"title": body.title.strip(),
|
|
"content": body.content.strip(),
|
|
"sort_order": body.sort_order,
|
|
}
|
|
row = await store.create(data)
|
|
return RecommendedQuestionResponse(**row)
|
|
|
|
|
|
@router.put("/recommended-questions/{question_id}", response_model=RecommendedQuestionResponse)
|
|
async def update_recommended_question(request: Request, question_id: str, body: RecommendedQuestionUpdateRequest) -> RecommendedQuestionResponse:
|
|
store = _get_store(request)
|
|
data: dict = {}
|
|
if body.title is not None:
|
|
data["title"] = body.title.strip()
|
|
if body.content is not None:
|
|
data["content"] = body.content.strip()
|
|
if body.sort_order is not None:
|
|
data["sort_order"] = body.sort_order
|
|
|
|
row = await store.update(question_id, data)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
return RecommendedQuestionResponse(**row)
|
|
|
|
|
|
@router.delete("/recommended-questions/{question_id}", status_code=204)
|
|
async def delete_recommended_question(request: Request, question_id: str) -> None:
|
|
store = _get_store(request)
|
|
deleted = await store.delete(question_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Question not found")
|
|
|
|
|
|
@router.put("/recommended-questions-reorder", response_model=RecommendedQuestionsListResponse)
|
|
async def reorder_recommended_questions(request: Request, body: ReorderRequest) -> RecommendedQuestionsListResponse:
|
|
store = _get_store(request)
|
|
rows = await store.reorder(body.ids)
|
|
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
|