test ferst
This commit is contained in:
0
api/route/__init__.py
Normal file
0
api/route/__init__.py
Normal file
224
api/route/admin_articles.py
Normal file
224
api/route/admin_articles.py
Normal file
@@ -0,0 +1,224 @@
|
||||
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
|
||||
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,
|
||||
"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),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
|
||||
if status:
|
||||
q = q.where(Article.status == status)
|
||||
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
|
||||
articles = (await db.execute(q.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,
|
||||
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.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()}
|
||||
62
api/route/admin_auth.py
Normal file
62
api/route/admin_auth.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from bd.database import get_db, AsyncSessionLocal
|
||||
from bd.models import Admin
|
||||
|
||||
router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXP_HOURS = 24
|
||||
|
||||
|
||||
def _make_token(username: str) -> str:
|
||||
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
|
||||
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
|
||||
|
||||
|
||||
async def ensure_default_admin():
|
||||
username = os.getenv("ADMIN_USERNAME", "admin")
|
||||
password = os.getenv("ADMIN_PASSWORD", "changeme")
|
||||
async with AsyncSessionLocal() as db:
|
||||
existing = (await db.execute(select(Admin))).first()
|
||||
if not existing:
|
||||
admin = Admin(username=username, password_hash=pwd_context.hash(password))
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
|
||||
return {"access_token": _make_token(admin.username), "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from route.deps import require_admin
|
||||
# handled with deps in main
|
||||
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
|
||||
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
|
||||
admin.password_hash = pwd_context.hash(data["new_password"])
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
78
api/route/admin_categories.py
Normal file
78
api/route/admin_categories.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from bd.database import get_db, get_redis
|
||||
from bd.models import Category
|
||||
from route.deps import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str
|
||||
slug: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_categories(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
|
||||
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
|
||||
return [{"id": str(c.id), "name": c.name, "slug": c.slug} for c in cats]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_category(
|
||||
data: CategoryIn,
|
||||
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.name)
|
||||
existing = (await db.execute(select(Category).where(Category.slug == slug))).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Категория с таким slug уже существует")
|
||||
cat = Category(name=data.name, slug=slug)
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
await redis.delete("news:categories")
|
||||
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
|
||||
|
||||
|
||||
@router.put("/{cat_id}")
|
||||
async def update_category(
|
||||
cat_id: str,
|
||||
data: CategoryIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
from slugify import slugify
|
||||
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
cat.name = data.name
|
||||
cat.slug = data.slug or slugify(data.name)
|
||||
await db.commit()
|
||||
await redis.delete("news:categories")
|
||||
return {"id": str(cat.id), "name": cat.name, "slug": cat.slug}
|
||||
|
||||
|
||||
@router.delete("/{cat_id}")
|
||||
async def delete_category(
|
||||
cat_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
cat = (await db.execute(select(Category).where(Category.id == uuid.UUID(cat_id)))).scalar_one_or_none()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
await db.delete(cat)
|
||||
await db.commit()
|
||||
await redis.delete("news:categories")
|
||||
return {"ok": True}
|
||||
138
api/route/admin_media.py
Normal file
138
api/route/admin_media.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import os
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from bd.database import get_db
|
||||
from bd.models import Media
|
||||
from route.deps import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
|
||||
|
||||
ALLOWED_IMAGE = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
ALLOWED_VIDEO = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"}
|
||||
MAX_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
|
||||
def get_minio() -> Minio:
|
||||
return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
|
||||
|
||||
|
||||
def ensure_bucket(client: Minio):
|
||||
try:
|
||||
if not client.bucket_exists(MINIO_BUCKET):
|
||||
client.make_bucket(MINIO_BUCKET)
|
||||
policy = f"""{{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{{
|
||||
"Effect":"Allow",
|
||||
"Principal":"*",
|
||||
"Action":["s3:GetObject"],
|
||||
"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]
|
||||
}}]
|
||||
}}"""
|
||||
client.set_bucket_policy(MINIO_BUCKET, policy)
|
||||
except S3Error:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in ALLOWED_IMAGE and content_type not in ALLOWED_VIDEO:
|
||||
raise HTTPException(status_code=400, detail=f"Тип файла не поддерживается: {content_type}")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Файл слишком большой (макс. 100 МБ)")
|
||||
|
||||
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
|
||||
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
|
||||
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
client = get_minio()
|
||||
ensure_bucket(client)
|
||||
client.put_object(
|
||||
MINIO_BUCKET,
|
||||
object_name,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||
|
||||
media = Media(
|
||||
filename=file.filename or object_name,
|
||||
url=public_url,
|
||||
media_type=media_type,
|
||||
size_bytes=len(data),
|
||||
)
|
||||
db.add(media)
|
||||
await db.commit()
|
||||
await db.refresh(media)
|
||||
|
||||
return {
|
||||
"id": str(media.id),
|
||||
"url": public_url,
|
||||
"filename": media.filename,
|
||||
"media_type": media_type,
|
||||
"size_bytes": media.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_media(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
media_type: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
q = select(Media)
|
||||
if media_type:
|
||||
q = q.where(Media.media_type == media_type)
|
||||
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
return [
|
||||
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||
for m in items
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{media_id}")
|
||||
async def delete_media(
|
||||
media_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
import uuid as uuid_lib
|
||||
media = (await db.execute(select(Media).where(Media.id == uuid_lib.UUID(media_id)))).scalar_one_or_none()
|
||||
if not media:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
object_name = media.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
|
||||
try:
|
||||
client = get_minio()
|
||||
client.remove_object(MINIO_BUCKET, object_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.delete(media)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
17
api/route/deps.py
Normal file
17
api/route/deps.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
|
||||
JWT_ALGO = "HS256"
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
|
||||
async def require_admin(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGO])
|
||||
return payload["sub"]
|
||||
except (JWTError, KeyError):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||
132
api/route/public.py
Normal file
132
api/route/public.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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
|
||||
|
||||
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,
|
||||
"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),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis),
|
||||
):
|
||||
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag 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))
|
||||
|
||||
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, 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", 60, 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
|
||||
Reference in New Issue
Block a user