initial commit (backend)
This commit is contained in:
+725
@@ -0,0 +1,725 @@
|
||||
"""
|
||||
Feed Router — посты для карусели (новости / партнёры / контакты / приложения).
|
||||
|
||||
Публичные эндпоинты:
|
||||
- GET /feed/{section}?lang={lang} — лента постов с локализацией
|
||||
- GET /feed/post/{id}?lang={lang} — один пост
|
||||
|
||||
Админские эндпоинты:
|
||||
- POST /admin/feed/post — создать пост + автоперевод на 4 языка
|
||||
- PUT /admin/feed/post/{id} — редактировать (опционально перевести заново)
|
||||
- POST /admin/feed/post/{id}/delete — удалить (вложения удалит CASCADE)
|
||||
- POST /admin/feed/upload-image — загрузить картинку (multipart, до 3 на пост)
|
||||
- POST /admin/feed/translate — перевести title+body вручную (без сохранения)
|
||||
|
||||
Логика локализации:
|
||||
- В БД хранятся 4 версии поста (title_pl/ru/uk/en, body_pl/ru/uk/en).
|
||||
- Юзер получает только свою версию.
|
||||
- Fallback: _{lang} → _en → _pl.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, HTTPException, UploadFile, File, Form
|
||||
from auth import require_auth
|
||||
from database import get_pool
|
||||
from config import (
|
||||
TOGETHER_API_KEY,
|
||||
ADMIN_IDS,
|
||||
FEED_SECTIONS,
|
||||
FEED_TARGET_LANGUAGES,
|
||||
MAX_FEED_ATTACHMENTS,
|
||||
MAX_FEED_IMAGE_SIZE_BYTES,
|
||||
FEED_ALLOWED_IMAGE_TYPES,
|
||||
UPLOADS_DIR,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["feed"])
|
||||
|
||||
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
|
||||
TRANSLATE_MODEL = "MiniMaxAI/MiniMax-M3"
|
||||
|
||||
# 🔧 ФИКС: бэк отдаёт полный URL картинки, чтобы фронт не гадал,
|
||||
# куда склеивать относительный путь /uploads/feed/...
|
||||
API_PUBLIC_URL = "https://api.filwordspl.com"
|
||||
|
||||
|
||||
# ============ HELPERS ============
|
||||
|
||||
def _require_admin(telegram_id: int) -> None:
|
||||
"""Проверка, что пользователь — админ. Бросает 403 если нет."""
|
||||
if telegram_id not in ADMIN_IDS:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
|
||||
def _localized_field(row: dict, field: str, lang: str) -> str:
|
||||
"""
|
||||
Достаёт локализованное поле из строки БД.
|
||||
Fallback: _{lang} → _en → _pl → ''.
|
||||
"""
|
||||
if not row:
|
||||
return ""
|
||||
return (
|
||||
row.get(f"{field}_{lang}")
|
||||
or row.get(f"{field}_en")
|
||||
or row.get(f"{field}_pl")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _serialize_post(row: dict, attachments: list, lang: str) -> dict:
|
||||
"""
|
||||
Собирает финальный объект поста для фронта:
|
||||
локализованный title/body, ссылки, метаданные, картинки.
|
||||
"""
|
||||
return {
|
||||
"id": row["id"],
|
||||
"section": row["section"],
|
||||
"title": _localized_field(row, "title", lang),
|
||||
"body": _localized_field(row, "body", lang),
|
||||
"images": [
|
||||
f"{API_PUBLIC_URL}{a['file_path']}" if a["file_path"].startswith("/") else a["file_path"]
|
||||
for a in attachments
|
||||
],
|
||||
"linkUrl": row.get("link_url"),
|
||||
"telegramUrl": row.get("telegram_url"),
|
||||
"createdAt": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None,
|
||||
"expiresAt": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||
"isPinned": bool(row.get("is_pinned")),
|
||||
}
|
||||
|
||||
|
||||
# ============ ПУБЛИЧНЫЕ ЭНДПОИНТЫ ============
|
||||
|
||||
@router.get("/feed/{section}")
|
||||
async def get_feed_section(request: Request, section: str, lang: Optional[str] = None):
|
||||
"""
|
||||
Возвращает ленту постов для секции (news / partners / contacts / apps).
|
||||
|
||||
- Закреплённые посты — сверху.
|
||||
- Остальные — по created_at DESC.
|
||||
- Истёкшие (expires_at < NOW()) — НЕ отдаём.
|
||||
- Опубликованные (is_published = TRUE) — только они.
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
|
||||
if section not in FEED_SECTIONS:
|
||||
raise HTTPException(400, f"Invalid section: {section}")
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Если lang не передан — берём из профиля юзера
|
||||
if not lang or lang not in FEED_TARGET_LANGUAGES:
|
||||
lang = await conn.fetchval(
|
||||
"SELECT app_language FROM users WHERE telegram_id = $1",
|
||||
telegram_id,
|
||||
) or "pl"
|
||||
if lang not in FEED_TARGET_LANGUAGES:
|
||||
lang = "pl"
|
||||
|
||||
# Тянем посты: закреплённые сверху, потом по дате
|
||||
rows = await conn.fetch("""
|
||||
SELECT *
|
||||
FROM feed_posts
|
||||
WHERE section = $1
|
||||
AND is_published = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY is_pinned DESC, created_at DESC
|
||||
""", section)
|
||||
|
||||
if not rows:
|
||||
return {"posts": [], "section": section, "lang": lang}
|
||||
|
||||
# Одним запросом тянем все вложения для всех постов (без N+1)
|
||||
post_ids = [r["id"] for r in rows]
|
||||
att_rows = await conn.fetch("""
|
||||
SELECT post_id, file_path, order_number
|
||||
FROM feed_attachments
|
||||
WHERE post_id = ANY($1::bigint[])
|
||||
ORDER BY post_id, order_number ASC
|
||||
""", post_ids)
|
||||
|
||||
# Группируем вложения по post_id
|
||||
att_by_post: dict[int, list] = {}
|
||||
for a in att_rows:
|
||||
att_by_post.setdefault(a["post_id"], []).append(dict(a))
|
||||
|
||||
posts = [
|
||||
_serialize_post(dict(r), att_by_post.get(r["id"], []), lang)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {"posts": posts, "section": section, "lang": lang}
|
||||
|
||||
|
||||
@router.get("/feed/post/{post_id}")
|
||||
async def get_feed_post(request: Request, post_id: int, lang: Optional[str] = None):
|
||||
"""Возвращает один пост по ID (с локализацией)."""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if not lang or lang not in FEED_TARGET_LANGUAGES:
|
||||
lang = await conn.fetchval(
|
||||
"SELECT app_language FROM users WHERE telegram_id = $1",
|
||||
telegram_id,
|
||||
) or "pl"
|
||||
if lang not in FEED_TARGET_LANGUAGES:
|
||||
lang = "pl"
|
||||
|
||||
row = await conn.fetchrow("""
|
||||
SELECT * FROM feed_posts WHERE id = $1 AND is_published = TRUE
|
||||
""", post_id)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Post not found")
|
||||
|
||||
att_rows = await conn.fetch("""
|
||||
SELECT file_path, order_number
|
||||
FROM feed_attachments
|
||||
WHERE post_id = $1
|
||||
ORDER BY order_number ASC
|
||||
""", post_id)
|
||||
|
||||
return _serialize_post(dict(row), [dict(a) for a in att_rows], lang)
|
||||
|
||||
|
||||
# ============ AI-ПЕРЕВОД ============
|
||||
|
||||
async def translate_to_all_languages(
|
||||
title: str,
|
||||
body: str,
|
||||
source_lang: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Переводит title и body с source_lang на все 4 языка (PL/RU/UK/EN).
|
||||
|
||||
Возвращает dict с 8 полями:
|
||||
title_pl, title_ru, title_uk, title_en,
|
||||
body_pl, body_ru, body_uk, body_en.
|
||||
|
||||
Если AI упал или вернул невалидный JSON — возвращает пустой dict.
|
||||
Caller должен решить, использовать ли fallback (source_lang для всех языков).
|
||||
"""
|
||||
if not TOGETHER_API_KEY:
|
||||
logger.error("TOGETHER_API_KEY not configured — cannot translate")
|
||||
return {}
|
||||
|
||||
if source_lang not in FEED_TARGET_LANGUAGES:
|
||||
logger.error(f"Invalid source_lang: {source_lang}")
|
||||
return {}
|
||||
|
||||
system_prompt = f"""Ты — переводчик. Переведи title и body на 4 языка: PL, RU, UK, EN.
|
||||
Язык оригинала: {source_lang}.
|
||||
|
||||
Верни СТРОГО JSON без markdown и без пояснений:
|
||||
{{
|
||||
"title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...",
|
||||
"body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..."
|
||||
}}
|
||||
|
||||
ВАЖНО:
|
||||
- Если source_lang = ru, то title_ru и body_ru оставь БЕЗ изменений.
|
||||
- Если source_lang = pl, то title_pl и body_pl оставь БЕЗ изменений.
|
||||
- Если source_lang = uk, то title_uk и body_uk оставь БЕЗ изменений.
|
||||
- Если source_lang = en, то title_en и body_en оставь БЕЗ изменений.
|
||||
- Сохраняй переносы строк внутри body (используй \\n в JSON).
|
||||
- Не добавляй никаких полей кроме указанных 8.
|
||||
- Если title пустой — оставь все title_* пустыми строками."""
|
||||
|
||||
user_prompt = f"title: {title}\nbody: {body}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
response = await client.post(
|
||||
TOGETHER_API_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {TOGETHER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": TRANSLATE_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.3,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Translate API error: {response.status_code}, body={response.text[:200]}"
|
||||
)
|
||||
return {}
|
||||
|
||||
data = response.json()
|
||||
raw = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Если AI обернул в ```json ... ``` — снимаем обёртку
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw)
|
||||
|
||||
# Иногда AI добавляет текст до/после JSON — вырезаем первый {...}
|
||||
json_match = re.search(r'\{.*\}', raw, re.DOTALL)
|
||||
if not json_match:
|
||||
logger.error(f"AI response has no JSON: {raw[:200]}")
|
||||
return {}
|
||||
|
||||
parsed = json.loads(json_match.group(0))
|
||||
|
||||
# Валидация: все 8 полей должны быть строками
|
||||
expected_keys = [
|
||||
"title_pl", "title_ru", "title_uk", "title_en",
|
||||
"body_pl", "body_ru", "body_uk", "body_en",
|
||||
]
|
||||
result = {}
|
||||
for key in expected_keys:
|
||||
value = parsed.get(key, "")
|
||||
result[key] = str(value) if value is not None else ""
|
||||
|
||||
# Гарантия: source_lang остаётся без изменений
|
||||
result[f"title_{source_lang}"] = title
|
||||
result[f"body_{source_lang}"] = body
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse AI JSON: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Translate error: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
# ============ АДМИНСКИЕ ЭНДПОИНТЫ ============
|
||||
|
||||
@router.post("/admin/feed/post")
|
||||
async def admin_create_feed_post(request: Request):
|
||||
"""
|
||||
Создать feed-пост.
|
||||
|
||||
Тело запроса (JSON):
|
||||
{
|
||||
"section": "news" | "partners" | "contacts" | "apps",
|
||||
"source_language": "ru" | "pl" | "uk" | "en",
|
||||
"title": "Заголовок на source_language",
|
||||
"body": "Текст поста на source_language",
|
||||
"link_url": "https://..." (опционально),
|
||||
"telegram_url": "https://t.me/..." (опционально),
|
||||
"is_pinned": false (опционально),
|
||||
"expires_at": "2026-10-15T23:59:59Z" (опционально, для partners)
|
||||
}
|
||||
|
||||
Backend автоматически переведёт title/body на 4 языка через MiniMax-M3.
|
||||
Если перевод упал — сохраняем source_language для всех языков (fallback).
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
_require_admin(telegram_id)
|
||||
|
||||
body = await request.json()
|
||||
|
||||
section = body.get("section", "").strip()
|
||||
source_language = body.get("source_language", "ru").strip().lower()
|
||||
title = (body.get("title") or "").strip()
|
||||
post_body = (body.get("body") or "").strip()
|
||||
link_url = (body.get("link_url") or "").strip() or None
|
||||
telegram_url = (body.get("telegram_url") or "").strip() or None
|
||||
is_pinned = bool(body.get("is_pinned", False))
|
||||
expires_at_raw = body.get("expires_at")
|
||||
|
||||
# Валидация
|
||||
if section not in FEED_SECTIONS:
|
||||
raise HTTPException(400, f"Invalid section. Allowed: {sorted(FEED_SECTIONS)}")
|
||||
|
||||
if source_language not in FEED_TARGET_LANGUAGES:
|
||||
raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}")
|
||||
|
||||
if not title and not post_body:
|
||||
raise HTTPException(400, "Either title or body is required")
|
||||
|
||||
if len(title) > 200:
|
||||
raise HTTPException(400, "Title too long (max 200 characters)")
|
||||
|
||||
if len(post_body) > 5000:
|
||||
raise HTTPException(400, "Body too long (max 5000 characters)")
|
||||
|
||||
# Парсим expires_at если передан
|
||||
expires_at = None
|
||||
if expires_at_raw:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
# Поддерживаем ISO 8601 с 'Z' или без
|
||||
normalized = expires_at_raw.replace("Z", "+00:00")
|
||||
expires_at = datetime.fromisoformat(normalized)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
raise HTTPException(400, "Invalid expires_at format (use ISO 8601)")
|
||||
|
||||
# Переводим на 4 языка
|
||||
logger.info(f"Translating feed post from {source_language} → PL/RU/UK/EN")
|
||||
translations = await translate_to_all_languages(title, post_body, source_language)
|
||||
|
||||
# Fallback: если перевод не удался — копируем source для всех языков
|
||||
if not translations:
|
||||
logger.warning("Translation failed — falling back to source_language for all")
|
||||
translations = {}
|
||||
for lang in FEED_TARGET_LANGUAGES:
|
||||
translations[f"title_{lang}"] = title
|
||||
translations[f"body_{lang}"] = post_body
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO feed_posts (
|
||||
section, source_language,
|
||||
title_pl, title_ru, title_uk, title_en,
|
||||
body_pl, body_ru, body_uk, body_en,
|
||||
link_url, telegram_url,
|
||||
author_id, is_pinned, is_published, expires_at
|
||||
) VALUES (
|
||||
$1, $2,
|
||||
$3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12,
|
||||
$13, $14, TRUE, $15
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
section, source_language,
|
||||
translations["title_pl"], translations["title_ru"],
|
||||
translations["title_uk"], translations["title_en"],
|
||||
translations["body_pl"], translations["body_ru"],
|
||||
translations["body_uk"], translations["body_en"],
|
||||
link_url, telegram_url,
|
||||
telegram_id, is_pinned, expires_at,
|
||||
)
|
||||
|
||||
return {"status": "ok", "post_id": row["id"]}
|
||||
|
||||
|
||||
@router.put("/admin/feed/post/{post_id}")
|
||||
async def admin_update_feed_post(request: Request, post_id: int):
|
||||
"""
|
||||
Редактировать feed-пост.
|
||||
|
||||
Тело запроса (JSON) — все поля опциональны:
|
||||
{
|
||||
"title": "...", (на source_language)
|
||||
"body": "...", (на source_language)
|
||||
"link_url": "..." | null,
|
||||
"telegram_url": "..." | null,
|
||||
"is_pinned": true/false,
|
||||
"is_published": true/false,
|
||||
"expires_at": "..." | null,
|
||||
"translate_again": true — перевести заново через AI
|
||||
}
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
_require_admin(telegram_id)
|
||||
|
||||
body = await request.json()
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT * FROM feed_posts WHERE id = $1
|
||||
""", post_id)
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(404, "Post not found")
|
||||
|
||||
source_language = existing["source_language"]
|
||||
|
||||
# Мерджим поля: если не передано — оставляем старое
|
||||
title = body.get("title", existing.get(f"title_{source_language}") or "")
|
||||
post_body = body.get("body", existing.get(f"body_{source_language}") or "")
|
||||
link_url = body.get("link_url", existing["link_url"])
|
||||
telegram_url = body.get("telegram_url", existing["telegram_url"])
|
||||
is_pinned = body.get("is_pinned", existing["is_pinned"])
|
||||
is_published = body.get("is_published", existing["is_published"])
|
||||
|
||||
# expires_at: явное None → очистить, отсутствие ключа → оставить
|
||||
if "expires_at" in body:
|
||||
expires_at_raw = body.get("expires_at")
|
||||
if expires_at_raw is None:
|
||||
expires_at = None
|
||||
else:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
normalized = str(expires_at_raw).replace("Z", "+00:00")
|
||||
expires_at = datetime.fromisoformat(normalized)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
raise HTTPException(400, "Invalid expires_at format (use ISO 8601)")
|
||||
else:
|
||||
expires_at = existing["expires_at"]
|
||||
|
||||
# Если translate_again = True — переводим заново
|
||||
if body.get("translate_again"):
|
||||
logger.info(f"Re-translating feed post #{post_id} from {source_language}")
|
||||
translations = await translate_to_all_languages(title, post_body, source_language)
|
||||
|
||||
if not translations:
|
||||
logger.warning("Translation failed — keeping old translations for other languages")
|
||||
# Оставляем старые версии для не-source языков, обновляем только source
|
||||
translations = {
|
||||
"title_pl": existing["title_pl"], "title_ru": existing["title_ru"],
|
||||
"title_uk": existing["title_uk"], "title_en": existing["title_en"],
|
||||
"body_pl": existing["body_pl"], "body_ru": existing["body_ru"],
|
||||
"body_uk": existing["body_uk"], "body_en": existing["body_en"],
|
||||
}
|
||||
translations[f"title_{source_language}"] = title
|
||||
translations[f"body_{source_language}"] = post_body
|
||||
else:
|
||||
# Без translate_again — обновляем ТОЛЬКО source_language, остальные не трогаем
|
||||
translations = {
|
||||
"title_pl": existing["title_pl"], "title_ru": existing["title_ru"],
|
||||
"title_uk": existing["title_uk"], "title_en": existing["title_en"],
|
||||
"body_pl": existing["body_pl"], "body_ru": existing["body_ru"],
|
||||
"body_uk": existing["body_uk"], "body_en": existing["body_en"],
|
||||
}
|
||||
translations[f"title_{source_language}"] = title
|
||||
translations[f"body_{source_language}"] = post_body
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE feed_posts SET
|
||||
title_pl = $1, title_ru = $2, title_uk = $3, title_en = $4,
|
||||
body_pl = $5, body_ru = $6, body_uk = $7, body_en = $8,
|
||||
link_url = $9, telegram_url = $10,
|
||||
is_pinned = $11, is_published = $12,
|
||||
expires_at = $13,
|
||||
updated_at = NOW(),
|
||||
edited_at = NOW()
|
||||
WHERE id = $14
|
||||
""",
|
||||
translations["title_pl"], translations["title_ru"],
|
||||
translations["title_uk"], translations["title_en"],
|
||||
translations["body_pl"], translations["body_ru"],
|
||||
translations["body_uk"], translations["body_en"],
|
||||
link_url, telegram_url,
|
||||
is_pinned, is_published,
|
||||
expires_at,
|
||||
post_id,
|
||||
)
|
||||
|
||||
return {"status": "ok", "post_id": post_id}
|
||||
|
||||
|
||||
# 🔧 ФИКС (18.09.2026): POST вместо DELETE.
|
||||
# Причина: Telegram WebView (iOS / macOS) не пропускает DELETE из-за CORS preflight,
|
||||
# который Cloudflare кеширует на 20 дней. POST без Content-Type — simple request,
|
||||
# preflight не отправляется, Cloudflare не вмешивается.
|
||||
#
|
||||
# 🔧 ФИКС (19.09.2026): удаляем не только запись из БД, но и файлы с диска.
|
||||
# Раньше файлы копились мёртвым грузом (27 мёртвых файлов за день тестов).
|
||||
@router.post("/admin/feed/post/{post_id}/delete")
|
||||
async def admin_delete_feed_post(request: Request, post_id: int):
|
||||
"""
|
||||
Удалить feed-пост (POST вместо DELETE — обход CORS preflight в WebView).
|
||||
|
||||
Что делаем:
|
||||
1. Читаем пути всех файлов поста из feed_attachments ДО удаления из БД.
|
||||
2. Удаляем пост из БД (ON DELETE CASCADE уберёт вложения).
|
||||
3. Удаляем файлы с диска.
|
||||
|
||||
Если файл уже удалён вручную / нет прав — не роняем запрос, только логируем.
|
||||
Пост из БД уже удалён — файл вторичен.
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
_require_admin(telegram_id)
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# 1. Читаем пути файлов ДО удаления
|
||||
attachment_rows = await conn.fetch("""
|
||||
SELECT file_path FROM feed_attachments WHERE post_id = $1
|
||||
""", post_id)
|
||||
|
||||
# 2. Удаляем пост (CASCADE уберёт feed_attachments)
|
||||
result = await conn.execute("""
|
||||
DELETE FROM feed_posts WHERE id = $1
|
||||
""", post_id)
|
||||
|
||||
affected = int(result.split()[-1])
|
||||
if affected == 0:
|
||||
raise HTTPException(404, "Post not found")
|
||||
|
||||
logger.info(f"Feed post #{post_id} deleted by admin {telegram_id}")
|
||||
|
||||
# 3. Удаляем файлы с диска (вне pool.acquire — чтобы не держать коннект)
|
||||
# file_path в БД = "/uploads/feed/16_0_abc.jpg"
|
||||
# на диске = "/root/filwordspl/backend/uploads/feed/16_0_abc.jpg"
|
||||
# UPLOADS_DIR = "/root/filwordspl/backend/uploads" (Path)
|
||||
removed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for row in attachment_rows:
|
||||
file_path = row["file_path"] # "/uploads/feed/16_0_abc.jpg"
|
||||
# Убираем префикс "/uploads/" → "feed/16_0_abc.jpg"
|
||||
rel_path = file_path.replace("/uploads/", "", 1).lstrip("/")
|
||||
abs_path = Path(UPLOADS_DIR) / rel_path
|
||||
|
||||
try:
|
||||
if abs_path.exists():
|
||||
abs_path.unlink()
|
||||
removed_count += 1
|
||||
logger.info(f"[delete_feed_post] removed file: {abs_path}")
|
||||
else:
|
||||
logger.warning(f"[delete_feed_post] file not found: {abs_path}")
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
# Не роняем запрос — пост уже удалён из БД, файл вторичен
|
||||
logger.error(f"[delete_feed_post] failed to remove {abs_path}: {e}")
|
||||
|
||||
if removed_count or failed_count:
|
||||
logger.info(
|
||||
f"[delete_feed_post] post #{post_id}: "
|
||||
f"files removed={removed_count}, failed={failed_count}"
|
||||
)
|
||||
|
||||
return {"status": "ok", "files_removed": removed_count}
|
||||
|
||||
|
||||
@router.post("/admin/feed/upload-image")
|
||||
async def admin_upload_feed_image(
|
||||
request: Request,
|
||||
post_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
Загрузить картинку к посту.
|
||||
- Максимум MAX_FEED_ATTACHMENTS (3) на пост.
|
||||
- Максимум MAX_FEED_IMAGE_SIZE_MB (5 МБ) на файл.
|
||||
- Только image/jpeg, image/png, image/webp, image/gif.
|
||||
|
||||
Возвращает путь к файлу (используется как URL на фронте).
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
_require_admin(telegram_id)
|
||||
|
||||
# Валидация MIME-типа
|
||||
content_type = (file.content_type or "").lower()
|
||||
if content_type not in FEED_ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Invalid image type: {content_type}. Allowed: {sorted(FEED_ALLOWED_IMAGE_TYPES)}",
|
||||
)
|
||||
|
||||
# Читаем файл, проверяем размер
|
||||
content = await file.read()
|
||||
if len(content) == 0:
|
||||
raise HTTPException(400, "Empty file")
|
||||
|
||||
if len(content) > MAX_FEED_IMAGE_SIZE_BYTES:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"File too large: {len(content)} bytes. Max: {MAX_FEED_IMAGE_SIZE_BYTES} bytes",
|
||||
)
|
||||
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Проверяем, что пост существует
|
||||
post_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM feed_posts WHERE id = $1", post_id
|
||||
)
|
||||
if not post_exists:
|
||||
raise HTTPException(404, "Post not found")
|
||||
|
||||
# Проверяем лимит вложений
|
||||
current_count = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM feed_attachments WHERE post_id = $1
|
||||
""", post_id) or 0
|
||||
|
||||
if current_count >= MAX_FEED_ATTACHMENTS:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Max {MAX_FEED_ATTACHMENTS} attachments per post (already {current_count})",
|
||||
)
|
||||
|
||||
# Готовим имя файла: {post_id}_{order}_{uuid8}.{ext}
|
||||
ext_map = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
}
|
||||
ext = ext_map.get(content_type, "jpg")
|
||||
order_number = current_count # 0, 1, 2
|
||||
filename = f"{post_id}_{order_number}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
|
||||
# Сохраняем на диск
|
||||
feed_uploads_dir = Path(UPLOADS_DIR) / "feed"
|
||||
feed_uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path_disk = feed_uploads_dir / filename
|
||||
file_path_disk.write_bytes(content)
|
||||
|
||||
# Публичный URL для фронта (относительный — фронт сам подставит API_BASE)
|
||||
public_path = f"/uploads/feed/{filename}"
|
||||
|
||||
# Пишем в БД
|
||||
await conn.execute("""
|
||||
INSERT INTO feed_attachments (post_id, file_path, file_type, order_number)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
""", post_id, public_path, "image", order_number)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"file_path": public_path,
|
||||
"order_number": order_number,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/feed/translate")
|
||||
async def admin_translate_feed(request: Request):
|
||||
"""
|
||||
Перевести title+body БЕЗ сохранения в БД.
|
||||
Используется для превью перевода в админ-UI.
|
||||
|
||||
Тело запроса:
|
||||
{
|
||||
"title": "...",
|
||||
"body": "...",
|
||||
"source_language": "ru"
|
||||
}
|
||||
|
||||
Возвращает:
|
||||
{
|
||||
"title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...",
|
||||
"body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..."
|
||||
}
|
||||
"""
|
||||
telegram_id, _ = await require_auth(request)
|
||||
_require_admin(telegram_id)
|
||||
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
post_body = (body.get("body") or "").strip()
|
||||
source_language = body.get("source_language", "ru").strip().lower()
|
||||
|
||||
if source_language not in FEED_TARGET_LANGUAGES:
|
||||
raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}")
|
||||
|
||||
if not title and not post_body:
|
||||
raise HTTPException(400, "Either title or body is required")
|
||||
|
||||
translations = await translate_to_all_languages(title, post_body, source_language)
|
||||
|
||||
if not translations:
|
||||
raise HTTPException(500, "Translation failed (AI unavailable or invalid response)")
|
||||
|
||||
return translations
|
||||
Reference in New Issue
Block a user