bmc_hub/app/modules/manual/backend/router.py

422 lines
14 KiB
Python
Raw Normal View History

import logging
import json
import re
from typing import Any, Dict, List, Literal, Optional
from fastapi import BackgroundTasks, APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.modules.manual.backend.cache import manual_cache
from app.core.database import execute_query, execute_query_single, execute_update
logger = logging.getLogger(__name__)
router = APIRouter()
DifficultyType = Literal["beginner", "advanced"]
class ManualStepInput(BaseModel):
step_number: int = Field(ge=1)
title: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
image_url: Optional[str] = None
video_url: Optional[str] = None
class ManualRelationInput(BaseModel):
related_module: Optional[str] = Field(default=None, max_length=80)
related_tag: Optional[str] = Field(default=None, max_length=120)
related_sag_type: Optional[str] = Field(default=None, max_length=120)
related_manual_id: Optional[str] = None
class ManualArticleCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
slug: Optional[str] = Field(default=None, max_length=255)
content: str = Field(min_length=1)
summary: Optional[str] = None
module: str = Field(min_length=1, max_length=80)
tags: List[str] = Field(default_factory=list)
difficulty: DifficultyType = "beginner"
steps: List[ManualStepInput] = Field(default_factory=list)
relations: List[ManualRelationInput] = Field(default_factory=list)
class ManualArticleUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
slug: Optional[str] = Field(default=None, max_length=255)
content: Optional[str] = Field(default=None, min_length=1)
summary: Optional[str] = None
module: Optional[str] = Field(default=None, min_length=1, max_length=80)
tags: Optional[List[str]] = None
difficulty: Optional[DifficultyType] = None
steps: Optional[List[ManualStepInput]] = None
relations: Optional[List[ManualRelationInput]] = None
def _slugify(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9\s-]", "", value or "").strip().lower()
cleaned = re.sub(r"[-\s]+", "-", cleaned)
return cleaned[:255] or "manual"
def _normalize_tags(tags: Optional[List[str]]) -> List[str]:
if not tags:
return []
out: List[str] = []
seen = set()
for tag in tags:
clean = str(tag or "").strip().lower()
if not clean:
continue
if clean in seen:
continue
seen.add(clean)
out.append(clean)
return out
def _next_unique_slug(base_slug: str, exclude_id: Optional[str] = None) -> str:
candidate = _slugify(base_slug)
suffix = 1
while True:
if exclude_id:
row = execute_query_single(
"SELECT id FROM manual_articles WHERE slug = %s AND id <> %s AND deleted_at IS NULL",
(candidate, exclude_id),
)
else:
row = execute_query_single(
"SELECT id FROM manual_articles WHERE slug = %s AND deleted_at IS NULL",
(candidate,),
)
if not row:
return candidate
candidate = f"{_slugify(base_slug)}-{suffix}"
suffix += 1
def _fetch_article_by_id(article_id: str) -> Optional[Dict[str, Any]]:
row = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty,
use_count, created_at, updated_at
FROM manual_articles
WHERE id = %s AND deleted_at IS NULL
""",
(article_id,),
)
if not row:
return None
return _expand_article(row)
def _expand_article(row: Dict[str, Any]) -> Dict[str, Any]:
article = dict(row)
steps = execute_query(
"""
SELECT id, step_number, title, content, image_url, video_url
FROM manual_steps
WHERE manual_id = %s
ORDER BY step_number ASC
""",
(article["id"],),
) or []
relations = execute_query(
"""
SELECT
mr.id,
mr.related_module,
mr.related_tag,
mr.related_sag_type,
mr.related_manual_id,
rm.slug AS related_manual_slug,
rm.title AS related_manual_title
FROM manual_relations mr
LEFT JOIN manual_articles rm
ON rm.id = mr.related_manual_id
AND rm.deleted_at IS NULL
WHERE mr.manual_id = %s
ORDER BY rm.use_count DESC NULLS LAST, rm.updated_at DESC NULLS LAST
""",
(article["id"],),
) or []
article["steps"] = steps
article["relations"] = relations
return article
def _replace_steps(article_id: str, steps: List[ManualStepInput]) -> None:
execute_update("DELETE FROM manual_steps WHERE manual_id = %s", (article_id,))
for step in sorted(steps, key=lambda s: s.step_number):
execute_query(
"""
INSERT INTO manual_steps (manual_id, step_number, title, content, image_url, video_url)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
article_id,
step.step_number,
step.title,
step.content,
step.image_url,
step.video_url,
),
)
def _replace_relations(article_id: str, relations: List[ManualRelationInput]) -> None:
execute_update("DELETE FROM manual_relations WHERE manual_id = %s", (article_id,))
for relation in relations:
execute_query(
"""
INSERT INTO manual_relations (manual_id, related_module, related_tag, related_sag_type, related_manual_id)
VALUES (%s, %s, %s, %s, %s)
""",
(
article_id,
relation.related_module,
relation.related_tag,
relation.related_sag_type,
relation.related_manual_id,
),
)
@router.get("/manual")
async def list_manual_articles(
module: Optional[str] = Query(default=None),
difficulty: Optional[DifficultyType] = Query(default=None),
tags: Optional[str] = Query(default=None, description="Comma-separated tags"),
search: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
where_parts = ["deleted_at IS NULL"]
params: List[Any] = []
if module:
where_parts.append("LOWER(module) = LOWER(%s)")
params.append(module.strip())
if difficulty:
where_parts.append("difficulty = %s")
params.append(difficulty)
if tags:
tag_list = _normalize_tags([t.strip() for t in tags.split(",") if t.strip()])
if tag_list:
where_parts.append(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE t.tag = ANY(%s::text[]))"
)
params.append(tag_list)
if search:
where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)")
needle = f"%{search.strip()}%"
params.extend([needle, needle, needle])
query = f"""
SELECT id, title, slug, summary, module, tags, difficulty, use_count, created_at, updated_at
FROM manual_articles
WHERE {' AND '.join(where_parts)}
ORDER BY use_count DESC, updated_at DESC, created_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
items = execute_query(query, tuple(params)) or []
return {"items": items, "count": len(items)}
@router.get("/manual/context")
async def contextual_manual_suggestions(
module: Optional[str] = Query(default=None),
tag: Optional[str] = Query(default=None),
sag_type: Optional[str] = Query(default=None),
limit: int = Query(default=10, ge=1, le=50),
):
where_parts = ["ma.deleted_at IS NULL"]
params: List[Any] = []
if module:
where_parts.append("(LOWER(ma.module) = LOWER(%s) OR LOWER(mr.related_module) = LOWER(%s))")
params.extend([module.strip(), module.strip()])
if tag:
where_parts.append(
"(LOWER(mr.related_tag) = LOWER(%s) OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(ma.tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s)))"
)
params.extend([tag.strip(), tag.strip()])
if sag_type:
where_parts.append("LOWER(mr.related_sag_type) = LOWER(%s)")
params.append(sag_type.strip())
query = f"""
SELECT DISTINCT
ma.id,
ma.title,
ma.slug,
ma.summary,
ma.module,
ma.tags,
ma.difficulty,
ma.use_count,
ma.updated_at
FROM manual_articles ma
LEFT JOIN manual_relations mr ON mr.manual_id = ma.id
WHERE {' AND '.join(where_parts)}
ORDER BY ma.use_count DESC, ma.updated_at DESC
LIMIT %s
"""
params.append(limit)
items = execute_query(query, tuple(params)) or []
return {"items": items, "count": len(items)}
@router.get("/manual/{slug}")
async def get_manual_article(slug: str, background_tasks: BackgroundTasks):
cache_key = f"slug:{slug}"
cached = manual_cache.get(cache_key)
if cached:
background_tasks.add_task(_increment_use_count, cached["id"])
return cached
article = execute_query_single(
"""
SELECT id, title, slug, content, summary, module, tags, difficulty, use_count, created_at, updated_at
FROM manual_articles
WHERE slug = %s AND deleted_at IS NULL
""",
(slug,),
)
if not article:
raise HTTPException(status_code=404, detail="Manual not found")
execute_update(
"UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s",
(article["id"],),
)
# Read the refreshed count for consistency in response.
article["use_count"] = int(article.get("use_count") or 0) + 1
return _expand_article(article)
@router.post("/manual")
async def create_manual_article(payload: ManualArticleCreate):
try:
wanted_slug = payload.slug or payload.title
slug = _next_unique_slug(wanted_slug)
created = execute_query_single(
"""
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
RETURNING id
""",
(
payload.title.strip(),
slug,
payload.content,
payload.summary,
payload.module.strip().lower(),
json.dumps(_normalize_tags(payload.tags), ensure_ascii=False),
payload.difficulty,
),
)
if not created:
raise HTTPException(status_code=500, detail="Could not create manual")
article_id = created["id"]
if payload.steps:
_replace_steps(article_id, payload.steps)
if payload.relations:
_replace_relations(article_id, payload.relations)
article = _fetch_article_by_id(article_id)
if not article:
raise HTTPException(status_code=500, detail="Could not load created manual")
return article
except HTTPException:
raise
except Exception as e:
logger.error("❌ Failed to create manual article: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create manual")
@router.put("/manual/{article_id}")
async def update_manual_article(article_id: str, payload: ManualArticleUpdate):
existing = execute_query_single(
"SELECT id, title, slug FROM manual_articles WHERE id = %s AND deleted_at IS NULL",
(article_id,),
)
if not existing:
raise HTTPException(status_code=404, detail="Manual not found")
updates: List[str] = []
params: List[Any] = []
if payload.title is not None:
updates.append("title = %s")
params.append(payload.title.strip())
if payload.content is not None:
updates.append("content = %s")
params.append(payload.content)
if payload.summary is not None:
updates.append("summary = %s")
params.append(payload.summary)
if payload.module is not None:
updates.append("module = %s")
params.append(payload.module.strip().lower())
if payload.difficulty is not None:
updates.append("difficulty = %s")
params.append(payload.difficulty)
if payload.tags is not None:
updates.append("tags = %s::jsonb")
params.append(json.dumps(_normalize_tags(payload.tags), ensure_ascii=False))
if payload.slug is not None or payload.title is not None:
slug_source = payload.slug or payload.title or existing["slug"]
unique_slug = _next_unique_slug(slug_source, exclude_id=article_id)
updates.append("slug = %s")
params.append(unique_slug)
if updates:
params.append(article_id)
execute_update(
f"UPDATE manual_articles SET {', '.join(updates)} WHERE id = %s",
tuple(params),
)
if payload.steps is not None:
_replace_steps(article_id, payload.steps)
if payload.relations is not None:
_replace_relations(article_id, payload.relations)
article = _fetch_article_by_id(article_id)
if not article:
raise HTTPException(status_code=500, detail="Could not load updated manual")
return article
@router.delete("/manual/{article_id}")
async def delete_manual_article(article_id: str):
affected = execute_update(
"UPDATE manual_articles SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL",
(article_id,),
)
if affected == 0:
raise HTTPException(status_code=404, detail="Manual not found")
return {"success": True, "deleted_id": article_id}