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

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