import json from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, or_ 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, article_tag 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, "source_url": a.source_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), date_from: str | None = Query(None), date_to: str | None = Query(None), q: str | None = Query(None, max_length=200), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), ): cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}:{date_from or ''}:{date_to or ''}:{q 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)) if date_from: try: base = base.where(Article.published_at >= datetime.fromisoformat(date_from)) except ValueError: pass if date_to: try: base = base.where(Article.published_at <= datetime.fromisoformat(date_to)) except ValueError: pass if q: pattern = f"%{q}%" base = base.where( or_( Article.title.ilike(pattern), Article.excerpt.ilike(pattern), Article.content.ilike(pattern), Article.tags.any(Tag.name.ilike(pattern)), ) ) 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 if q else 120, 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", 300, json.dumps(result)) return result @router.get("/tags") async def list_tags( limit: int = Query(30, ge=1, le=100), db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), ): cache_key = f"news:tags:{limit}" cached = await redis.get(cache_key) if cached: return json.loads(cached) q = ( select(Tag, func.count(article_tag.c.article_id).label("cnt")) .join(article_tag, Tag.id == article_tag.c.tag_id) .join(Article, article_tag.c.article_id == Article.id) .where(Article.status == ArticleStatus.published) .group_by(Tag.id) .order_by(func.count(article_tag.c.article_id).desc()) .limit(limit) ) rows = (await db.execute(q)).all() result = [ {"id": str(r.Tag.id), "name": r.Tag.name, "slug": r.Tag.slug, "count": r.cnt} for r in rows ] await redis.setex(cache_key, 300, 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