import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel 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 from route.deps import require_admin router = APIRouter() class ArticleIn(BaseModel): title: str slug: Optional[str] = None content: str = "" excerpt: str = "" cover_url: Optional[str] = None source_url: Optional[str] = None font_family: str = "Merriweather" category_id: Optional[str] = None tag_names: list[str] = [] status: ArticleStatus = ArticleStatus.draft def _article_dict(a: Article) -> dict: return { "id": str(a.id), "title": a.title, "slug": a.slug, "content": a.content, "excerpt": a.excerpt, "cover_url": a.cover_url, "source_url": a.source_url, "font_family": a.font_family, "status": a.status.value, "view_count": a.view_count, "created_at": a.created_at.isoformat(), "updated_at": a.updated_at.isoformat(), "published_at": a.published_at.isoformat() if a.published_at else None, "category": {"id": str(a.category.id), "name": a.category.name, "slug": a.category.slug} if a.category else None, "tags": [{"id": str(t.id), "name": t.name, "slug": t.slug} for t in a.tags], } async def _resolve_tags(tag_names: list[str], db: AsyncSession) -> list[Tag]: from slugify import slugify tags = [] for name in tag_names: name = name.strip() if not name: continue slug = slugify(name) tag = (await db.execute(select(Tag).where(Tag.slug == slug))).scalar_one_or_none() if not tag: tag = Tag(name=name, slug=slug) db.add(tag) tags.append(tag) return tags async def _invalidate_cache(redis: aioredis.Redis): keys = await redis.keys("news:*") if keys: await redis.delete(*keys) @router.get("") async def list_articles( offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), status: Optional[ArticleStatus] = Query(None), q: Optional[str] = Query(None, max_length=200), db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): from sqlalchemy import or_ stmt = select(Article).options(selectinload(Article.category), selectinload(Article.tags)) if status: stmt = stmt.where(Article.status == status) if q: pattern = f"%{q}%" stmt = stmt.where(or_(Article.title.ilike(pattern), Article.excerpt.ilike(pattern))) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() articles = (await db.execute(stmt.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all() return { "total": total, "offset": offset, "limit": limit, "has_more": (offset + limit) < total, "items": [_article_dict(a) for a in articles], } @router.post("") async def create_article( data: ArticleIn, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), _: str = Depends(require_admin), ): from slugify import slugify slug = data.slug or slugify(data.title) existing = (await db.execute(select(Article).where(Article.slug == slug))).scalar_one_or_none() if existing: slug = f"{slug}-{uuid.uuid4().hex[:6]}" cat = None if data.category_id: cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none() tags = await _resolve_tags(data.tag_names, db) article = Article( title=data.title, slug=slug, content=data.content, excerpt=data.excerpt, cover_url=data.cover_url, source_url=data.source_url, font_family=data.font_family, status=data.status, category=cat, tags=tags, published_at=datetime.utcnow() if data.status == ArticleStatus.published else None, ) db.add(article) await db.commit() await db.refresh(article) await _invalidate_cache(redis) return _article_dict(article) @router.get("/{article_id}") async def get_article( article_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id)) article = (await db.execute(q)).scalar_one_or_none() if not article: raise HTTPException(status_code=404, detail="Not found") return _article_dict(article) @router.put("/{article_id}") async def update_article( article_id: str, data: ArticleIn, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), _: str = Depends(require_admin), ): from slugify import slugify q = select(Article).options(selectinload(Article.category), selectinload(Article.tags)).where(Article.id == uuid.UUID(article_id)) article = (await db.execute(q)).scalar_one_or_none() if not article: raise HTTPException(status_code=404, detail="Not found") slug = data.slug or slugify(data.title) conflict = (await db.execute( select(Article).where(Article.slug == slug, Article.id != article.id) )).scalar_one_or_none() if conflict: slug = f"{slug}-{uuid.uuid4().hex[:6]}" was_draft = article.status == ArticleStatus.draft cat = None if data.category_id: cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(data.category_id)))).scalar_one_or_none() tags = await _resolve_tags(data.tag_names, db) article.title = data.title article.slug = slug article.content = data.content article.excerpt = data.excerpt article.cover_url = data.cover_url article.source_url = data.source_url article.font_family = data.font_family article.status = data.status article.category = cat article.tags = tags article.updated_at = datetime.utcnow() if was_draft and data.status == ArticleStatus.published and not article.published_at: article.published_at = datetime.utcnow() await db.commit() await db.refresh(article) await _invalidate_cache(redis) return _article_dict(article) @router.delete("/{article_id}") async def delete_article( article_id: str, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), _: str = Depends(require_admin), ): article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none() if not article: raise HTTPException(status_code=404, detail="Not found") await db.delete(article) await db.commit() await _invalidate_cache(redis) return {"ok": True} @router.post("/{article_id}/publish") async def publish_article( article_id: str, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), _: str = Depends(require_admin), ): article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none() if not article: raise HTTPException(status_code=404, detail="Not found") article.status = ArticleStatus.published if not article.published_at: article.published_at = datetime.utcnow() article.updated_at = datetime.utcnow() await db.commit() await _invalidate_cache(redis) return {"ok": True, "published_at": article.published_at.isoformat()}