Files
news_all_spo/api/route/admin_articles.py
2026-05-15 03:31:28 +05:00

234 lines
7.6 KiB
Python

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()}