"""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])