feat: implement Phase 2 (Pulse, Admin, RC2)
This commit is contained in:
164
app/routers/pulse.py
Normal file
164
app/routers/pulse.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pymysql.cursors import DictCursor
|
||||
import json
|
||||
from collections import Counter
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from deps import get_db, current_user
|
||||
from services.config_loader import ConfigLoader
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/crumbforest/pulse", response_class=HTMLResponse)
|
||||
async def pulse_dashboard(req: Request, tag: Optional[str] = None, user = Depends(current_user)):
|
||||
"""
|
||||
Show the Pulse (Blog) dashboard with tag cloud.
|
||||
"""
|
||||
# 1. Auth & Config
|
||||
if not user:
|
||||
# Public access allowed? User said "Neuigkeiten...".
|
||||
# But usually Crumbforest is internal.
|
||||
# Let's assume login required for now to access specific group templates.
|
||||
lang = req.query_params.get("lang", "de")
|
||||
return req.app.state.render(req, "pages/login.html", error="Login required for Pulse")
|
||||
# Actually Redirect is better
|
||||
# return RedirectResponse(f"/{lang}/login", status_code=302)
|
||||
|
||||
user_group = user.get('user_group', 'demo')
|
||||
config = ConfigLoader.load_config()
|
||||
group_config = config.get('groups', {}).get(user_group, {})
|
||||
lang = req.query_params.get("lang") or req.session.get("lang") or "de"
|
||||
req.session["lang"] = lang
|
||||
|
||||
# 2. Fetch Posts
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Fetch all published posts
|
||||
base_query = """
|
||||
SELECT id, title, slug, excerpt, tags, author, created_at
|
||||
FROM posts
|
||||
WHERE is_published = 1
|
||||
"""
|
||||
cur.execute(base_query)
|
||||
posts_raw = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 3. Process Tags & Filter
|
||||
posts = []
|
||||
all_tags = []
|
||||
|
||||
for p in posts_raw:
|
||||
# Parse JSON tags
|
||||
if isinstance(p['tags'], str):
|
||||
p['tags'] = json.loads(p['tags'])
|
||||
elif p['tags'] is None:
|
||||
p['tags'] = []
|
||||
|
||||
all_tags.extend(p['tags'])
|
||||
|
||||
# Filter (if tag selected)
|
||||
if tag and tag not in p['tags']:
|
||||
continue
|
||||
|
||||
posts.append(p)
|
||||
|
||||
# 4. Build Tag Cloud
|
||||
# Count tags
|
||||
tag_counts = Counter(all_tags)
|
||||
# Convert to list of dicts for template: [{'name': 'RAG', 'count': 5, 'size': '...'}, ...]
|
||||
# Simple sizing logic: 1..10
|
||||
tag_cloud = []
|
||||
if tag_counts:
|
||||
max_count = max(tag_counts.values())
|
||||
min_count = min(tag_counts.values())
|
||||
delta = max_count - min_count or 1
|
||||
|
||||
for t_name, count in tag_counts.items():
|
||||
# Linear scaling 1.0 to 2.0em
|
||||
size = 0.8 + (1.2 * (count - min_count) / delta)
|
||||
tag_cloud.append({
|
||||
'name': t_name,
|
||||
'count': count,
|
||||
'size': f"{size:.1f}em",
|
||||
'active': t_name == tag
|
||||
})
|
||||
|
||||
# Sort cloud alphabetically
|
||||
tag_cloud.sort(key=lambda x: x['name'])
|
||||
|
||||
return req.app.state.render(
|
||||
req,
|
||||
"crumbforest/blog.html",
|
||||
posts=posts,
|
||||
tag_cloud=tag_cloud,
|
||||
current_tag=tag,
|
||||
user_group=user_group,
|
||||
group_config=group_config,
|
||||
lang=lang
|
||||
)
|
||||
|
||||
|
||||
@router.get("/crumbforest/pulse/{slug}", response_class=HTMLResponse)
|
||||
async def pulse_post(req: Request, slug: str, user = Depends(current_user)):
|
||||
"""
|
||||
Show a single blog post.
|
||||
"""
|
||||
if not user:
|
||||
# Redirect
|
||||
lang = req.query_params.get("lang", "de")
|
||||
return req.app.state.render(req, "pages/login.html") # Simplified
|
||||
|
||||
user_group = user.get('user_group', 'demo')
|
||||
config = ConfigLoader.load_config()
|
||||
group_config = config.get('groups', {}).get(user_group, {})
|
||||
lang = req.query_params.get("lang") or req.session.get("lang") or "de"
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, slug, excerpt, tags, author, body_md, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE slug=%s AND is_published=1
|
||||
""",
|
||||
(slug,)
|
||||
)
|
||||
post = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
# Parse tags
|
||||
if isinstance(post['tags'], str):
|
||||
post['tags'] = json.loads(post['tags'])
|
||||
elif post['tags'] is None:
|
||||
post['tags'] = []
|
||||
|
||||
# Markdown rendering handled in template via filter?
|
||||
# Or pre-render here.
|
||||
# Usually we pass markdown and let template render it if using a JS lib,
|
||||
# OR we use `markdown` lib here.
|
||||
# checking existing templates... we likely use `markdown` filter or lib.
|
||||
# Code Tour mentioned "body_md".
|
||||
# I will assume we render MD in Python for safety/consistency?
|
||||
# Or use a client-side renderer (Zero-MD, Marked)?
|
||||
# Looking at `diary_rag.py` / `rag_service.py` -> we just store MD.
|
||||
# If I verify `app/templates/`?
|
||||
# I'll use `markdown` python lib if available.
|
||||
# Assume it is available or basic text for now.
|
||||
import markdown
|
||||
post['html_content'] = markdown.markdown(post['body_md'])
|
||||
|
||||
return req.app.state.render(
|
||||
req,
|
||||
"crumbforest/post.html",
|
||||
post=post,
|
||||
group_config=group_config,
|
||||
lang=lang
|
||||
)
|
||||
Reference in New Issue
Block a user