feat(kb): admin CRUD for topics

Backs the Phase 4 topic-management table in the KnowledgeBase EspoCRM
extension ("ניהול" tab → "נושאים" panel). Four new admin endpoints:

  GET    /admin/kb/topics         — list ALL topics including
                                    is_active=false ones, with
                                    source_count joined from kb_source
  POST   /admin/kb/topics         — create. Validates slug shape
                                    (^[a-z][a-z0-9-]{1,62}$) and
                                    uniqueness — clean 400 on duplicate
                                    rather than leaking UniqueViolation
  PUT    /admin/kb/topics/{id}    — patch name, description,
                                    system_prompt_addendum, is_active.
                                    slug is intentionally NOT editable —
                                    the S3 layout depends on it
                                    (inbox/<topic_slug>/<kind>/...)
  DELETE /admin/kb/topics/{id}    — hard-delete; refuses with 409 if
                                    any sources still belong to the
                                    topic. Soft-delete (is_active=false)
                                    is the normal "remove from dropdown"
                                    path

Refs Task Master #15 (espocrm-extensions/KnowledgeBase)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 17:46:31 +00:00
parent 17f93b5f3e
commit 49503caab3
2 changed files with 225 additions and 0 deletions
+62
View File
@@ -387,3 +387,65 @@ async def reingest_admin_source(request: Request, source_id: int):
asyncio.create_task(kb_admin_sources.process_reingest_job(job_id))
return {"job_id": job_id, "status": "queued", "source_id": source_id}
# ── Phase 4: topic CRUD (Task #15) ─────────────────────────────────────────
class TopicCreate(BaseModel):
slug: str = Field(..., min_length=2, max_length=63)
name: str = Field(..., min_length=1, max_length=200)
description: str | None = Field(None, max_length=1000)
system_prompt_addendum: str | None = Field(None, max_length=4000)
is_active: bool = True
class TopicUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
description: str | None = Field(None, max_length=1000)
system_prompt_addendum: str | None = Field(None, max_length=4000)
is_active: bool | None = None
@router.get("/topics")
async def list_admin_topics(request: Request):
_verify_admin(request)
items = await kb_topics.list_topics_admin()
return {"items": items, "count": len(items)}
@router.post("/topics")
async def create_admin_topic(request: Request, body: TopicCreate):
_verify_admin(request)
try:
return await kb_topics.create_topic(
slug=body.slug,
name=body.name,
description=body.description,
system_prompt_addendum=body.system_prompt_addendum,
is_active=body.is_active,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/topics/{topic_id}")
async def update_admin_topic(request: Request, topic_id: int, body: TopicUpdate):
_verify_admin(request)
fields = body.model_dump(exclude_unset=True)
try:
return await kb_topics.update_topic(topic_id, fields)
except LookupError:
raise HTTPException(status_code=404, detail="Topic not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/topics/{topic_id}")
async def delete_admin_topic(request: Request, topic_id: int):
_verify_admin(request)
try:
return await kb_topics.delete_topic(topic_id)
except LookupError:
raise HTTPException(status_code=404, detail="Topic not found")
except kb_topics.ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))