import logging import html import re from typing import Any, List, Optional from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from app.core.database import execute_query, execute_query_single logger = logging.getLogger(__name__) router = APIRouter() templates = Jinja2Templates(directory="app") def _normalize_tag_list(value: Any) -> List[str]: if isinstance(value, list): return [str(v).strip() for v in value if str(v).strip()] return [] def _normalize_manual_text(value: Any) -> str: text = str(value or "") text = html.unescape(text) text = re.sub(r"(?i)", "\n", text) text = text.replace("\\n", "\n") return text @router.get("/manual", response_class=HTMLResponse) async def manual_index( request: Request, module: Optional[str] = Query(default=None), difficulty: Optional[str] = Query(default=None), tag: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None), ): where_parts = ["deleted_at IS NULL"] params: List[Any] = [] if module: where_parts.append("LOWER(module) = LOWER(%s)") params.append(module.strip()) if difficulty in {"beginner", "advanced"}: where_parts.append("difficulty = %s") params.append(difficulty) if search: needle = f"%{search.strip()}%" where_parts.append("(title ILIKE %s OR summary ILIKE %s OR content ILIKE %s)") params.extend([needle, needle, needle]) if tag: where_parts.append( "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(tags, '[]'::jsonb)) t(tag) WHERE LOWER(t.tag) = LOWER(%s))" ) params.append(tag.strip()) rows = execute_query( f""" SELECT id, title, slug, summary, module, difficulty, tags, use_count, updated_at FROM manual_articles WHERE {' AND '.join(where_parts)} ORDER BY use_count DESC, updated_at DESC LIMIT 300 """, tuple(params), ) or [] modules = execute_query( """ SELECT DISTINCT module FROM manual_articles WHERE deleted_at IS NULL ORDER BY module ASC """ ) or [] all_tags: List[str] = [] for row in rows: all_tags.extend(_normalize_tag_list(row.get("tags"))) unique_tags = sorted(list(set(all_tags))) return templates.TemplateResponse( "modules/manual/templates/list.html", { "request": request, "articles": rows, "available_modules": modules, "available_tags": unique_tags, "filters": { "module": module or "", "difficulty": difficulty or "", "tag": tag or "", "search": search or "", }, }, ) @router.get("/manual/admin", response_class=HTMLResponse) async def manual_admin(request: Request): articles = execute_query( """ SELECT id, title, slug, module, difficulty, use_count, updated_at FROM manual_articles WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 200 """ ) or [] return templates.TemplateResponse( "modules/manual/templates/admin.html", {"request": request, "articles": articles}, ) @router.get("/manual/{slug}", response_class=HTMLResponse) async def manual_detail(request: Request, slug: str): 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: return templates.TemplateResponse( "modules/manual/templates/detail.html", {"request": request, "article": None, "steps": [], "related": []}, status_code=404, ) execute_query( "UPDATE manual_articles SET use_count = COALESCE(use_count, 0) + 1 WHERE id = %s", (article["id"],), ) article["use_count"] = int(article.get("use_count") or 0) + 1 article["content_normalized"] = _normalize_manual_text(article.get("content")) article["summary_normalized"] = _normalize_manual_text(article.get("summary")) steps = execute_query( """ SELECT step_number, title, content, image_url, video_url FROM manual_steps WHERE manual_id = %s ORDER BY step_number ASC """, (article["id"],), ) or [] for step in steps: step["content_normalized"] = _normalize_manual_text(step.get("content")) related = execute_query( """ SELECT rm.slug, rm.title, rm.summary, rm.module FROM manual_relations mr JOIN manual_articles rm ON rm.id = mr.related_manual_id WHERE mr.manual_id = %s AND rm.deleted_at IS NULL ORDER BY rm.use_count DESC, rm.updated_at DESC LIMIT 12 """, (article["id"],), ) or [] return templates.TemplateResponse( "modules/manual/templates/detail.html", {"request": request, "article": article, "steps": steps, "related": related}, )