feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations.
- Added preview functionality for markdown content.
- Created list view for recent manuals with edit and view options.
- Developed detail view for individual manuals displaying content, steps, and related guides.
- Established database schema for manual articles, steps, and relations with appropriate indexing.
- Seeded initial manual articles and steps for core functionalities.
- Normalized newline characters in existing manual content.
- Added additional manuals and steps for enhanced user guidance.
2026-04-05 21:48:59 +02:00
import logging
import json
import re
from typing import Any , Dict , List , Literal , Optional
2026-04-12 02:27:01 +02:00
from fastapi import BackgroundTasks , APIRouter , HTTPException , Query
feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations.
- Added preview functionality for markdown content.
- Created list view for recent manuals with edit and view options.
- Developed detail view for individual manuals displaying content, steps, and related guides.
- Established database schema for manual articles, steps, and relations with appropriate indexing.
- Seeded initial manual articles and steps for core functionalities.
- Normalized newline characters in existing manual content.
- Added additional manuals and steps for enhanced user guidance.
2026-04-05 21:48:59 +02:00
from pydantic import BaseModel , Field
2026-04-12 02:27:01 +02:00
from app . modules . manual . backend . cache import manual_cache
feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations.
- Added preview functionality for markdown content.
- Created list view for recent manuals with edit and view options.
- Developed detail view for individual manuals displaying content, steps, and related guides.
- Established database schema for manual articles, steps, and relations with appropriate indexing.
- Seeded initial manual articles and steps for core functionalities.
- Normalized newline characters in existing manual content.
- Added additional manuals and steps for enhanced user guidance.
2026-04-05 21:48:59 +02:00
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} " )
2026-04-12 02:27:01 +02:00
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
feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations.
- Added preview functionality for markdown content.
- Created list view for recent manuals with edit and view options.
- Developed detail view for individual manuals displaying content, steps, and related guides.
- Established database schema for manual articles, steps, and relations with appropriate indexing.
- Seeded initial manual articles and steps for core functionalities.
- Normalized newline characters in existing manual content.
- Added additional manuals and steps for enhanced user guidance.
2026-04-05 21:48:59 +02:00
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 }