This commit is contained in:
jze9
2026-05-15 20:12:44 +05:00
parent 2335497226
commit dd7d8a7ccf
21 changed files with 672 additions and 121 deletions

View File

@@ -1,3 +1,4 @@
import os
import uuid
from datetime import datetime
from typing import Optional
@@ -9,9 +10,11 @@ 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 bd.models import Article, Category, Tag, ArticleStatus, Media
from route.deps import require_admin
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
router = APIRouter()
@@ -208,6 +211,26 @@ async def delete_article(
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")
# Удаляем все связанные медиафайлы из MinIO и БД
media_items = (await db.execute(
select(Media).where(Media.article_id == article.id)
)).scalars().all()
if media_items:
from route.admin_media import get_minio
try:
client = get_minio()
for m in media_items:
obj = m.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
try:
client.remove_object(MINIO_BUCKET, obj)
except Exception:
pass
await db.delete(m)
except Exception:
pass
await db.delete(article)
await db.commit()
await _invalidate_cache(redis)

View File

@@ -7,9 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from minio import Minio
from minio.error import S3Error
from slugify import slugify
from bd.database import get_db
from bd.models import Media
from bd.models import Media, Article
from route.deps import require_admin
router = APIRouter()
@@ -50,6 +51,7 @@ def ensure_bucket(client: Minio):
@router.post("/upload")
async def upload_media(
file: UploadFile = File(...),
article_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
@@ -63,7 +65,27 @@ async def upload_media(
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}"
# Определяем путь в MinIO
art_uuid = None
if article_id:
try:
art_uuid = uuid.UUID(article_id)
# Проверяем существование статьи
art = (await db.execute(select(Article).where(Article.id == art_uuid))).scalar_one_or_none()
if not art:
art_uuid = None
except ValueError:
art_uuid = None
if art_uuid:
folder = "images" if media_type == "image" else "videos"
art_slug = slugify(art.title) if art and art.title else str(art_uuid)[:8]
art_dir = f"{art_slug}-{str(art_uuid)[:8]}"
base_name = f"{art_slug}-{uuid.uuid4().hex[:6]}.{ext}"
object_name = f"articles/{art_dir}/{folder}/{base_name}"
else:
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
client = get_minio()
ensure_bucket(client)
@@ -78,10 +100,11 @@ async def upload_media(
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
media = Media(
filename=file.filename or object_name,
filename=object_name.split("/")[-1],
url=public_url,
media_type=media_type,
size_bytes=len(data),
article_id=art_uuid,
)
db.add(media)
await db.commit()
@@ -101,20 +124,40 @@ async def list_media(
offset: int = Query(0, ge=0),
limit: int = Query(40, ge=1, le=200),
media_type: str | None = Query(None),
q: str | None = Query(None, max_length=200),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
from sqlalchemy import func
from sqlalchemy.orm import outerjoin
base = select(Media)
if media_type:
base = base.where(Media.media_type == media_type)
if q:
base = base.where(Media.filename.ilike(f"%{q}%"))
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
# Fetch article titles for media that have an article_id
art_ids = {m.article_id for m in items if m.article_id}
art_titles: dict = {}
if art_ids:
arts = (await db.execute(select(Article.id, Article.title).where(Article.id.in_(art_ids)))).all()
art_titles = {a.id: a.title for a in arts}
return {
"total": total,
"items": [
{"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()}
{
"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(),
"article_id": str(m.article_id) if m.article_id else None,
"article_title": art_titles.get(m.article_id) if m.article_id else None,
}
for m in items
],
}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException # BackgroundTasks используется в history endpoints
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -88,9 +88,9 @@ async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: s
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
@router.post("/import")
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
background_tasks.add_task(run_import)
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
async def vk_manual_import(_: str = Depends(require_admin)):
result = await run_import()
return result
@router.post("/import/history")

View File

@@ -75,13 +75,15 @@ async def list_news(
if date_from:
try:
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
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:
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
dt = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
base = base.where(Article.published_at <= dt.replace(tzinfo=None))
except ValueError:
pass