import json from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from sqlalchemy.orm import selectinload import redis.asyncio as aioredis from bd.database import get_db, get_redis from bd.models import Article, Category, Tag, ArticleStatus router = APIRouter() def _cat_dict(c: Category) -> dict: return {"id": str(c.id), "name": c.name, "slug": c.slug} def _article_card(a: Article) -> dict: return { "id": str(a.id), "title": a.title, "slug": a.slug, "excerpt": a.excerpt, "cover_url": a.cover_url, "published_at": a.published_at.isoformat() if a.published_at else None, "view_count": a.view_count, "category": _cat_dict(a.category) if a.category else None, "tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags], } def _article_full(a: Article) -> dict: d = _article_card(a) d["content"] = a.content d["font_family"] = a.font_family return d @router.get("") async def list_news( offset: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), category: str | None = Query(None), tag: str | None = Query(None), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), ): cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}" cached = await redis.get(cache_key) if cached: return json.loads(cached) base = ( select(Article) .options(selectinload(Article.category), selectinload(Article.tags)) .where(Article.status == ArticleStatus.published) ) if category: cat = (await db.execute(select(Category).where(Category.slug == category))).scalar_one_or_none() if cat: base = base.where(Article.category_id == cat.id) else: return {"total": 0, "offset": offset, "limit": limit, "has_more": False, "items": []} if tag: t = (await db.execute(select(Tag).where(Tag.slug == tag))).scalar_one_or_none() if t: base = base.where(Article.tags.any(Tag.id == t.id)) count_q = select(func.count()).select_from(base.subquery()) total: int = (await db.execute(count_q)).scalar_one() articles = ( await db.execute(base.order_by(Article.published_at.desc()).offset(offset).limit(limit)) ).scalars().all() result = { "total": total, "offset": offset, "limit": limit, "has_more": (offset + limit) < total, "items": [_article_card(a) for a in articles], } await redis.setex(cache_key, 30, json.dumps(result, default=str)) return result @router.get("/categories") async def list_categories( db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), ): cached = await redis.get("news:categories") if cached: return json.loads(cached) cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all() result = [_cat_dict(c) for c in cats] await redis.setex("news:categories", 60, json.dumps(result)) return result @router.get("/{slug}") async def get_article( slug: str, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), ): cache_key = f"news:article:{slug}" cached = await redis.get(cache_key) q = ( select(Article) .options(selectinload(Article.category), selectinload(Article.tags)) .where(Article.slug == slug, Article.status == ArticleStatus.published) ) article = (await db.execute(q)).scalar_one_or_none() if not article: raise HTTPException(status_code=404, detail="Article not found") article.view_count += 1 await db.commit() if cached: data = json.loads(cached) data["view_count"] = article.view_count return data await db.refresh(article) result = _article_full(article) await redis.setex(cache_key, 60, json.dumps(result, default=str)) return result