257 lines
8.3 KiB
Python
257 lines
8.3 KiB
Python
import os
|
|
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, Media
|
|
from route.deps import require_admin
|
|
|
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
|
|
|
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")
|
|
|
|
# Удаляем все связанные медиафайлы из MinIO и БД
|
|
media_items = (await db.execute(
|
|
select(Media).where(Media.article_id == article.id)
|
|
)).scalars().all()
|
|
|
|
if media_items:
|
|
from route.admin_media import get_minio
|
|
try:
|
|
client = get_minio()
|
|
for m in media_items:
|
|
obj = m.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
|
|
try:
|
|
client.remove_object(MINIO_BUCKET, obj)
|
|
except Exception:
|
|
pass
|
|
await db.delete(m)
|
|
except Exception:
|
|
pass
|
|
|
|
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()}
|