test ferst

This commit is contained in:
jze9
2026-05-13 19:05:07 +05:00
commit 0ac1c8a862
33 changed files with 3702 additions and 0 deletions

6
api/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
api/bd/__init__.py Normal file
View File

44
api/bd/database.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import asyncio
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/news")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=20, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session
async def wait_for_db():
for attempt in range(30):
try:
async with engine.connect() as conn:
await conn.execute(sqlalchemy.text("SELECT 1"))
return
except Exception:
if attempt == 29:
raise
await asyncio.sleep(2)
import redis.asyncio as aioredis
_redis: aioredis.Redis | None = None
async def get_redis() -> aioredis.Redis:
global _redis
if _redis is None:
_redis = aioredis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
return _redis

73
api/bd/models.py Normal file
View File

@@ -0,0 +1,73 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column, Integer, Enum as SAEnum
from sqlalchemy.orm import mapped_column, Mapped, relationship
from sqlalchemy.dialects.postgresql import UUID
import enum
from .database import Base
class ArticleStatus(str, enum.Enum):
draft = "draft"
published = "published"
article_tag = Table(
"article_tag",
Base.metadata,
Column("article_id", UUID(as_uuid=True), ForeignKey("articles.id", ondelete="CASCADE")),
Column("tag_id", UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE")),
)
class Category(Base):
__tablename__ = "categories"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
articles: Mapped[list["Article"]] = relationship(back_populates="category", lazy="noload")
class Tag(Base):
__tablename__ = "tags"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
class Article(Base):
__tablename__ = "articles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(500))
slug: Mapped[str] = mapped_column(String(500), unique=True, index=True)
content: Mapped[str] = mapped_column(Text, default="")
excerpt: Mapped[str] = mapped_column(String(1000), default="")
cover_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
font_family: Mapped[str] = mapped_column(String(100), default="Merriweather")
status: Mapped[ArticleStatus] = mapped_column(SAEnum(ArticleStatus), default=ArticleStatus.draft)
view_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
category: Mapped["Category | None"] = relationship(back_populates="articles", lazy="selectin")
tags: Mapped[list["Tag"]] = relationship(secondary=article_tag, lazy="selectin")
class Admin(Base):
__tablename__ = "admins"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(String(300))
class Media(Base):
__tablename__ = "media"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename: Mapped[str] = mapped_column(String(500))
url: Mapped[str] = mapped_column(String(1000))
media_type: Mapped[str] = mapped_column(String(20)) # image | video
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

82
api/main.py Normal file
View File

@@ -0,0 +1,82 @@
import os
import secrets
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.middleware.cors import CORSMiddleware
from bd.database import wait_for_db, engine, Base
from route.public import router as public_router
from route.admin_auth import router as auth_router, ensure_default_admin
from route.admin_articles import router as articles_router
from route.admin_categories import router as categories_router
from route.admin_media import router as media_router
DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
DOCS_PASS = os.getenv("DOCS_PASSWORD", "admin")
_basic = HTTPBasic()
def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
ok_u = secrets.compare_digest(creds.username.encode(), DOCS_USER.encode())
ok_p = secrets.compare_digest(creds.password.encode(), DOCS_PASS.encode())
if not (ok_u and ok_p):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Basic"})
return creds.username
@asynccontextmanager
async def lifespan(app: FastAPI):
await wait_for_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await ensure_default_admin()
yield
app = FastAPI(
title="News API",
version="1.0.0",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Docs protected by HTTP Basic Auth
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from fastapi.responses import JSONResponse
@app.get("/docs", include_in_schema=False)
async def docs(username: str = Depends(_verify_docs)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="News API Docs")
@app.get("/redoc", include_in_schema=False)
async def redoc(username: str = Depends(_verify_docs)):
return get_redoc_html(openapi_url="/openapi.json", title="News API Redoc")
@app.get("/openapi.json", include_in_schema=False)
async def openapi(username: str = Depends(_verify_docs)):
return JSONResponse(app.openapi())
app.include_router(public_router, prefix="/news", tags=["public"])
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
app.include_router(categories_router, prefix="/admin/categories", tags=["admin-categories"])
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)

12
api/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
redis[asyncio]==5.2.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==3.2.2
python-multipart==0.0.12
minio==7.2.11
python-slugify==8.0.4
pillow==11.0.0

0
api/route/__init__.py Normal file
View File

224
api/route/admin_articles.py Normal file
View 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
View 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}

View 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
View 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
View 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
View 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