Files
news_all_spo/api/route/public.py
jze9 3161470a7d feat: мультивыбор колледжей в фильтре публичной ленты
Параметр category в /news принимает несколько slug'ов через запятую
(category_id IN ...), обратно совместим с одиночным slug. FilterBar:
чипы колледжей стали переключателями (можно отметить несколько),
filters.category теперь массив.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:47:19 +05:00

196 lines
6.1 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:
# Поддержка нескольких колледжей: category="slug1,slug2,..."
slugs = [s for s in (part.strip() for part in category.split(",")) if s]
cat_ids = (
await db.execute(select(Category.id).where(Category.slug.in_(slugs)))
).scalars().all()
if cat_ids:
base = base.where(Article.category_id.in_(cat_ids))
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