- Implemented a new bottom bar feature in `bottom-bar.js` that fetches and displays various notifications and statuses in real-time. - Added functions for handling visibility, state updates, and user interactions within the bottom bar. - Introduced WebSocket connection for real-time updates and fallback polling mechanism. - Created a manual testing script `test_manual.py` to validate API endpoints for the manual module. - Included tests for various paths to ensure expected responses from the server.
422 lines
14 KiB
Python
422 lines
14 KiB
Python
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}
|