192 lines
5.9 KiB
Python
192 lines
5.9 KiB
Python
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:
|
|
dt = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
|
|
base = base.where(Article.published_at >= dt.replace(tzinfo=None))
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_to:
|
|
try:
|
|
dt = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
|
|
base = base.where(Article.published_at <= dt.replace(tzinfo=None))
|
|
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
|