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}