3861 lines
147 KiB
Python
3861 lines
147 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import base64
|
||
import hashlib
|
||
import mimetypes
|
||
import re
|
||
import secrets
|
||
import time
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import aiohttp
|
||
from fastapi import FastAPI, File, Form, Request, UploadFile, status
|
||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from loguru import logger
|
||
|
||
from .config import settings
|
||
from .constants import PLATFORM_VK
|
||
from .db import fetch_int_setting, fetch_setting, get_pool
|
||
from .security import hash_password, new_token, token_hash, verify_password
|
||
from .text_utils import build_publication_text, normalize_hash_tag, parse_categories
|
||
from .vk_api import VKAPIClient, normalize_vk_source
|
||
from .workers.ai_qualifier import normalize_model, response_usage
|
||
|
||
COOKIE_NAME = "vk_parser_admin"
|
||
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
||
VK_OAUTH_STATE_COOKIE = "vk_oauth_state"
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
PROJECT_ROOT = BASE_DIR.parents[1]
|
||
FAVICON_PATH = PROJECT_ROOT / "favi.png"
|
||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||
app = FastAPI(title="VK Parser Admin")
|
||
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
|
||
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
|
||
UPLOAD_ROOT = Path("uploads").resolve()
|
||
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
|
||
EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads")
|
||
|
||
|
||
@app.get("/favi.png")
|
||
async def favicon_png():
|
||
return FileResponse(FAVICON_PATH)
|
||
|
||
|
||
@app.get("/favicon.ico")
|
||
async def favicon_ico():
|
||
return FileResponse(FAVICON_PATH, media_type="image/png")
|
||
|
||
PROVIDER_OPTIONS = [
|
||
{"value": "openrouter", "label": "OpenRouter"},
|
||
{"value": "openai", "label": "OpenAI"},
|
||
{"value": "anthropic", "label": "Anthropic"},
|
||
{"value": "gemini", "label": "Google Gemini"},
|
||
{"value": "openai_compatible", "label": "OpenAI-compatible API"},
|
||
]
|
||
|
||
PROMPT_HINTS = {
|
||
"ai_qualifier_prompt": {
|
||
"title": "Свободная инструкция квалификатора",
|
||
"body": (
|
||
"Здесь можно спокойно менять смысл: роль, тон, критерии пригодности, примеры хороших и плохих постов, "
|
||
"логику оценки по шкале 1-10."
|
||
),
|
||
"safe": (
|
||
"Можно менять: критерии, примеры, тон причины, пороговые объяснения. "
|
||
"Не добавляй сюда JSON-схему: для неё есть отдельный технический контракт ниже."
|
||
),
|
||
"input": "На вход уходит JSON-массив постов: id, source, original_url, media_count, media_types, text.",
|
||
},
|
||
"ai_qualifier_contract": {
|
||
"title": "Технический контракт квалификатора",
|
||
"body": (
|
||
"Эта часть защищает парсер от сломанного ответа. Меняй её только если осознанно меняешь формат ответа "
|
||
"и одновременно готов править валидатор в коде."
|
||
),
|
||
"safe": "Обычно не трогаем. Здесь живут JSON-схема, допустимые decision/reject_tag и правило вернуть результат на каждый id.",
|
||
"input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.",
|
||
"contract": '{"results":[{"id":123,"score":8,"decision":"accepted","reason":"до 10 слов на русском","reject_tag":null}]}',
|
||
},
|
||
"ai_writer_prompt": {
|
||
"title": "Свободная инструкция райтера",
|
||
"body": (
|
||
"Здесь можно менять редакторскую часть: стиль, длину, голос канала, запреты на выдумки, примеры хорошего текста."
|
||
),
|
||
"safe": (
|
||
"Можно менять: tone of voice, правила фактов, длину, примеры фраз. "
|
||
"Хэштеги руками писать не надо: модель выбирает category, а код сам соберёт #category и #producer_tag."
|
||
),
|
||
"input": "На вход уходит JSON-объект: categories и posts. В каждом post есть id, producer_name, producer_tag, original_url, qualification_score, media_count, media_types, text.",
|
||
},
|
||
"ai_writer_contract": {
|
||
"title": "Технический контракт райтера",
|
||
"body": (
|
||
"Эта часть фиксирует формат JSON, запрет хэштегов в тексте и выбор категории. "
|
||
"Менять её стоит только вместе с валидатором райтера."
|
||
),
|
||
"safe": "Обычно не трогаем. Если промпт райтера переписывается, свободную часть меняем выше, контракт оставляем стабильным.",
|
||
"input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.",
|
||
"contract": '{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст до 900 символов без хэштегов; явно упоминает producer_name","notes":"короткая заметка для редактора"}]}',
|
||
},
|
||
}
|
||
|
||
MODEL_FALLBACKS = {
|
||
"openrouter": [
|
||
"openrouter/openai/gpt-4.1-mini",
|
||
"openrouter/anthropic/claude-3.5-sonnet",
|
||
"openrouter/google/gemini-2.5-flash",
|
||
],
|
||
"openai": ["gpt-4.1-mini", "gpt-4o-mini", "o4-mini"],
|
||
"anthropic": ["anthropic/claude-haiku-4-5-20251001", "anthropic/claude-sonnet-4-20250514"],
|
||
"gemini": ["gemini/gemini-2.5-flash", "gemini/gemini-2.5-pro"],
|
||
"openai_compatible": [],
|
||
}
|
||
|
||
PREFERRED_MODELS = {
|
||
"openrouter": [
|
||
"openrouter/anthropic/claude-sonnet-4",
|
||
"openrouter/anthropic/claude-3.5-sonnet",
|
||
"openrouter/openai/gpt-4.1-mini",
|
||
"openrouter/google/gemini-2.5-flash",
|
||
],
|
||
"anthropic": [
|
||
"anthropic/claude-haiku-4-5-20251001",
|
||
"anthropic/claude-4-sonnet-20250514",
|
||
"anthropic/claude-sonnet-4-20250514",
|
||
"anthropic/claude-3-7-sonnet-20250219",
|
||
"anthropic/claude-3-5-sonnet-20241022",
|
||
"anthropic/claude-3-5-haiku-20241022",
|
||
],
|
||
"openai": ["gpt-4.1-mini", "gpt-4o-mini", "o4-mini"],
|
||
"gemini": ["gemini/gemini-2.5-flash", "gemini/gemini-2.5-pro"],
|
||
}
|
||
|
||
CATEGORY_TITLES = {
|
||
"AI Qualifier": "AI-квалификатор",
|
||
"AI Writer": "AI-райтер",
|
||
"TG Poster": "TG-постер",
|
||
"TG Reactor": "TG-реактор",
|
||
"VK Poster": "VK-постер",
|
||
"MAX Poster": "MAX-постер",
|
||
"Daily Report": "Ежедневный отчет",
|
||
"Parser": "Парсер",
|
||
"Uploader": "Аплоадер",
|
||
"VK": "VK API",
|
||
"General": "Общие",
|
||
}
|
||
|
||
CATEGORY_ORDER = {
|
||
"AI Qualifier": 10,
|
||
"AI Writer": 20,
|
||
"TG Poster": 30,
|
||
"TG Reactor": 40,
|
||
"VK Poster": 50,
|
||
"MAX Poster": 51,
|
||
"Site Poster": 52,
|
||
"Daily Report": 55,
|
||
"Parser": 60,
|
||
"VK": 70,
|
||
"Uploader": 80,
|
||
"General": 100,
|
||
}
|
||
|
||
AI_WORKER_ENABLED_SETTINGS = {
|
||
"ai-qualifier": "ai_qualifier_enabled",
|
||
"ai-writer": "ai_writer_enabled",
|
||
"tg-poster": "tg_poster_enabled",
|
||
"tg-reactor": "tg_reactor_enabled",
|
||
"vk-poster": "vk_poster_enabled",
|
||
"max-poster": "max_poster_enabled",
|
||
"site-poster": "site_poster_enabled",
|
||
"daily-report": "daily_report_enabled",
|
||
}
|
||
|
||
SETTING_ORDER = {
|
||
"AI Qualifier": [
|
||
"ai_qualifier_provider",
|
||
"ai_qualifier_model",
|
||
"ai_qualifier_api_key",
|
||
"ai_qualifier_api_base",
|
||
"ai_qualifier_prompt",
|
||
"ai_qualifier_contract",
|
||
"ai_qualifier_batch_size",
|
||
"ai_qualifier_min_score",
|
||
"ai_qualifier_max_text_chars",
|
||
"ai_qualifier_temperature",
|
||
"ai_qualifier_timeout_sec",
|
||
"ai_qualifier_interval_sec",
|
||
],
|
||
"AI Writer": [
|
||
"ai_writer_provider",
|
||
"ai_writer_model",
|
||
"ai_writer_api_key",
|
||
"ai_writer_api_base",
|
||
"ai_writer_prompt",
|
||
"ai_writer_contract",
|
||
"ai_writer_categories",
|
||
"ai_writer_batch_size",
|
||
"ai_writer_max_text_chars",
|
||
"ai_writer_temperature",
|
||
"ai_writer_timeout_sec",
|
||
"ai_writer_interval_sec",
|
||
],
|
||
"TG Poster": [
|
||
"tg_poster_bot_token",
|
||
"tg_poster_chat_id",
|
||
"tg_poster_interval_sec",
|
||
"tg_poster_media_group_max_items",
|
||
"tg_poster_caption_limit",
|
||
"tg_poster_message_limit",
|
||
"tg_poster_max_attempts",
|
||
"tg_poster_retry_backoff_max_sec",
|
||
"tg_poster_send_delay_sec",
|
||
"tg_poster_text_overflow_caption",
|
||
"tg_poster_recent_window",
|
||
"tg_poster_category_repeat_penalty",
|
||
"tg_poster_source_repeat_penalty",
|
||
],
|
||
"TG Reactor": [
|
||
"tg_reactor_bot_tokens",
|
||
"tg_reactor_reactions_json",
|
||
"tg_reactor_since",
|
||
"tg_reactor_delay_sec",
|
||
"tg_reactor_interval_sec",
|
||
"tg_reactor_reaction_pause_sec",
|
||
"tg_reactor_react_all_messages",
|
||
"tg_reactor_max_attempts",
|
||
"tg_reactor_retry_backoff_max_sec",
|
||
],
|
||
"VK Poster": [
|
||
"vk_poster_app_id",
|
||
"vk_poster_client_secret",
|
||
"vk_poster_access_token",
|
||
"vk_poster_refresh_token",
|
||
"vk_poster_token_device_id",
|
||
"vk_poster_token_expires_at",
|
||
"vk_poster_owner_id",
|
||
"vk_poster_from_group",
|
||
"vk_poster_interval_sec",
|
||
"vk_poster_timeout_sec",
|
||
"vk_poster_max_attempts",
|
||
"vk_poster_retry_backoff_max_sec",
|
||
"vk_poster_send_delay_sec",
|
||
"vk_poster_recent_window",
|
||
"vk_poster_category_repeat_penalty",
|
||
"vk_poster_source_repeat_penalty",
|
||
"vk_poster_dry_run",
|
||
],
|
||
"MAX Poster": [
|
||
"max_poster_bot_token",
|
||
"max_poster_chat_id",
|
||
"max_poster_api_base_url",
|
||
"max_poster_interval_sec",
|
||
"max_poster_message_limit",
|
||
"max_poster_media_max_items",
|
||
"max_poster_media_process_delay_sec",
|
||
"max_poster_video_ready_attempts",
|
||
"max_poster_video_ready_delay_sec",
|
||
"max_poster_max_attempts",
|
||
"max_poster_retry_backoff_max_sec",
|
||
"max_poster_send_delay_sec",
|
||
"max_poster_recent_window",
|
||
"max_poster_category_repeat_penalty",
|
||
"max_poster_source_repeat_penalty",
|
||
"max_poster_auto_reaction_enabled",
|
||
"max_poster_auto_reaction",
|
||
"max_poster_reaction_path_template",
|
||
"max_poster_dry_run",
|
||
],
|
||
"Site Poster": [
|
||
"site_poster_interval_sec",
|
||
"site_poster_photo_batch_size",
|
||
"site_poster_video_batch_size",
|
||
"site_poster_ghost_url",
|
||
"site_poster_key_file",
|
||
],
|
||
"Daily Report": [
|
||
"daily_report_bot_token",
|
||
"daily_report_recipient_ids",
|
||
"daily_report_time",
|
||
"daily_report_interval_sec",
|
||
"daily_report_last_sent_date",
|
||
],
|
||
"Parser": [
|
||
"parser_interval_sec",
|
||
"parser_new_source_lookback_days",
|
||
"parser_reparse_overlap_minutes",
|
||
"parser_min_text_length",
|
||
"parser_skip_empty_text",
|
||
"parser_skip_no_media",
|
||
"parser_skip_text_too_short",
|
||
"parser_skip_reposts",
|
||
"parser_store_skipped_posts",
|
||
"parser_dedupe_content_hash",
|
||
"parser_source_pause_sec",
|
||
],
|
||
"VK": [
|
||
"vk_requests_per_second",
|
||
"vk_wall_page_size",
|
||
"vk_api_timeout_total_sec",
|
||
"vk_api_timeout_connect_sec",
|
||
"vk_rate_limit_sleep_sec",
|
||
"vk_api_retry_attempts",
|
||
"vk_api_retry_min_delay_sec",
|
||
"vk_api_retry_max_delay_sec",
|
||
],
|
||
"Uploader": [
|
||
"tg_media_channel_id",
|
||
"local_bot_api_url",
|
||
"uploader_interval_sec",
|
||
"uploader_download_timeout_sec",
|
||
"media_group_max_items",
|
||
"media_upload_delay_sec",
|
||
"media_post_job_pause_sec",
|
||
"max_media_attempts",
|
||
"tg_retry_attempts",
|
||
"tg_retry_backoff_max_sec",
|
||
"video_max_size_mb",
|
||
"video_max_height",
|
||
"video_max_duration_sec",
|
||
"uploader_yt_dlp_timeout_sec",
|
||
],
|
||
}
|
||
|
||
|
||
def now_utc() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def redirect(path: str) -> RedirectResponse:
|
||
return RedirectResponse(path, status_code=status.HTTP_303_SEE_OTHER)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def add_security_headers(request: Request, call_next):
|
||
response = await call_next(request)
|
||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||
return response
|
||
|
||
|
||
def should_audit_request(request: Request) -> bool:
|
||
path = request.url.path
|
||
if path in {"/health", "/favi.png", "/favicon.ico"}:
|
||
return False
|
||
if path.startswith("/uploads/"):
|
||
return False
|
||
if path == "/login" and request.method == "GET":
|
||
return False
|
||
return request.method in {"GET", "POST"}
|
||
|
||
|
||
@app.middleware("http")
|
||
async def add_request_audit(request: Request, call_next):
|
||
user = await get_current_user(request) if should_audit_request(request) else None
|
||
response = await call_next(request)
|
||
if user:
|
||
try:
|
||
await audit(
|
||
user["id"],
|
||
f"request.{request.method.lower()}",
|
||
"http_request",
|
||
None,
|
||
{"status_code": response.status_code},
|
||
request,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("Could not write request audit log: {}", exc)
|
||
return response
|
||
|
||
|
||
def pkce_challenge(verifier: str) -> str:
|
||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||
|
||
|
||
async def bootstrap_admin() -> None:
|
||
if not settings.admin_bootstrap_login or not settings.admin_bootstrap_password:
|
||
return
|
||
pool = await get_pool()
|
||
exists = await pool.fetchval("SELECT 1 FROM admin_users WHERE login=$1", settings.admin_bootstrap_login)
|
||
if exists:
|
||
return
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO admin_users(login, password_hash, role, is_active)
|
||
VALUES($1, $2, 'admin', TRUE)
|
||
""",
|
||
settings.admin_bootstrap_login.strip().lower(),
|
||
hash_password(settings.admin_bootstrap_password),
|
||
)
|
||
logger.warning("Bootstrap admin user created: {}", settings.admin_bootstrap_login)
|
||
|
||
|
||
async def get_current_user(request: Request) -> dict | None:
|
||
token = request.cookies.get(COOKIE_NAME)
|
||
if not token:
|
||
return None
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT s.csrf_token, u.id, u.login, u.role, u.is_active
|
||
FROM admin_sessions s
|
||
JOIN admin_users u ON u.id=s.user_id
|
||
WHERE s.token_hash=$1
|
||
AND s.expires_at > NOW()
|
||
AND u.is_active=TRUE
|
||
""",
|
||
token_hash(token, settings.app_secret_key),
|
||
)
|
||
return dict(row) if row else None
|
||
|
||
|
||
def require_csrf(user: dict, csrf_token: str) -> None:
|
||
if not user or csrf_token != user["csrf_token"]:
|
||
raise PermissionError("bad csrf")
|
||
|
||
|
||
def base_context(request: Request, user: dict | None, **extra: Any) -> dict[str, Any]:
|
||
ctx = {"request": request, "user": user, "app_env": settings.app_env}
|
||
ctx.update(extra)
|
||
return ctx
|
||
|
||
|
||
def client_ip(request: Request) -> str:
|
||
forwarded = request.headers.get("x-forwarded-for", "")
|
||
if forwarded:
|
||
return forwarded.split(",", 1)[0].strip()[:80]
|
||
real_ip = request.headers.get("x-real-ip", "")
|
||
if real_ip:
|
||
return real_ip.strip()[:80]
|
||
return (request.client.host if request.client else "unknown")[:80]
|
||
|
||
|
||
def preserved_query(request: Request, exclude: set[str] | None = None) -> list[dict[str, str]]:
|
||
exclude = exclude or {"page"}
|
||
items = []
|
||
for key, value in request.query_params.multi_items():
|
||
if key not in exclude:
|
||
items.append({"key": key, "value": value})
|
||
return items
|
||
|
||
|
||
def query_path(request: Request, **replace: str) -> str:
|
||
values: list[tuple[str, str]] = []
|
||
excluded = set(replace) | {"page"}
|
||
for key, value in request.query_params.multi_items():
|
||
if key not in excluded:
|
||
values.append((key, value))
|
||
for key, value in replace.items():
|
||
if value != "":
|
||
values.append((key, value))
|
||
encoded = urlencode(values)
|
||
return f"{request.url.path}?{encoded}" if encoded else request.url.path
|
||
|
||
|
||
def raw_sort_headers(request: Request, current_sort: str) -> dict[str, dict[str, str | bool]]:
|
||
columns = {
|
||
"id": ("id_asc", "id_desc"),
|
||
"source": ("source_asc", "source_desc"),
|
||
"stage": ("stage_asc", "stage_desc"),
|
||
"post": ("text_asc", "text_desc"),
|
||
"ai": ("score_asc", "score_desc"),
|
||
}
|
||
headers: dict[str, dict[str, str | bool]] = {}
|
||
for key, (asc, desc) in columns.items():
|
||
next_sort = desc if current_sort == asc else asc
|
||
headers[key] = {
|
||
"url": query_path(request, sort=next_sort),
|
||
"active": current_sort in {asc, desc},
|
||
"direction": "asc" if current_sort == asc else "desc" if current_sort == desc else "",
|
||
}
|
||
return headers
|
||
|
||
|
||
def parse_date_filter(value: str) -> date | None:
|
||
value = (value or "").strip()
|
||
if not value:
|
||
return None
|
||
try:
|
||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def format_dt(value: datetime | None) -> str:
|
||
if not value:
|
||
return ""
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(LOCAL_TZ).strftime("%d.%m.%y %H:%M")
|
||
|
||
|
||
def clamp_int(value: int, min_value: int, max_value: int) -> int:
|
||
return max(min_value, min(max_value, int(value)))
|
||
|
||
|
||
def pagination(page: int, per_page: int, total: int) -> dict[str, int | bool]:
|
||
page = clamp_int(page, 1, 1_000_000)
|
||
per_page = clamp_int(per_page, 10, 200)
|
||
pages = max(1, (int(total) + per_page - 1) // per_page)
|
||
page = min(page, pages)
|
||
return {
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"total": int(total),
|
||
"pages": pages,
|
||
"offset": (page - 1) * per_page,
|
||
"has_prev": page > 1,
|
||
"has_next": page < pages,
|
||
}
|
||
|
||
|
||
FILTER_OPS = {
|
||
"text": [
|
||
{"value": "contains", "label": "содержит"},
|
||
{"value": "eq", "label": "равно"},
|
||
{"value": "neq", "label": "не равно"},
|
||
],
|
||
"enum": [
|
||
{"value": "eq", "label": "равно"},
|
||
{"value": "neq", "label": "не равно"},
|
||
],
|
||
"number": [
|
||
{"value": "eq", "label": "="},
|
||
{"value": "gte", "label": ">="},
|
||
{"value": "lte", "label": "<="},
|
||
],
|
||
"date": [
|
||
{"value": "gte", "label": "от"},
|
||
{"value": "lte", "label": "до"},
|
||
{"value": "eq", "label": "день"},
|
||
],
|
||
}
|
||
FILTER_OP_OPTIONS = [
|
||
{"value": "contains", "label": "содержит"},
|
||
{"value": "eq", "label": "равно / день"},
|
||
{"value": "neq", "label": "не равно"},
|
||
{"value": "gte", "label": ">= / от"},
|
||
{"value": "lte", "label": "<= / до"},
|
||
]
|
||
|
||
SOURCE_FIELD_SPECS = {
|
||
"id": {"label": "ID", "expr": "s.id", "type": "number"},
|
||
"platform": {"label": "Площадка", "expr": "s.platform", "type": "enum"},
|
||
"name": {"label": "Название", "expr": "s.name", "type": "text"},
|
||
"tag": {"label": "Тэг", "expr": "COALESCE(s.tag,'')", "type": "text"},
|
||
"url": {"label": "Ссылка", "expr": "s.url", "type": "text"},
|
||
"external_id": {"label": "VK id", "expr": "COALESCE(s.external_id,'')", "type": "text"},
|
||
"active": {"label": "Включён", "expr": "s.active", "type": "enum"},
|
||
"status": {"label": "Статус", "expr": "s.status", "type": "enum"},
|
||
"posts_count": {
|
||
"label": "Постов",
|
||
"expr": "(SELECT COUNT(*) FROM raw_posts rp2 WHERE rp2.source_id=s.id)",
|
||
"type": "number",
|
||
},
|
||
"posts_24h": {
|
||
"label": "Постов за 24ч",
|
||
"expr": "(SELECT COUNT(*) FROM raw_posts rp3 WHERE rp3.source_id=s.id AND rp3.created_at > NOW() - INTERVAL '24 hours')",
|
||
"type": "number",
|
||
},
|
||
"last_parsed_at": {"label": "Последний парсинг", "expr": "timezone('Asia/Yekaterinburg', s.last_parsed_at)::date", "sort_expr": "s.last_parsed_at", "type": "date"},
|
||
"created_at": {"label": "Создан", "expr": "timezone('Asia/Yekaterinburg', s.created_at)::date", "sort_expr": "s.created_at", "type": "date"},
|
||
}
|
||
|
||
RAW_FIELD_SPECS = {
|
||
"id": {"label": "ID", "expr": "rp.id", "type": "number"},
|
||
"source_name": {"label": "Источник", "expr": "s.name", "type": "text"},
|
||
"source_tag": {"label": "Тэг источника", "expr": "COALESCE(s.tag,'')", "type": "text"},
|
||
"platform": {"label": "Площадка", "expr": "rp.platform", "type": "enum"},
|
||
"status": {"label": "Raw статус", "expr": "rp.status", "type": "enum"},
|
||
"original_url": {"label": "Оригинал", "expr": "rp.original_url", "type": "text"},
|
||
"text": {"label": "Текст", "expr": "rp.raw_text", "type": "text"},
|
||
"media_count": {
|
||
"label": "Медиа",
|
||
"expr": "(SELECT COUNT(*) FROM raw_post_media rmf WHERE rmf.raw_post_id=rp.id)",
|
||
"type": "number",
|
||
},
|
||
"posted_at": {"label": "Дата VK", "expr": "timezone('Asia/Yekaterinburg', rp.posted_at)::date", "sort_expr": "rp.posted_at", "type": "date"},
|
||
"created_at": {"label": "Дата загрузки", "expr": "timezone('Asia/Yekaterinburg', rp.created_at)::date", "sort_expr": "rp.created_at", "type": "date"},
|
||
"qualification_status": {"label": "Итог AI", "expr": "COALESCE(rp.qualification_status,'pending')", "type": "enum"},
|
||
"qualification_model_decision": {"label": "Решение модели", "expr": "COALESCE(rp.qualification_model_decision, rp.qualification_decision, '')", "type": "enum"},
|
||
"qualification_score": {"label": "Оценка", "expr": "rp.qualification_score", "type": "number"},
|
||
"qualification_reject_tag": {"label": "Reject tag", "expr": "COALESCE(rp.qualification_reject_tag,'')", "type": "enum"},
|
||
"rewrite_status": {"label": "Статус рерайта", "expr": "COALESCE(rp.rewrite_status,'pending')", "type": "enum"},
|
||
"rewrite_category": {"label": "Категория", "expr": "COALESCE(rp.rewrite_category,'')", "type": "enum"},
|
||
"rewrite_source_tag": {"label": "Хэштег источника", "expr": "COALESCE(rp.rewrite_source_tag,'')", "type": "text"},
|
||
}
|
||
|
||
EDITOR_STATUS_OPTIONS = [
|
||
{"value": "review", "label": "На проверке"},
|
||
{"value": "edited", "label": "Отредактировано"},
|
||
{"value": "accepted", "label": "Принято"},
|
||
{"value": "published", "label": "Опубликовано"},
|
||
{"value": "publish_failed", "label": "Ошибка публикации"},
|
||
{"value": "rejected", "label": "Отклонено"},
|
||
]
|
||
|
||
|
||
def field_options(specs: dict[str, dict[str, str]]) -> list[dict[str, str]]:
|
||
return [{"value": key, "label": spec["label"], "type": spec["type"]} for key, spec in specs.items()]
|
||
|
||
|
||
def parse_table_filters(request: Request, specs: dict[str, dict[str, str]], slots: int = 4) -> list[dict[str, str]]:
|
||
qp = request.query_params
|
||
fields = qp.getlist("f_field")
|
||
ops = qp.getlist("f_op")
|
||
values = qp.getlist("f_value")
|
||
rows: list[dict[str, str]] = []
|
||
for i in range(max(slots, len(fields))):
|
||
field = fields[i] if i < len(fields) else ""
|
||
op = ops[i] if i < len(ops) else ""
|
||
value = values[i] if i < len(values) else ""
|
||
if field not in specs:
|
||
field = ""
|
||
field_type = specs[field]["type"] if field else "text"
|
||
allowed_ops = {item["value"] for item in FILTER_OPS[field_type]}
|
||
if op not in allowed_ops:
|
||
op = "contains" if field_type == "text" else "eq"
|
||
rows.append({"field": field, "op": op, "value": value})
|
||
return rows
|
||
|
||
|
||
def parse_table_sorts(request: Request, specs: dict[str, dict[str, str]], slots: int = 3) -> list[dict[str, str]]:
|
||
qp = request.query_params
|
||
fields = qp.getlist("sort_field")
|
||
dirs = qp.getlist("sort_dir")
|
||
rows: list[dict[str, str]] = []
|
||
for i in range(max(slots, len(fields))):
|
||
field = fields[i] if i < len(fields) else ""
|
||
direction = dirs[i] if i < len(dirs) else "desc"
|
||
if field not in specs:
|
||
field = ""
|
||
if direction not in {"asc", "desc"}:
|
||
direction = "desc"
|
||
rows.append({"field": field, "dir": direction})
|
||
return rows
|
||
|
||
|
||
def build_filter_sql(filters: list[dict[str, str]], specs: dict[str, dict[str, str]], start_index: int = 1) -> tuple[list[str], list[Any]]:
|
||
clauses: list[str] = []
|
||
args: list[Any] = []
|
||
index = start_index
|
||
for item in filters:
|
||
field = item.get("field") or ""
|
||
value = str(item.get("value") or "").strip()
|
||
if not field or field not in specs or value == "":
|
||
continue
|
||
spec = specs[field]
|
||
expr = spec["expr"]
|
||
field_type = spec["type"]
|
||
op = item.get("op") or ("contains" if field_type == "text" else "eq")
|
||
if field_type == "text":
|
||
if op == "eq":
|
||
clauses.append(f"{expr} = ${index}")
|
||
args.append(value)
|
||
elif op == "neq":
|
||
clauses.append(f"{expr} <> ${index}")
|
||
args.append(value)
|
||
else:
|
||
clauses.append(f"{expr} ILIKE '%' || ${index} || '%'")
|
||
args.append(value)
|
||
elif field_type == "enum":
|
||
if op == "neq":
|
||
clauses.append(f"{expr} <> ${index}")
|
||
else:
|
||
clauses.append(f"{expr} = ${index}")
|
||
if value.lower() in {"true", "false"}:
|
||
args.append(value.lower() == "true")
|
||
else:
|
||
args.append(value)
|
||
elif field_type == "number":
|
||
try:
|
||
number_value = int(value)
|
||
except ValueError:
|
||
continue
|
||
sign = {"gte": ">=", "lte": "<=", "neq": "<>", "eq": "="}.get(op, "=")
|
||
clauses.append(f"{expr} {sign} ${index}")
|
||
args.append(number_value)
|
||
elif field_type == "date":
|
||
sign = {"gte": ">=", "lte": "<=", "eq": "="}.get(op, "=")
|
||
clauses.append(f"{expr} {sign} ${index}::date")
|
||
args.append(value)
|
||
index += 1
|
||
return clauses, args
|
||
|
||
|
||
def build_sort_sql(sorts: list[dict[str, str]], specs: dict[str, dict[str, str]], default_sql: str) -> str:
|
||
parts: list[str] = []
|
||
seen: set[str] = set()
|
||
for item in sorts:
|
||
field = item.get("field") or ""
|
||
if not field or field not in specs or field in seen:
|
||
continue
|
||
spec = specs[field]
|
||
expr = spec.get("sort_expr") or spec["expr"]
|
||
direction = "ASC" if item.get("dir") == "asc" else "DESC"
|
||
nulls = " NULLS LAST" if direction == "DESC" else " NULLS FIRST"
|
||
parts.append(f"{expr} {direction}{nulls}")
|
||
seen.add(field)
|
||
if parts:
|
||
if "id" not in seen and "id" in specs:
|
||
parts.append(f"{specs['id'].get('sort_expr') or specs['id']['expr']} DESC")
|
||
return ", ".join(part for part in parts if part)
|
||
return default_sql
|
||
|
||
|
||
def selected_values(request: Request, name: str) -> list[str]:
|
||
return [str(value).strip() for value in request.query_params.getlist(name) if str(value).strip()]
|
||
|
||
|
||
def selected_int_values(request: Request, name: str) -> list[int]:
|
||
values: list[int] = []
|
||
for value in selected_values(request, name):
|
||
try:
|
||
values.append(int(value))
|
||
except ValueError:
|
||
continue
|
||
return values
|
||
|
||
|
||
def add_where(clauses: list[str], args: list[Any], sql_template: str, value: Any) -> None:
|
||
args.append(value)
|
||
clauses.append(sql_template.format(i=len(args)))
|
||
|
||
|
||
async def raw_filter_facets(pool) -> dict[str, Any]:
|
||
source_rows = await pool.fetch(
|
||
"""
|
||
SELECT s.id, s.name, COALESCE(s.tag, '') AS tag, COUNT(rp.id) AS count
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
GROUP BY s.id, s.name, s.tag
|
||
ORDER BY COUNT(rp.id) DESC, s.name ASC
|
||
"""
|
||
)
|
||
category_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(final_category, rewrite_category, '') AS value, COUNT(*) AS count
|
||
FROM raw_posts
|
||
WHERE COALESCE(final_category, rewrite_category, '') <> ''
|
||
GROUP BY COALESCE(final_category, rewrite_category, '')
|
||
ORDER BY COUNT(*) DESC, value ASC
|
||
"""
|
||
)
|
||
status_rows = await pool.fetch("SELECT status AS value, COUNT(*) AS count FROM raw_posts GROUP BY status ORDER BY count DESC, value")
|
||
qualification_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(qualification_status, 'pending') AS value, COUNT(*) AS count
|
||
FROM raw_posts
|
||
GROUP BY COALESCE(qualification_status, 'pending')
|
||
ORDER BY count DESC, value
|
||
"""
|
||
)
|
||
rewrite_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(rewrite_status, 'pending') AS value, COUNT(*) AS count
|
||
FROM raw_posts
|
||
GROUP BY COALESCE(rewrite_status, 'pending')
|
||
ORDER BY count DESC, value
|
||
"""
|
||
)
|
||
return {
|
||
"sources": [dict(row) for row in source_rows],
|
||
"categories": [dict(row) for row in category_rows],
|
||
"statuses": [dict(row) for row in status_rows],
|
||
"qualification_statuses": [dict(row) for row in qualification_rows],
|
||
"rewrite_statuses": [dict(row) for row in rewrite_rows],
|
||
}
|
||
|
||
|
||
async def editor_filter_facets(pool) -> dict[str, Any]:
|
||
source_rows = await pool.fetch(
|
||
"""
|
||
SELECT s.id, s.name, COALESCE(s.tag, '') AS tag, COUNT(rp.id) AS count
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE rp.rewrite_status='ready'
|
||
GROUP BY s.id, s.name, s.tag
|
||
ORDER BY COUNT(rp.id) DESC, s.name ASC
|
||
"""
|
||
)
|
||
category_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(final_category, rewrite_category, '') AS value, COUNT(*) AS count
|
||
FROM raw_posts
|
||
WHERE rewrite_status='ready'
|
||
AND COALESCE(final_category, rewrite_category, '') <> ''
|
||
GROUP BY COALESCE(final_category, rewrite_category, '')
|
||
ORDER BY COUNT(*) DESC, value ASC
|
||
"""
|
||
)
|
||
status_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(editorial_status, 'review') AS value, COUNT(*) AS count
|
||
FROM raw_posts
|
||
WHERE rewrite_status='ready'
|
||
GROUP BY COALESCE(editorial_status, 'review')
|
||
ORDER BY count DESC, value
|
||
"""
|
||
)
|
||
return {
|
||
"sources": [dict(row) for row in source_rows],
|
||
"categories": [dict(row) for row in category_rows],
|
||
"statuses": [dict(row) for row in status_rows],
|
||
}
|
||
|
||
|
||
def parse_source_line(line: str) -> dict[str, str]:
|
||
raw = (line or "").strip()
|
||
if not raw:
|
||
return {"name": "", "tag": "", "url": "", "error": "Пустая строка"}
|
||
parts = re.split(r"\s+", raw)
|
||
url_index = next((i for i, part in enumerate(parts) if "vk.com/" in part or part.startswith("club")), -1)
|
||
if url_index < 0:
|
||
if len(parts) == 1:
|
||
value = parts[0].strip()
|
||
url = value if value.startswith("http") else f"https://vk.com/{value}"
|
||
return {"name": "", "tag": "", "url": url, "error": ""}
|
||
return {"name": "", "tag": "", "url": "", "error": "Не нашёл ссылку VK"}
|
||
url = parts[url_index].strip()
|
||
before_url = parts[:url_index]
|
||
if len(before_url) >= 2:
|
||
tag = before_url[-1].strip()
|
||
name = " ".join(before_url[:-1]).strip()
|
||
elif len(before_url) == 1:
|
||
name = before_url[0].strip()
|
||
tag = before_url[0].strip()
|
||
else:
|
||
name = ""
|
||
tag = ""
|
||
if url.startswith("vk.com/"):
|
||
url = f"https://{url}"
|
||
return {"name": name, "tag": normalize_hash_tag(tag, ""), "url": url, "error": ""}
|
||
|
||
|
||
async def build_sources_preview(lines_text: str, default_active: bool = True) -> list[dict[str, Any]]:
|
||
lines = [line for line in (lines_text or "").splitlines() if line.strip()]
|
||
parsed = [parse_source_line(line) for line in lines]
|
||
normalized_values = [normalize_vk_source(item["url"]) for item in parsed if item.get("url")]
|
||
pool = await get_pool()
|
||
existing_rows = await pool.fetch(
|
||
"""
|
||
SELECT lower(external_id) AS external_id, lower(url) AS url, lower(COALESCE(tag,'')) AS tag
|
||
FROM sources
|
||
WHERE archived_at IS NULL
|
||
AND (lower(external_id)=ANY($1::text[]) OR lower(url)=ANY($2::text[]) OR lower(COALESCE(tag,''))=ANY($3::text[]))
|
||
""",
|
||
[v.lower() for v in normalized_values if v],
|
||
[str(item.get("url") or "").lower() for item in parsed],
|
||
[normalize_hash_tag(str(item.get("tag") or ""), "").lower() for item in parsed if item.get("tag")],
|
||
)
|
||
existing_ids = {str(row["external_id"] or "").lower() for row in existing_rows}
|
||
existing_urls = {str(row["url"] or "").lower() for row in existing_rows}
|
||
existing_tags = {str(row["tag"] or "").lower() for row in existing_rows}
|
||
|
||
seen: set[str] = set()
|
||
seen_tags: set[str] = set()
|
||
async with VKAPIClient(rps=2, timeout_total_sec=12, timeout_connect_sec=5, retry_attempts=2) as client:
|
||
preview = []
|
||
for idx, item in enumerate(parsed, start=1):
|
||
url = item.get("url") or ""
|
||
external_id = normalize_vk_source(url) if url else ""
|
||
row = {
|
||
"line_no": idx,
|
||
"platform": PLATFORM_VK,
|
||
"name": item.get("name") or "",
|
||
"tag": normalize_hash_tag(item.get("tag") or external_id, external_id or "source"),
|
||
"url": url,
|
||
"external_id": external_id,
|
||
"external_owner_id": None,
|
||
"active": default_active,
|
||
"ok": False,
|
||
"error": item.get("error") or "",
|
||
}
|
||
key = external_id.lower()
|
||
if not row["error"] and key in seen:
|
||
row["error"] = "Дубль в этом списке"
|
||
if not row["error"] and row["tag"].lower() in seen_tags:
|
||
row["error"] = "Дубль тэга в этом списке"
|
||
if not row["error"] and (key in existing_ids or url.lower() in existing_urls):
|
||
row["error"] = "Уже есть в источниках"
|
||
if not row["error"] and row["tag"].lower() in existing_tags:
|
||
row["error"] = "Такой тэг уже есть"
|
||
if not row["error"] and not external_id:
|
||
row["error"] = "Не удалось разобрать VK-ссылку"
|
||
if not row["error"]:
|
||
try:
|
||
screen_name, owner_id, resolved_name = await client.resolve_group(url)
|
||
row["external_id"] = screen_name
|
||
row["external_owner_id"] = owner_id
|
||
row["name"] = row["name"] or resolved_name
|
||
row["tag"] = normalize_hash_tag(row["tag"] or screen_name, screen_name)
|
||
row["url"] = f"https://vk.com/{screen_name}"
|
||
row["ok"] = True
|
||
seen.add(key)
|
||
seen_tags.add(row["tag"].lower())
|
||
except Exception as exc:
|
||
row["error"] = str(exc)
|
||
preview.append(row)
|
||
return preview
|
||
|
||
|
||
def pipeline_stage(post: dict[str, Any]) -> dict[str, str]:
|
||
status = str(post.get("status") or "")
|
||
qualification_status = str(post.get("qualification_status") or "").strip().lower()
|
||
if status == "failed":
|
||
return {"key": "failed", "label": "Ошибка", "class": "bad"}
|
||
if status == "skipped":
|
||
return {"key": "skipped", "label": "Пропущен", "class": "muted"}
|
||
if status in {"raw_saved", "storage_pending"}:
|
||
return {"key": status, "label": "Медиа", "class": ""}
|
||
if status != "storage_ready":
|
||
return {"key": status, "label": status or "Raw", "class": ""}
|
||
rewrite_status = str(post.get("rewrite_status") or "").strip().lower()
|
||
if rewrite_status == "ready":
|
||
return {"key": "rewrite_ready", "label": "Рерайт готов", "class": "ok"}
|
||
if rewrite_status == "processing":
|
||
return {"key": "rewrite_processing", "label": "AI пишет", "class": "warn"}
|
||
if rewrite_status == "failed":
|
||
return {"key": "rewrite_failed", "label": "Рерайт ошибка", "class": "bad"}
|
||
if qualification_status in {"accepted"}:
|
||
return {"key": "qualified_accepted", "label": "AI принят", "class": "ok"}
|
||
if qualification_status in {"rejected"}:
|
||
return {"key": "qualified_rejected", "label": "AI отклонён", "class": "bad"}
|
||
if qualification_status == "processing":
|
||
return {"key": "qualification_processing", "label": "AI проверяет", "class": "warn"}
|
||
if qualification_status == "failed":
|
||
return {"key": "qualification_failed", "label": "AI ошибка", "class": "bad"}
|
||
return {"key": "qualification_pending", "label": "Ждёт AI", "class": ""}
|
||
|
||
|
||
def ai_badge(post: dict[str, Any]) -> dict[str, str]:
|
||
status = str(post.get("qualification_status") or "pending").strip().lower()
|
||
score = post.get("qualification_score")
|
||
decision = str(post.get("qualification_decision") or status or "pending")
|
||
reason = str(post.get("qualification_reason") or "")
|
||
if isinstance(score, int):
|
||
if score >= 8:
|
||
css = "ok"
|
||
elif score >= 5:
|
||
css = "warn"
|
||
else:
|
||
css = "bad"
|
||
label = f"{score}/10"
|
||
sublabel = decision
|
||
elif status == "processing":
|
||
css = "warn"
|
||
label = "..."
|
||
sublabel = "processing"
|
||
elif status == "failed":
|
||
css = "bad"
|
||
label = "!"
|
||
sublabel = "failed"
|
||
else:
|
||
css = ""
|
||
label = "AI"
|
||
sublabel = "pending"
|
||
return {"class": css, "label": label, "sublabel": sublabel, "reason": reason}
|
||
|
||
|
||
def prepare_raw_post(row: Any) -> dict[str, Any]:
|
||
post = dict(row)
|
||
media_items = post.get("media_items") or []
|
||
if isinstance(media_items, str):
|
||
try:
|
||
media_items = json.loads(media_items)
|
||
except json.JSONDecodeError:
|
||
media_items = []
|
||
post["media_items"] = media_items
|
||
post["posted_at_fmt"] = format_dt(post.get("posted_at"))
|
||
post["created_at_fmt"] = format_dt(post.get("created_at"))
|
||
post["qualified_at_fmt"] = format_dt(post.get("qualified_at"))
|
||
post["stage"] = pipeline_stage(post)
|
||
post["ai_badge"] = ai_badge(post)
|
||
return post
|
||
|
||
|
||
def prepare_editor_post(row: Any) -> dict[str, Any]:
|
||
post = prepare_raw_post(row)
|
||
category_name = post.get("final_category") or post.get("rewrite_category") or ""
|
||
category_tag = post.get("resolved_category_tag") or post.get("final_category_tag") or post.get("rewrite_category_tag") or category_name
|
||
source_tag = post.get("final_source_tag") or post.get("rewrite_source_tag") or post.get("source_tag") or ""
|
||
post["review_text"] = post.get("final_text") or post.get("rewritten_text") or ""
|
||
post["publication_preview_text"] = build_publication_text(post["review_text"], category_tag, source_tag)
|
||
post["review_category"] = category_name
|
||
post["review_category_tag"] = category_tag
|
||
post["review_source_tag"] = source_tag
|
||
publication_status = post.get("publication_status") or "pending"
|
||
if publication_status == "published":
|
||
post["publication_status_label"] = "опубликовано"
|
||
post["publication_status_class"] = "ok"
|
||
elif publication_status == "publish_failed":
|
||
post["publication_status_label"] = "ошибка публикации"
|
||
post["publication_status_class"] = "bad"
|
||
else:
|
||
post["publication_status_label"] = "не опубликовано"
|
||
post["publication_status_class"] = ""
|
||
platform_rows = post.get("platform_publications") or []
|
||
if isinstance(platform_rows, str):
|
||
try:
|
||
platform_rows = json.loads(platform_rows)
|
||
except json.JSONDecodeError:
|
||
platform_rows = []
|
||
platforms: dict[str, dict[str, Any]] = {}
|
||
for item in platform_rows or []:
|
||
if not isinstance(item, dict) or not item.get("platform"):
|
||
continue
|
||
platforms[str(item["platform"])] = item
|
||
if "tg" not in platforms and post.get("publication_status") and post.get("publication_status") != "pending":
|
||
platforms["tg"] = {
|
||
"platform": "tg",
|
||
"status": post.get("publication_status"),
|
||
"url": post.get("tg_publication_url"),
|
||
"error": post.get("publication_error"),
|
||
}
|
||
labels = {"tg": "TG", "vk": "VK", "max": "MAX", "site": "Сайт"}
|
||
status_labels = {
|
||
"published": "опубликовано",
|
||
"publish_failed": "ошибка",
|
||
"pending": "ожидает",
|
||
}
|
||
status_classes = {"published": "ok", "publish_failed": "bad", "pending": ""}
|
||
post["publication_platforms"] = [
|
||
{
|
||
**platforms.get(platform, {"platform": platform, "status": "pending"}),
|
||
"label": label,
|
||
"status_label": status_labels.get(str(platforms.get(platform, {}).get("status") or "pending"), str(platforms.get(platform, {}).get("status") or "pending")),
|
||
"class": status_classes.get(str(platforms.get(platform, {}).get("status") or "pending"), ""),
|
||
}
|
||
for platform, label in labels.items()
|
||
]
|
||
post["editorial_status_label"] = next(
|
||
(item["label"] for item in EDITOR_STATUS_OPTIONS if item["value"] == (post.get("editorial_status") or "review")),
|
||
post.get("editorial_status") or "review",
|
||
)
|
||
post["reviewed_at_fmt"] = format_dt(post.get("reviewed_at"))
|
||
post["edited_at_fmt"] = format_dt(post.get("edited_at"))
|
||
return post
|
||
|
||
|
||
async def resolve_category_for_editor(name: str, category_id: int | None = None) -> dict[str, Any]:
|
||
pool = await get_pool()
|
||
row = None
|
||
if category_id is not None:
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT sort_order AS id, name, tag
|
||
FROM content_categories
|
||
WHERE sort_order=$1
|
||
LIMIT 1
|
||
""",
|
||
category_id,
|
||
)
|
||
if row is None:
|
||
raw_name = (name or "").strip().strip("#")
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT sort_order AS id, name, tag
|
||
FROM content_categories
|
||
WHERE lower(name)=lower($1) OR lower(tag)=lower($1)
|
||
ORDER BY is_active DESC, sort_order, name
|
||
LIMIT 1
|
||
""",
|
||
raw_name,
|
||
)
|
||
if row:
|
||
return {"id": int(row["id"]), "name": str(row["name"]), "tag": normalize_hash_tag(str(row["tag"]), "category")}
|
||
fallback_name = (name or "").strip().strip("#")
|
||
return {"id": category_id, "name": fallback_name, "tag": normalize_hash_tag(fallback_name, "category")}
|
||
|
||
|
||
def uploaded_editor_media_path(url: str) -> Path | None:
|
||
raw = str(url or "").strip()
|
||
prefix = "/uploads/editor_media/"
|
||
if not raw.startswith(prefix):
|
||
return None
|
||
name = Path(raw.removeprefix(prefix)).name
|
||
if not name:
|
||
return None
|
||
path = (EDITOR_MEDIA_DIR / name).resolve()
|
||
try:
|
||
path.relative_to(EDITOR_MEDIA_DIR.resolve())
|
||
except ValueError:
|
||
return None
|
||
return path
|
||
|
||
|
||
async def writer_categories() -> list[str]:
|
||
pool = await get_pool()
|
||
rows = await pool.fetch(
|
||
"""
|
||
SELECT name
|
||
FROM content_categories
|
||
WHERE is_active=TRUE
|
||
ORDER BY sort_order, name
|
||
"""
|
||
)
|
||
categories = [str(row["name"]) for row in rows]
|
||
if categories:
|
||
return categories
|
||
legacy_categories = parse_categories(await fetch_setting("ai_writer_categories", []))
|
||
return legacy_categories or [
|
||
"защита",
|
||
"одежда",
|
||
"разгрузка",
|
||
"рюкзаки",
|
||
"airsoft",
|
||
"патчи",
|
||
"электроника",
|
||
"аксессуары",
|
||
"производство",
|
||
]
|
||
|
||
|
||
async def writer_category_options() -> list[dict[str, str]]:
|
||
pool = await get_pool()
|
||
rows = await pool.fetch(
|
||
"""
|
||
SELECT name, tag
|
||
FROM content_categories
|
||
WHERE is_active=TRUE
|
||
ORDER BY sort_order, name
|
||
"""
|
||
)
|
||
options = [
|
||
{"name": str(row["name"]), "tag": normalize_hash_tag(str(row["tag"]), "category")}
|
||
for row in rows
|
||
]
|
||
if options:
|
||
return options
|
||
return [{"name": name, "tag": normalize_hash_tag(name, "category")} for name in await writer_categories()]
|
||
|
||
|
||
def normalize_tg_schedule(value: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(value, list):
|
||
value = []
|
||
rows: list[dict[str, Any]] = []
|
||
for idx, item in enumerate(value):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
row_id = str(item.get("id") or f"slot-{idx + 1}").strip()[:80]
|
||
time_value = str(item.get("time") or "").strip()
|
||
count = item.get("count") or 1
|
||
try:
|
||
count_int = clamp_int(int(count), 1, 20)
|
||
except Exception:
|
||
count_int = 1
|
||
if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", time_value):
|
||
time_value = "10:00"
|
||
rows.append({"id": row_id, "time": time_value, "count": count_int, "enabled": bool(item.get("enabled", True))})
|
||
return sorted(rows, key=lambda row: row["time"])
|
||
|
||
|
||
async def tg_poster_schedule_rows() -> list[dict[str, Any]]:
|
||
value = await fetch_setting("tg_poster_schedule_json", [])
|
||
rows = normalize_tg_schedule(value)
|
||
if rows:
|
||
return rows
|
||
return [
|
||
{"id": "morning", "time": "10:00", "count": 1, "enabled": True},
|
||
{"id": "evening", "time": "18:00", "count": 1, "enabled": True},
|
||
]
|
||
|
||
|
||
async def vk_poster_schedule_rows() -> list[dict[str, Any]]:
|
||
value = await fetch_setting("vk_poster_schedule_json", [])
|
||
rows = normalize_tg_schedule(value)
|
||
if rows:
|
||
return rows
|
||
return [
|
||
{"id": "vk-morning", "time": "10:05", "count": 1, "enabled": True},
|
||
{"id": "vk-evening", "time": "18:05", "count": 1, "enabled": True},
|
||
]
|
||
|
||
|
||
async def save_tg_poster_schedule(rows: list[dict[str, Any]]) -> None:
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
|
||
VALUES(
|
||
'tg_poster_schedule_json',
|
||
$1::jsonb,
|
||
'json',
|
||
'TG publication schedule',
|
||
'EKB time schedule rows: id, time HH:MM, count, enabled.',
|
||
'TG Poster'
|
||
)
|
||
ON CONFLICT (key) DO UPDATE
|
||
SET value_json=EXCLUDED.value_json,
|
||
updated_at=NOW()
|
||
""",
|
||
json.dumps(normalize_tg_schedule(rows), ensure_ascii=False),
|
||
)
|
||
|
||
|
||
async def save_vk_poster_schedule(rows: list[dict[str, Any]]) -> None:
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
|
||
VALUES(
|
||
'vk_poster_schedule_json',
|
||
$1::jsonb,
|
||
'json',
|
||
'VK publication schedule',
|
||
'EKB time schedule rows: id, time HH:MM, count, enabled.',
|
||
'VK Poster'
|
||
)
|
||
ON CONFLICT (key) DO UPDATE
|
||
SET value_json=EXCLUDED.value_json,
|
||
updated_at=NOW()
|
||
""",
|
||
json.dumps(normalize_tg_schedule(rows), ensure_ascii=False),
|
||
)
|
||
|
||
|
||
async def writer_category_payload() -> list[dict[str, Any]]:
|
||
pool = await get_pool()
|
||
rows = await pool.fetch(
|
||
"""
|
||
SELECT sort_order AS id, name, tag
|
||
FROM content_categories
|
||
WHERE is_active=TRUE
|
||
ORDER BY sort_order, name
|
||
"""
|
||
)
|
||
categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows]
|
||
if categories:
|
||
return categories
|
||
return [
|
||
{"id": idx + 1, "name": name, "tag": normalize_hash_tag(name, "category")}
|
||
for idx, name in enumerate(await writer_categories())
|
||
]
|
||
|
||
|
||
async def category_rows() -> list[dict[str, Any]]:
|
||
pool = await get_pool()
|
||
rows = await pool.fetch(
|
||
"""
|
||
SELECT id, name, tag, site_name, site_slug, site_enabled,
|
||
is_active, sort_order, created_at, updated_at
|
||
FROM content_categories
|
||
ORDER BY is_active DESC, sort_order, name
|
||
"""
|
||
)
|
||
result = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
item["updated_at_fmt"] = format_dt(item.get("updated_at"))
|
||
result.append(item)
|
||
return result
|
||
|
||
|
||
PROMPT_TEST_STATUS_OPTIONS = [
|
||
{"value": "all", "label": "Все"},
|
||
{"value": "qualified", "label": "Оцененные"},
|
||
{"value": "accepted", "label": "Принятые"},
|
||
{"value": "maybe", "label": "Спорные"},
|
||
{"value": "rejected", "label": "Отклоненные"},
|
||
{"value": "pending", "label": "Ждут AI"},
|
||
]
|
||
|
||
|
||
def normalize_prompt_test_status(value: str | None) -> str:
|
||
value = str(value or "all").strip().lower()
|
||
allowed = {item["value"] for item in PROMPT_TEST_STATUS_OPTIONS}
|
||
return value if value in allowed else "all"
|
||
|
||
|
||
def prompt_test_status_where(status_filter: str) -> str:
|
||
if status_filter == "qualified":
|
||
return "AND rp.qualification_status IN ('accepted', 'maybe', 'rejected')"
|
||
if status_filter in {"accepted", "maybe", "rejected", "pending"}:
|
||
return f"AND rp.qualification_status = '{status_filter}'"
|
||
return ""
|
||
|
||
|
||
async def prompt_test_posts(
|
||
selected_post_id: int | None = None,
|
||
status_filter: str = "all",
|
||
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
||
pool = await get_pool()
|
||
status_filter = normalize_prompt_test_status(status_filter)
|
||
where_sql = prompt_test_status_where(status_filter)
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
|
||
rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
|
||
rp.qualification_reason,
|
||
(SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
|
||
(SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
|
||
FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE TRUE
|
||
{where_sql}
|
||
ORDER BY rp.created_at DESC, rp.id DESC
|
||
LIMIT 150
|
||
"""
|
||
)
|
||
posts = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
text = str(item.get("raw_text") or "").strip()
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
item["posted_at_fmt"] = format_dt(item.get("posted_at"))
|
||
item["label"] = f"#{item['id']} · {item.get('source_name') or 'source'} · {item['created_at_fmt']}"
|
||
item["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
|
||
posts.append(item)
|
||
selected = next((item for item in posts if int(item["id"]) == int(selected_post_id or 0)), None)
|
||
if not selected and selected_post_id:
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
|
||
rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
|
||
rp.qualification_reason,
|
||
(SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
|
||
(SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
|
||
FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE rp.id=$1
|
||
""",
|
||
selected_post_id,
|
||
)
|
||
if row:
|
||
selected = dict(row)
|
||
text = str(selected.get("raw_text") or "").strip()
|
||
selected["created_at_fmt"] = format_dt(selected.get("created_at"))
|
||
selected["posted_at_fmt"] = format_dt(selected.get("posted_at"))
|
||
selected["label"] = f"#{selected['id']} · {selected.get('source_name') or 'source'} · {selected['created_at_fmt']}"
|
||
selected["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
|
||
posts.insert(0, selected)
|
||
if not selected and posts:
|
||
selected = posts[0]
|
||
return posts, selected
|
||
|
||
|
||
def prompt_test_payload(post: dict[str, Any], max_text_chars: int) -> dict[str, Any]:
|
||
return {
|
||
"task": "rewrite_accepted_posts",
|
||
"categories": [],
|
||
"posts": [
|
||
{
|
||
"id": int(post["id"]),
|
||
"producer_name": post.get("source_name") or "",
|
||
"producer_tag": normalize_hash_tag(post.get("source_tag") or post.get("source_name") or "", "source"),
|
||
"original_url": post.get("original_url") or "",
|
||
"qualification_score": post.get("qualification_score"),
|
||
"qualification_reason": post.get("qualification_reason") or "",
|
||
"media_count": int(post.get("media_count") or 0),
|
||
"media_types": list(post.get("media_types") or []),
|
||
"text": str(post.get("raw_text") or "")[:max_text_chars],
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def prompt_test_first_rewrite(parsed: Any) -> dict[str, Any] | None:
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
rewrites = parsed.get("rewrites")
|
||
if not isinstance(rewrites, list) and isinstance(parsed.get("data"), dict):
|
||
rewrites = parsed["data"].get("rewrites")
|
||
if not isinstance(rewrites, list) or not rewrites:
|
||
return None
|
||
first = rewrites[0]
|
||
return first if isinstance(first, dict) else None
|
||
|
||
|
||
def prompt_test_preview_text(parsed: Any) -> str:
|
||
rewrite = prompt_test_first_rewrite(parsed)
|
||
return str((rewrite or {}).get("text") or "").strip()
|
||
|
||
|
||
def prompt_test_preview_category(parsed: Any) -> str:
|
||
rewrite = prompt_test_first_rewrite(parsed)
|
||
if not rewrite:
|
||
return ""
|
||
category_id = rewrite.get("category_id")
|
||
if category_id not in {None, ""}:
|
||
return f"category_id: {category_id}"
|
||
return str(rewrite.get("category") or "").strip()
|
||
|
||
|
||
def prompt_test_preview_notes(parsed: Any) -> str:
|
||
rewrite = prompt_test_first_rewrite(parsed)
|
||
notes = (rewrite or {}).get("notes")
|
||
return "" if notes in {None, ""} else str(notes).strip()
|
||
|
||
|
||
def setting_value(settings_rows: list[dict], key: str, default: Any = None) -> Any:
|
||
for row in settings_rows:
|
||
if row.get("key") == key:
|
||
return row.get("value_json", default)
|
||
return default
|
||
|
||
|
||
def setting_sort_key(item: dict) -> tuple[int, int, str]:
|
||
category = str(item.get("category") or "General")
|
||
key = str(item.get("key") or "")
|
||
keys = SETTING_ORDER.get(category, [])
|
||
pos = keys.index(key) if key in keys else 1000
|
||
return (CATEGORY_ORDER.get(category, 999), pos, key)
|
||
|
||
|
||
def model_label(model_id: str, name: str | None = None, context: int | None = None) -> str:
|
||
label = name or model_id
|
||
if context:
|
||
label = f"{label} · ctx {context}"
|
||
return label
|
||
|
||
|
||
async def fetch_openrouter_models() -> list[dict[str, str]]:
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=8)) as session:
|
||
async with session.get("https://openrouter.ai/api/v1/models") as resp:
|
||
resp.raise_for_status()
|
||
data = await resp.json()
|
||
models = []
|
||
for item in data.get("data", []):
|
||
model_id = str(item.get("id") or "").strip()
|
||
if not model_id:
|
||
continue
|
||
context = item.get("context_length")
|
||
models.append(
|
||
{
|
||
"value": f"openrouter/{model_id}",
|
||
"label": model_label(model_id, item.get("name"), context if isinstance(context, int) else None),
|
||
}
|
||
)
|
||
return models
|
||
|
||
|
||
async def fetch_anthropic_models(api_key: str) -> list[dict[str, str]]:
|
||
if not api_key:
|
||
return []
|
||
headers = {
|
||
"x-api-key": api_key,
|
||
"anthropic-version": "2023-06-01",
|
||
}
|
||
models: list[dict[str, str]] = []
|
||
url = "https://api.anthropic.com/v1/models"
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||
for _ in range(5):
|
||
async with session.get(url, headers=headers) as resp:
|
||
resp.raise_for_status()
|
||
data = await resp.json()
|
||
for item in data.get("data", []):
|
||
model_id = str(item.get("id") or "").strip()
|
||
if not model_id:
|
||
continue
|
||
display_name = item.get("display_name") or model_id
|
||
models.append({"value": f"anthropic/{model_id}", "label": f"{display_name} · live Anthropic"})
|
||
if not data.get("has_more"):
|
||
break
|
||
last_id = data.get("last_id")
|
||
if not last_id:
|
||
break
|
||
url = f"https://api.anthropic.com/v1/models?after_id={last_id}"
|
||
return models
|
||
|
||
|
||
async def fetch_openai_models(api_key: str) -> list[dict[str, str]]:
|
||
if not api_key:
|
||
return []
|
||
headers = {"Authorization": f"Bearer {api_key}"}
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||
async with session.get("https://api.openai.com/v1/models", headers=headers) as resp:
|
||
resp.raise_for_status()
|
||
data = await resp.json()
|
||
models = []
|
||
for item in data.get("data", []):
|
||
model_id = str(item.get("id") or "").strip()
|
||
if not model_id:
|
||
continue
|
||
if model_id.startswith(("gpt-", "o1", "o3", "o4")):
|
||
models.append({"value": model_id, "label": f"{model_id} · live OpenAI"})
|
||
return sorted(models, key=lambda item: item["value"])
|
||
|
||
|
||
def litellm_catalog_models(provider: str) -> list[dict[str, str]]:
|
||
try:
|
||
import litellm
|
||
except Exception:
|
||
return []
|
||
|
||
catalog = getattr(litellm, "model_cost", {}) or {}
|
||
models: list[dict[str, str]] = []
|
||
for model_id, meta in catalog.items():
|
||
if not isinstance(model_id, str):
|
||
continue
|
||
normalized = model_id
|
||
if provider == "openai":
|
||
if "/" in model_id or not model_id.startswith(("gpt-", "o1", "o3", "o4")):
|
||
continue
|
||
elif provider == "anthropic":
|
||
if model_id.startswith("anthropic/"):
|
||
normalized = model_id
|
||
elif model_id.startswith("claude"):
|
||
normalized = f"anthropic/{model_id}"
|
||
else:
|
||
continue
|
||
elif provider == "gemini":
|
||
if model_id.startswith("gemini/"):
|
||
normalized = model_id
|
||
elif model_id.startswith("gemini"):
|
||
normalized = f"gemini/{model_id}"
|
||
else:
|
||
continue
|
||
else:
|
||
continue
|
||
context = meta.get("max_input_tokens") if isinstance(meta, dict) else None
|
||
models.append(
|
||
{
|
||
"value": normalized,
|
||
"label": f"{model_label(normalized, context=context if isinstance(context, int) else None)} · LiteLLM catalog",
|
||
}
|
||
)
|
||
unique = {m["value"]: m for m in models}
|
||
preferred = PREFERRED_MODELS.get(provider, [])
|
||
|
||
def sort_key(item: dict[str, str]) -> tuple[int, str]:
|
||
value = item["value"]
|
||
if value in preferred:
|
||
return (preferred.index(value), "")
|
||
return (1000, item["label"].lower())
|
||
|
||
return sorted(unique.values(), key=sort_key)
|
||
|
||
|
||
def sort_model_options(provider: str, models: list[dict[str, str]]) -> list[dict[str, str]]:
|
||
preferred = PREFERRED_MODELS.get(provider, [])
|
||
|
||
def sort_key(item: dict[str, str]) -> tuple[int, str]:
|
||
value = item["value"]
|
||
if value in preferred:
|
||
return (preferred.index(value), "")
|
||
return (1000, item["label"].lower())
|
||
|
||
unique = {m["value"]: m for m in models}
|
||
return sorted(unique.values(), key=sort_key)
|
||
|
||
|
||
async def model_options_for_provider(provider: str, api_key: str = "") -> list[dict[str, str]]:
|
||
provider = (provider or "openrouter").strip().lower()
|
||
now = time.time()
|
||
cache_key = f"{provider}:{hashlib.sha256((api_key or '').encode('utf-8')).hexdigest()[:10]}"
|
||
if MODEL_CACHE["key"] == cache_key and now - float(MODEL_CACHE["at"]) < 3600:
|
||
return list(MODEL_CACHE["models"])
|
||
|
||
models: list[dict[str, str]] = []
|
||
if provider == "openrouter":
|
||
try:
|
||
models = await fetch_openrouter_models()
|
||
except Exception as exc:
|
||
logger.warning("OpenRouter model list failed: {}", exc)
|
||
elif provider == "anthropic":
|
||
try:
|
||
models = await fetch_anthropic_models(api_key)
|
||
except Exception as exc:
|
||
logger.warning("Anthropic model list failed: {}", exc)
|
||
elif provider == "openai":
|
||
try:
|
||
models = await fetch_openai_models(api_key)
|
||
except Exception as exc:
|
||
logger.warning("OpenAI model list failed: {}", exc)
|
||
if not models:
|
||
models = litellm_catalog_models(provider)
|
||
if not models:
|
||
models = [{"value": m, "label": f"{m} · fallback"} for m in MODEL_FALLBACKS.get(provider, [])]
|
||
|
||
models = sort_model_options(provider, models)
|
||
MODEL_CACHE.update({"key": cache_key, "at": now, "models": models})
|
||
return list(models)
|
||
|
||
|
||
async def prune_audit_log() -> None:
|
||
pool = await get_pool()
|
||
await pool.execute("DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '30 days'")
|
||
|
||
|
||
def request_audit_meta(request: Request | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
meta = dict(extra or {})
|
||
if request:
|
||
meta.update(
|
||
{
|
||
"ip": client_ip(request),
|
||
"method": request.method,
|
||
"path": request.url.path,
|
||
"query": str(request.url.query),
|
||
"user_agent": (request.headers.get("user-agent") or "")[:300],
|
||
}
|
||
)
|
||
return meta
|
||
|
||
|
||
async def audit(
|
||
actor_id: int | None,
|
||
action: str,
|
||
entity_type: str,
|
||
entity_id: int | None,
|
||
after: Any = None,
|
||
request: Request | None = None,
|
||
) -> None:
|
||
pool = await get_pool()
|
||
payload = request_audit_meta(request, after if isinstance(after, dict) else {"value": after} if after is not None else None)
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO audit_log(actor_id, action, entity_type, entity_id, after_json)
|
||
VALUES($1, $2, $3, $4, $5::jsonb)
|
||
""",
|
||
actor_id,
|
||
action,
|
||
entity_type,
|
||
entity_id,
|
||
json.dumps(payload, ensure_ascii=False) if payload else None,
|
||
)
|
||
|
||
|
||
def prepare_audit_row(row: Any) -> dict[str, Any]:
|
||
item = dict(row)
|
||
payload = item.get("after_json")
|
||
if isinstance(payload, str):
|
||
try:
|
||
payload = json.loads(payload)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
item["actor_label"] = item.get("actor_login") or (f"user #{item['actor_id']}" if item.get("actor_id") else "system")
|
||
item["details_pretty"] = json.dumps(payload, ensure_ascii=False, indent=2, default=str) if payload is not None else ""
|
||
return item
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def startup() -> None:
|
||
await bootstrap_admin()
|
||
await prune_audit_log()
|
||
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict[str, str]:
|
||
return {"ok": "true"}
|
||
|
||
|
||
@app.get("/vk/oauth/callback", response_class=HTMLResponse)
|
||
async def vk_oauth_callback(request: Request, code: str = "", error: str = "", error_description: str = ""):
|
||
return templates.TemplateResponse(
|
||
"vk_oauth_callback.html",
|
||
base_context(
|
||
request,
|
||
None,
|
||
code=code,
|
||
code_verifier=request.cookies.get(VK_OAUTH_VERIFIER_COOKIE, ""),
|
||
device_id=request.query_params.get("device_id", ""),
|
||
state=request.query_params.get("state", ""),
|
||
expected_state=request.cookies.get(VK_OAUTH_STATE_COOKIE, ""),
|
||
error=error,
|
||
error_description=error_description,
|
||
),
|
||
)
|
||
|
||
|
||
@app.get("/vk/oauth/start")
|
||
async def vk_oauth_start() -> RedirectResponse:
|
||
verifier = secrets.token_urlsafe(64)
|
||
state = secrets.token_urlsafe(24)
|
||
redirect_uri = "https://sw.exostring.xyz/vk/oauth/callback"
|
||
params = {
|
||
"client_id": "54635120",
|
||
"redirect_uri": redirect_uri,
|
||
"response_type": "code",
|
||
"scope": "wall photos video groups offline",
|
||
"state": state,
|
||
"code_challenge": pkce_challenge(verifier),
|
||
"code_challenge_method": "s256",
|
||
"origin": "https://sw.exostring.xyz",
|
||
"v": "5.199",
|
||
}
|
||
response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}")
|
||
response.set_cookie(VK_OAUTH_VERIFIER_COOKIE, verifier, httponly=True, samesite="lax", max_age=15 * 60)
|
||
response.set_cookie(VK_OAUTH_STATE_COOKIE, state, httponly=True, samesite="lax", max_age=15 * 60)
|
||
return response
|
||
|
||
|
||
@app.get("/vk/group-oauth/start")
|
||
async def vk_group_oauth_start() -> RedirectResponse:
|
||
group_id = abs(int(settings.vk_storage_group_id))
|
||
params = {
|
||
"client_id": "54635120",
|
||
"group_ids": str(group_id),
|
||
"display": "page",
|
||
"redirect_uri": "https://sw.exostring.xyz/vk/oauth/callback",
|
||
"scope": "manage,photos,docs",
|
||
"response_type": "token",
|
||
"state": secrets.token_urlsafe(24),
|
||
"v": "5.199",
|
||
}
|
||
return RedirectResponse(f"https://oauth.vk.com/authorize?{urlencode(params)}")
|
||
|
||
|
||
@app.get("/vk-oauth/start")
|
||
async def vk_poster_oauth_start(request: Request) -> RedirectResponse:
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
client_id = str(await fetch_setting("vk_poster_app_id", "") or "").strip()
|
||
if not client_id:
|
||
return redirect("/workers")
|
||
verifier = secrets.token_urlsafe(64)
|
||
state = secrets.token_urlsafe(24)
|
||
redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback"
|
||
params = {
|
||
"client_id": client_id,
|
||
"redirect_uri": redirect_uri,
|
||
"response_type": "code",
|
||
"scope": "wall photos",
|
||
"state": state,
|
||
"code_challenge": pkce_challenge(verifier),
|
||
"code_challenge_method": "s256",
|
||
"origin": "https://sw.exostring.xyz",
|
||
"v": "5.199",
|
||
}
|
||
response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}")
|
||
response.set_cookie(VK_OAUTH_VERIFIER_COOKIE, verifier, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=15 * 60)
|
||
response.set_cookie(VK_OAUTH_STATE_COOKIE, state, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=15 * 60)
|
||
return response
|
||
|
||
|
||
@app.get("/vk-oauth/callback", response_class=HTMLResponse)
|
||
async def vk_poster_oauth_callback(
|
||
request: Request,
|
||
code: str = "",
|
||
state: str = "",
|
||
error: str = "",
|
||
error_description: str = "",
|
||
):
|
||
user = await get_current_user(request)
|
||
result: dict[str, Any] = {
|
||
"ok": False,
|
||
"error": error,
|
||
"error_description": error_description,
|
||
"state_ok": bool(state and state == request.cookies.get(VK_OAUTH_STATE_COOKIE, "")),
|
||
}
|
||
if not user:
|
||
result.update({"error": "auth_required", "error_description": "Сначала войдите в админку, затем повторите VK OAuth."})
|
||
elif error:
|
||
pass
|
||
elif not code:
|
||
result.update({"error": "missing_code", "error_description": "VK callback пришёл без code."})
|
||
elif not result["state_ok"]:
|
||
result.update({"error": "state_mismatch", "error_description": "OAuth state не совпал. Повторите авторизацию из админки."})
|
||
else:
|
||
pool = await get_pool()
|
||
client_id = str(await fetch_setting("vk_poster_app_id", "") or "").strip()
|
||
client_secret = str(await fetch_setting("vk_poster_client_secret", "") or "").strip()
|
||
owner_id = int(await fetch_int_setting("vk_poster_owner_id", 0))
|
||
redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback"
|
||
code_verifier = request.cookies.get(VK_OAUTH_VERIFIER_COOKIE, "")
|
||
device_id = request.query_params.get("device_id", "")
|
||
try:
|
||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||
async with session.post(
|
||
"https://id.vk.ru/oauth2/auth",
|
||
data={
|
||
"grant_type": "authorization_code",
|
||
"client_id": client_id,
|
||
"client_secret": client_secret,
|
||
"redirect_uri": redirect_uri,
|
||
"code": code,
|
||
"code_verifier": code_verifier,
|
||
"device_id": device_id,
|
||
"state": state,
|
||
},
|
||
) as resp:
|
||
token_payload = await resp.json(content_type=None)
|
||
if token_payload.get("error"):
|
||
raise RuntimeError(f"{token_payload.get('error')}: {token_payload.get('error_description')}")
|
||
access_token = str(token_payload.get("access_token") or "").strip()
|
||
if not access_token:
|
||
raise RuntimeError(f"VK did not return access_token: {token_payload}")
|
||
refresh_token = str(token_payload.get("refresh_token") or "").strip()
|
||
expires_in = token_payload.get("expires_in")
|
||
expires_at = None
|
||
try:
|
||
expires_at = (now_utc() + timedelta(seconds=int(expires_in))).isoformat() if expires_in is not None else None
|
||
except Exception:
|
||
expires_at = None
|
||
updates = {
|
||
"vk_poster_access_token": access_token,
|
||
"vk_poster_refresh_token": refresh_token,
|
||
"vk_poster_token_device_id": str(token_payload.get("device_id") or device_id or ""),
|
||
"vk_poster_token_expires_at": expires_at or "",
|
||
}
|
||
for key, value in updates.items():
|
||
await pool.execute(
|
||
"""
|
||
UPDATE app_settings
|
||
SET value_json=$2::jsonb,
|
||
updated_by=$3,
|
||
updated_at=NOW()
|
||
WHERE key=$1
|
||
""",
|
||
key,
|
||
json.dumps(value),
|
||
user["id"],
|
||
)
|
||
check: dict[str, Any] = {}
|
||
async with VKAPIClient(token=access_token, rps=2, timeout_total_sec=30, retry_attempts=1) as client:
|
||
try:
|
||
upload_url = await client.get_wall_upload_server(abs(owner_id))
|
||
check["photo_upload_server_ok"] = bool(upload_url)
|
||
except Exception as exc:
|
||
check["photo_upload_server_ok"] = False
|
||
check["photo_upload_server_error"] = str(exc)
|
||
try:
|
||
response = await client.call("wall.get", owner_id=owner_id, count=1, filter="owner")
|
||
check["wall_get_ok"] = isinstance(response, dict)
|
||
except Exception as exc:
|
||
check["wall_get_ok"] = False
|
||
check["wall_get_error"] = str(exc)
|
||
await audit(
|
||
user["id"],
|
||
"vk_oauth.token_saved",
|
||
"vk_poster",
|
||
None,
|
||
{
|
||
"owner_id": owner_id,
|
||
"user_id": token_payload.get("user_id"),
|
||
"expires_in": token_payload.get("expires_in"),
|
||
"has_refresh_token": bool(refresh_token),
|
||
"expires_at": expires_at,
|
||
**check,
|
||
},
|
||
request,
|
||
)
|
||
result.update(
|
||
{
|
||
"ok": True,
|
||
"error": "",
|
||
"error_description": "",
|
||
"owner_id": owner_id,
|
||
"user_id": token_payload.get("user_id"),
|
||
"expires_in": token_payload.get("expires_in"),
|
||
"has_refresh_token": bool(refresh_token),
|
||
"expires_at": expires_at,
|
||
**check,
|
||
}
|
||
)
|
||
except Exception as exc:
|
||
result.update({"error": "token_exchange_failed", "error_description": str(exc)})
|
||
response = templates.TemplateResponse("vk_oauth_callback.html", base_context(request, user, result=result))
|
||
response.delete_cookie(VK_OAUTH_STATE_COOKIE)
|
||
response.delete_cookie(VK_OAUTH_VERIFIER_COOKIE)
|
||
return response
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def index(request: Request):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
return redirect("/sources")
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
async def login_get(request: Request):
|
||
return templates.TemplateResponse("login.html", base_context(request, None, error=""))
|
||
|
||
|
||
@app.post("/login", response_class=HTMLResponse)
|
||
async def login_post(request: Request, login: str = Form(...), password: str = Form(...)):
|
||
pool = await get_pool()
|
||
login_value = login.strip().lower()
|
||
ip = client_ip(request)
|
||
await pool.execute("DELETE FROM admin_login_attempts WHERE created_at < NOW() - INTERVAL '7 days'")
|
||
ip_failures = int(
|
||
await pool.fetchval(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM admin_login_attempts
|
||
WHERE ip=$1 AND success=FALSE AND created_at > NOW() - INTERVAL '15 minutes'
|
||
""",
|
||
ip,
|
||
)
|
||
or 0
|
||
)
|
||
login_failures = int(
|
||
await pool.fetchval(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM admin_login_attempts
|
||
WHERE login=$1 AND success=FALSE AND created_at > NOW() - INTERVAL '15 minutes'
|
||
""",
|
||
login_value,
|
||
)
|
||
or 0
|
||
)
|
||
if ip_failures >= 10 or login_failures >= 5:
|
||
await pool.execute(
|
||
"INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, FALSE)",
|
||
ip,
|
||
login_value,
|
||
)
|
||
await audit(None, "auth.login_blocked", "admin_login", None, {"login": login_value}, request)
|
||
return templates.TemplateResponse(
|
||
"login.html",
|
||
base_context(request, None, error="Слишком много попыток. Подожди 15 минут и попробуй снова."),
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
row = await pool.fetchrow(
|
||
"SELECT id, login, password_hash, role, is_active FROM admin_users WHERE login=$1",
|
||
login_value,
|
||
)
|
||
if not row or not row["is_active"] or not verify_password(password, row["password_hash"]):
|
||
await pool.execute(
|
||
"INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, FALSE)",
|
||
ip,
|
||
login_value,
|
||
)
|
||
await audit(None, "auth.login_failed", "admin_login", None, {"login": login_value}, request)
|
||
return templates.TemplateResponse("login.html", base_context(request, None, error="Неверный логин или пароль"))
|
||
|
||
await pool.execute("INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, TRUE)", ip, login_value)
|
||
token = new_token()
|
||
csrf = new_token()
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO admin_sessions(token_hash, user_id, csrf_token, expires_at)
|
||
VALUES($1, $2, $3, $4)
|
||
""",
|
||
token_hash(token, settings.app_secret_key),
|
||
row["id"],
|
||
csrf,
|
||
now_utc() + timedelta(days=14),
|
||
)
|
||
await audit(row["id"], "auth.login_success", "admin_user", int(row["id"]), {"login": login_value}, request)
|
||
response = redirect("/sources")
|
||
response.set_cookie(COOKIE_NAME, token, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=14 * 24 * 3600)
|
||
return response
|
||
|
||
|
||
@app.post("/logout")
|
||
async def logout(request: Request):
|
||
token = request.cookies.get(COOKIE_NAME)
|
||
user = await get_current_user(request) if token else None
|
||
if token:
|
||
pool = await get_pool()
|
||
await pool.execute("DELETE FROM admin_sessions WHERE token_hash=$1", token_hash(token, settings.app_secret_key))
|
||
if user:
|
||
await audit(user["id"], "auth.logout", "admin_user", int(user["id"]), None, request)
|
||
response = redirect("/login")
|
||
response.delete_cookie(COOKIE_NAME)
|
||
return response
|
||
|
||
|
||
@app.get("/sources", response_class=HTMLResponse)
|
||
async def sources_list(request: Request, q: str = "", status_filter: str = "", active_filter: str = "", page: int = 1, per_page: int = 50):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
table_filters = parse_table_filters(request, SOURCE_FIELD_SPECS)
|
||
table_sorts = parse_table_sorts(request, SOURCE_FIELD_SPECS)
|
||
filter_clauses, filter_args = build_filter_sql(table_filters, SOURCE_FIELD_SPECS, 4)
|
||
where_parts = [
|
||
"s.archived_at IS NULL",
|
||
"($1='' OR s.name ILIKE '%' || $1 || '%' OR COALESCE(s.tag,'') ILIKE '%' || $1 || '%' OR s.url ILIKE '%' || $1 || '%' OR COALESCE(s.external_id,'') ILIKE '%' || $1 || '%')",
|
||
"($2='' OR s.status=$2)",
|
||
"($3='' OR ($3='on' AND s.active=TRUE) OR ($3='off' AND s.active=FALSE))",
|
||
*filter_clauses,
|
||
]
|
||
where_sql = " AND ".join(where_parts)
|
||
base_args = [q.strip(), status_filter.strip(), active_filter.strip(), *filter_args]
|
||
total = int(
|
||
await pool.fetchval(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM sources s
|
||
WHERE {where_sql}
|
||
""",
|
||
*base_args,
|
||
)
|
||
or 0
|
||
)
|
||
pager = pagination(page, per_page, total)
|
||
limit_index = len(base_args) + 1
|
||
offset_index = len(base_args) + 2
|
||
order_sql = build_sort_sql(table_sorts, SOURCE_FIELD_SPECS, "s.active DESC, s.priority ASC, s.id DESC")
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT s.*,
|
||
COUNT(rp.id) AS posts_count,
|
||
COUNT(rp.id) FILTER (WHERE rp.created_at > NOW() - INTERVAL '24 hours') AS posts_24h
|
||
FROM sources s
|
||
LEFT JOIN raw_posts rp ON rp.source_id=s.id
|
||
WHERE {where_sql}
|
||
GROUP BY s.id
|
||
ORDER BY {order_sql}
|
||
LIMIT ${limit_index} OFFSET ${offset_index}
|
||
""",
|
||
*base_args,
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
sources = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at"))
|
||
item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at"))
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
sources.append(item)
|
||
template_name = "sources_content.html" if request.headers.get("hx-request") else "sources.html"
|
||
return templates.TemplateResponse(
|
||
template_name,
|
||
base_context(
|
||
request,
|
||
user,
|
||
sources=sources,
|
||
q=q,
|
||
status_filter=status_filter,
|
||
active_filter=active_filter,
|
||
pagination=pager,
|
||
table_filters=table_filters,
|
||
table_sorts=table_sorts,
|
||
field_options=field_options(SOURCE_FIELD_SPECS),
|
||
filter_ops=FILTER_OPS,
|
||
filter_op_options=FILTER_OP_OPTIONS,
|
||
source_preview=[],
|
||
bulk_text="",
|
||
bulk_active=True,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/sources/preview", response_class=HTMLResponse)
|
||
async def sources_preview(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
bulk_text: str = Form(""),
|
||
bulk_active: str = Form("off"),
|
||
q: str = Form(""),
|
||
status_filter: str = Form(""),
|
||
per_page: int = Form(50),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
preview = await build_sources_preview(bulk_text, default_active=bulk_active == "on")
|
||
pool = await get_pool()
|
||
table_filters = parse_table_filters(request, SOURCE_FIELD_SPECS)
|
||
table_sorts = parse_table_sorts(request, SOURCE_FIELD_SPECS)
|
||
filter_clauses, filter_args = build_filter_sql(table_filters, SOURCE_FIELD_SPECS, 3)
|
||
where_parts = [
|
||
"s.archived_at IS NULL",
|
||
"($1='' OR s.name ILIKE '%' || $1 || '%' OR COALESCE(s.tag,'') ILIKE '%' || $1 || '%' OR s.url ILIKE '%' || $1 || '%' OR COALESCE(s.external_id,'') ILIKE '%' || $1 || '%')",
|
||
"($2='' OR s.status=$2)",
|
||
*filter_clauses,
|
||
]
|
||
where_sql = " AND ".join(where_parts)
|
||
base_args = [q.strip(), status_filter.strip(), *filter_args]
|
||
total = int(await pool.fetchval(f"SELECT COUNT(*) FROM sources s WHERE {where_sql}", *base_args) or 0)
|
||
pager = pagination(1, per_page, total)
|
||
limit_index = len(base_args) + 1
|
||
offset_index = len(base_args) + 2
|
||
order_sql = build_sort_sql(table_sorts, SOURCE_FIELD_SPECS, "s.active DESC, s.priority ASC, s.id DESC")
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT s.*,
|
||
COUNT(rp.id) AS posts_count,
|
||
COUNT(rp.id) FILTER (WHERE rp.created_at > NOW() - INTERVAL '24 hours') AS posts_24h
|
||
FROM sources s
|
||
LEFT JOIN raw_posts rp ON rp.source_id=s.id
|
||
WHERE {where_sql}
|
||
GROUP BY s.id
|
||
ORDER BY {order_sql}
|
||
LIMIT ${limit_index} OFFSET ${offset_index}
|
||
""",
|
||
*base_args,
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
sources = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at"))
|
||
item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at"))
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
sources.append(item)
|
||
return templates.TemplateResponse(
|
||
"sources.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
sources=sources,
|
||
q=q,
|
||
status_filter=status_filter,
|
||
pagination=pager,
|
||
table_filters=table_filters,
|
||
table_sorts=table_sorts,
|
||
field_options=field_options(SOURCE_FIELD_SPECS),
|
||
filter_ops=FILTER_OPS,
|
||
filter_op_options=FILTER_OP_OPTIONS,
|
||
source_preview=preview,
|
||
bulk_text=bulk_text,
|
||
bulk_active=bulk_active == "on",
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/sources/bulk")
|
||
async def sources_bulk_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
bulk_payload: str = Form("[]"),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
try:
|
||
items = json.loads(bulk_payload)
|
||
except json.JSONDecodeError:
|
||
items = []
|
||
pool = await get_pool()
|
||
created = 0
|
||
for item in items:
|
||
if not isinstance(item, dict) or not item.get("ok"):
|
||
continue
|
||
try:
|
||
row = await pool.fetchrow(
|
||
"""
|
||
INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, created_by)
|
||
VALUES($1, $2, $3, $4, $5, $6, $7, 100, $8)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id
|
||
""",
|
||
PLATFORM_VK,
|
||
str(item.get("name") or item.get("external_id") or "").strip(),
|
||
normalize_hash_tag(str(item.get("tag") or item.get("external_id") or ""), str(item.get("external_id") or "source")),
|
||
str(item.get("url") or "").strip(),
|
||
str(item.get("external_id") or "").strip(),
|
||
item.get("external_owner_id"),
|
||
bool(item.get("active", True)),
|
||
user["id"],
|
||
)
|
||
if row:
|
||
created += 1
|
||
await audit(user["id"], "source.create", "source", int(row["id"]), {"url": item.get("url"), "bulk": True})
|
||
except Exception:
|
||
logger.exception("Bulk source insert failed: {}", item)
|
||
return redirect(f"/sources?q=&status_filter=&created={created}")
|
||
|
||
|
||
@app.get("/sources/new", response_class=HTMLResponse)
|
||
async def source_new(request: Request):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
return templates.TemplateResponse(
|
||
"source_form.html",
|
||
base_context(request, user, source=None, action="/sources/new", title="Новый источник"),
|
||
)
|
||
|
||
|
||
@app.post("/sources/new")
|
||
async def source_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
platform: str = Form(PLATFORM_VK),
|
||
name: str = Form(...),
|
||
tag: str = Form(""),
|
||
url: str = Form(...),
|
||
active: str = Form("off"),
|
||
priority: int = Form(100),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
platform = platform.strip().lower() or PLATFORM_VK
|
||
external_id = normalize_vk_source(url) if platform == PLATFORM_VK else ""
|
||
external_owner_id = None
|
||
resolved_name = name.strip()
|
||
resolved_url = url.strip()
|
||
status_value = "new"
|
||
status_msg = None
|
||
if platform == PLATFORM_VK and external_id:
|
||
try:
|
||
async with VKAPIClient(rps=2, timeout_total_sec=12, timeout_connect_sec=5, retry_attempts=2) as client:
|
||
screen_name, owner_id, vk_name = await client.resolve_group(url)
|
||
external_id = screen_name
|
||
external_owner_id = owner_id
|
||
resolved_name = resolved_name or vk_name
|
||
resolved_url = f"https://vk.com/{screen_name}"
|
||
except Exception as exc:
|
||
resolved_name = resolved_name or external_id
|
||
status_value = "error"
|
||
status_msg = str(exc)[:500]
|
||
resolved_tag = normalize_hash_tag(tag or external_id or resolved_name, external_id or "source")
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, status, status_msg, created_by)
|
||
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id
|
||
""",
|
||
platform,
|
||
resolved_name,
|
||
resolved_tag,
|
||
resolved_url,
|
||
external_id,
|
||
external_owner_id,
|
||
active == "on",
|
||
priority,
|
||
status_value,
|
||
status_msg,
|
||
user["id"],
|
||
)
|
||
if row:
|
||
await audit(user["id"], "source.create", "source", int(row["id"]), {"url": url, "platform": platform})
|
||
return redirect("/sources")
|
||
|
||
|
||
@app.get("/sources/{source_id}/edit", response_class=HTMLResponse)
|
||
async def source_edit(request: Request, source_id: int):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
source = await pool.fetchrow("SELECT * FROM sources WHERE id=$1 AND archived_at IS NULL", source_id)
|
||
if not source:
|
||
return redirect("/sources")
|
||
return templates.TemplateResponse(
|
||
"source_form.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
source=dict(source),
|
||
action=f"/sources/{source_id}/edit",
|
||
title=f"Источник #{source_id}",
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/sources/{source_id}/edit")
|
||
async def source_update(
|
||
request: Request,
|
||
source_id: int,
|
||
csrf_token: str = Form(...),
|
||
platform: str = Form(PLATFORM_VK),
|
||
name: str = Form(...),
|
||
tag: str = Form(""),
|
||
url: str = Form(...),
|
||
active: str = Form("off"),
|
||
priority: int = Form(100),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
platform = platform.strip().lower() or PLATFORM_VK
|
||
external_id = normalize_vk_source(url) if platform == PLATFORM_VK else ""
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
UPDATE sources
|
||
SET platform=$2,
|
||
name=$3,
|
||
tag=$4,
|
||
url=$5,
|
||
external_id=$6,
|
||
active=$7,
|
||
priority=$8,
|
||
updated_at=NOW()
|
||
WHERE id=$1
|
||
""",
|
||
source_id,
|
||
platform,
|
||
name.strip(),
|
||
normalize_hash_tag(tag or external_id or name, external_id or "source"),
|
||
url.strip(),
|
||
external_id,
|
||
active == "on",
|
||
priority,
|
||
)
|
||
await audit(user["id"], "source.update", "source", source_id, {"url": url, "platform": platform})
|
||
return redirect("/sources")
|
||
|
||
|
||
@app.post("/sources/{source_id}/toggle")
|
||
async def source_toggle(request: Request, source_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
UPDATE sources
|
||
SET active=NOT active,
|
||
status=CASE WHEN active THEN 'paused' ELSE 'new' END,
|
||
updated_at=NOW()
|
||
WHERE id=$1
|
||
RETURNING active
|
||
""",
|
||
source_id,
|
||
)
|
||
await audit(user["id"], "source.toggle", "source", source_id, {"active": bool(row["active"]) if row else None})
|
||
if request.headers.get("hx-request"):
|
||
# Fetch the updated source
|
||
source_row = await pool.fetchrow("SELECT * FROM sources WHERE id=$1", source_id)
|
||
if source_row:
|
||
s = dict(source_row)
|
||
# Fetch stats just for this source to render correctly
|
||
stats = await pool.fetchrow(
|
||
"""
|
||
SELECT COUNT(*) AS total_posts,
|
||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') AS recent_posts
|
||
FROM raw_posts
|
||
WHERE source_id=$1
|
||
""",
|
||
source_id
|
||
)
|
||
s["posts_count"] = stats["total_posts"]
|
||
s["posts_24h"] = stats["recent_posts"]
|
||
s["last_parsed_at_fmt"] = s["last_parsed_at"].strftime("%d.%m.%Y %H:%M") if s.get("last_parsed_at") else None
|
||
return templates.TemplateResponse("source_row.html", base_context(request, user, s=s))
|
||
return redirect("/sources")
|
||
|
||
|
||
@app.post("/sources/{source_id}/delete")
|
||
async def source_delete(request: Request, source_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
await pool.execute("UPDATE sources SET archived_at=NOW(), active=FALSE, updated_at=NOW() WHERE id=$1", source_id)
|
||
await audit(user["id"], "source.archive", "source", source_id)
|
||
if request.headers.get("hx-request"):
|
||
return HTMLResponse("")
|
||
return redirect("/sources")
|
||
|
||
|
||
@app.get("/raw", response_class=HTMLResponse)
|
||
async def raw_posts(
|
||
request: Request,
|
||
status_filter: str = "",
|
||
date_from: str = "",
|
||
date_to: str = "",
|
||
score_min: str = "",
|
||
score_max: str = "",
|
||
sort: str = "created_desc",
|
||
page: int = 1,
|
||
per_page: int = 50,
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
order_map = {
|
||
"id_desc": "rp.id DESC",
|
||
"id_asc": "rp.id ASC",
|
||
"created_desc": "rp.created_at DESC",
|
||
"created_asc": "rp.created_at ASC",
|
||
"posted_desc": "rp.posted_at DESC NULLS LAST, rp.created_at DESC",
|
||
"posted_asc": "rp.posted_at ASC NULLS LAST, rp.created_at ASC",
|
||
"source_asc": "lower(s.name) ASC, rp.created_at DESC",
|
||
"source_desc": "lower(s.name) DESC, rp.created_at DESC",
|
||
"stage_asc": "rp.status ASC, COALESCE(rp.qualification_status, 'pending') ASC, COALESCE(rp.rewrite_status, 'pending') ASC, rp.created_at DESC",
|
||
"stage_desc": "rp.status DESC, COALESCE(rp.qualification_status, 'pending') DESC, COALESCE(rp.rewrite_status, 'pending') DESC, rp.created_at DESC",
|
||
"text_asc": "lower(rp.raw_text) ASC, rp.created_at DESC",
|
||
"text_desc": "lower(rp.raw_text) DESC, rp.created_at DESC",
|
||
"score_desc": "rp.qualification_score DESC NULLS LAST, rp.created_at DESC",
|
||
"score_asc": "rp.qualification_score ASC NULLS LAST, rp.created_at DESC",
|
||
}
|
||
raw_statuses = selected_values(request, "raw_status")
|
||
if status_filter.strip() and not raw_statuses:
|
||
raw_statuses = [status_filter.strip()]
|
||
source_ids = selected_int_values(request, "source_id")
|
||
categories_selected = selected_values(request, "category")
|
||
qualification_statuses = selected_values(request, "qualification_status")
|
||
rewrite_statuses = selected_values(request, "rewrite_status")
|
||
where_parts: list[str] = []
|
||
base_args: list[Any] = []
|
||
if raw_statuses:
|
||
add_where(where_parts, base_args, "rp.status = ANY(${i}::text[])", raw_statuses)
|
||
if source_ids:
|
||
add_where(where_parts, base_args, "rp.source_id = ANY(${i}::bigint[])", source_ids)
|
||
if categories_selected:
|
||
add_where(where_parts, base_args, "COALESCE(rp.final_category, rp.rewrite_category, '') = ANY(${i}::text[])", categories_selected)
|
||
if qualification_statuses:
|
||
add_where(where_parts, base_args, "COALESCE(rp.qualification_status, 'pending') = ANY(${i}::text[])", qualification_statuses)
|
||
if rewrite_statuses:
|
||
add_where(where_parts, base_args, "COALESCE(rp.rewrite_status, 'pending') = ANY(${i}::text[])", rewrite_statuses)
|
||
parsed_date_from = parse_date_filter(date_from)
|
||
parsed_date_to = parse_date_filter(date_to)
|
||
if parsed_date_from:
|
||
add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date >= ${i}::date", parsed_date_from)
|
||
if parsed_date_to:
|
||
add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date <= ${i}::date", parsed_date_to)
|
||
if score_min.strip().isdigit():
|
||
add_where(where_parts, base_args, "rp.qualification_score >= ${i}::int", int(score_min.strip()))
|
||
if score_max.strip().isdigit():
|
||
add_where(where_parts, base_args, "rp.qualification_score <= ${i}::int", int(score_max.strip()))
|
||
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
|
||
order_sql = order_map.get(sort, order_map["created_desc"])
|
||
total = int(
|
||
await pool.fetchval(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE {where_sql}
|
||
""",
|
||
*base_args,
|
||
)
|
||
or 0
|
||
)
|
||
pager = pagination(page, per_page, total)
|
||
limit_index = len(base_args) + 1
|
||
offset_index = len(base_args) + 2
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform,
|
||
COUNT(rpm.id) AS media_count,
|
||
COALESCE(
|
||
jsonb_agg(
|
||
jsonb_build_object(
|
||
'id', rpm.id,
|
||
'type', rpm.media_type,
|
||
'url', rpm.original_url,
|
||
'status', rpm.status,
|
||
'error', rpm.error,
|
||
'duration_sec', rpm.duration_sec
|
||
)
|
||
ORDER BY rpm.sort_order ASC, rpm.id ASC
|
||
) FILTER (WHERE rpm.id IS NOT NULL),
|
||
'[]'::jsonb
|
||
) AS media_items,
|
||
COALESCE(
|
||
(
|
||
SELECT jsonb_agg(
|
||
jsonb_build_object(
|
||
'platform', pp.platform,
|
||
'status', pp.status,
|
||
'target_id', pp.target_id,
|
||
'external_id', pp.external_id,
|
||
'url', pp.url,
|
||
'error', pp.error,
|
||
'published_at', pp.published_at
|
||
)
|
||
ORDER BY pp.platform
|
||
)
|
||
FROM post_publications pp
|
||
WHERE pp.raw_post_id=rp.id
|
||
),
|
||
'[]'::jsonb
|
||
) AS platform_publications
|
||
,
|
||
COALESCE(
|
||
(
|
||
SELECT jsonb_agg(
|
||
jsonb_build_object(
|
||
'platform', pp.platform,
|
||
'status', pp.status,
|
||
'target_id', pp.target_id,
|
||
'external_id', pp.external_id,
|
||
'url', pp.url,
|
||
'error', pp.error,
|
||
'published_at', pp.published_at
|
||
)
|
||
ORDER BY pp.platform
|
||
)
|
||
FROM post_publications pp
|
||
WHERE pp.raw_post_id=rp.id
|
||
),
|
||
'[]'::jsonb
|
||
) AS platform_publications
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_added, FALSE)=FALSE
|
||
WHERE {where_sql}
|
||
GROUP BY rp.id, s.name, s.tag, s.platform
|
||
ORDER BY {order_sql}
|
||
LIMIT ${limit_index} OFFSET ${offset_index}
|
||
""",
|
||
*base_args,
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
posts = [prepare_raw_post(row) for row in rows]
|
||
|
||
template_name = "raw_posts_content.html" if request.headers.get("hx-request") else "raw_posts.html"
|
||
|
||
return templates.TemplateResponse(
|
||
template_name,
|
||
base_context(
|
||
request,
|
||
user,
|
||
posts=posts,
|
||
raw_statuses=raw_statuses,
|
||
source_ids=source_ids,
|
||
categories_selected=categories_selected,
|
||
qualification_statuses=qualification_statuses,
|
||
rewrite_statuses=rewrite_statuses,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
score_min=score_min,
|
||
score_max=score_max,
|
||
sort=sort,
|
||
facets=await raw_filter_facets(pool),
|
||
preserved_query=preserved_query(request, {"page", "per_page"}),
|
||
sort_headers=raw_sort_headers(request, sort),
|
||
pagination=pager,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/raw/send-to-rewrite")
|
||
async def raw_send_to_rewrite(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
raw_post_id: list[int] | None = Form(default=None),
|
||
next_url: str = Form("/raw"),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
post_ids = sorted({int(post_id) for post_id in (raw_post_id or []) if int(post_id) > 0})
|
||
safe_next = next_url if next_url.startswith("/raw") and not next_url.startswith("//") else "/raw"
|
||
if not post_ids:
|
||
return redirect(safe_next)
|
||
pool = await get_pool()
|
||
updated_rows = await pool.fetch(
|
||
"""
|
||
WITH selected AS (
|
||
SELECT id
|
||
FROM raw_posts
|
||
WHERE id=ANY($1::bigint[])
|
||
AND status='storage_ready'
|
||
AND COALESCE(rewrite_status, 'pending') IN ('pending', 'failed')
|
||
FOR UPDATE
|
||
)
|
||
UPDATE raw_posts rp
|
||
SET qualification_status='accepted',
|
||
qualification_decision='accepted',
|
||
qualification_model_decision='manual',
|
||
qualification_reason='Ручная отправка в рерайт из Raw',
|
||
qualification_reject_tag=NULL,
|
||
qualified_at=NOW(),
|
||
rewrite_status='pending',
|
||
rewrite_notes=NULL,
|
||
rewrite_batch_id=NULL,
|
||
editorial_status=NULL,
|
||
updated_at=NOW()
|
||
FROM selected
|
||
WHERE rp.id=selected.id
|
||
RETURNING rp.id
|
||
""",
|
||
post_ids,
|
||
)
|
||
updated_ids = [int(row["id"]) for row in updated_rows]
|
||
await audit(
|
||
user["id"],
|
||
"raw.manual_send_to_rewrite",
|
||
"raw_post",
|
||
None,
|
||
{"selected_ids": post_ids, "updated_ids": updated_ids, "ignored_count": len(post_ids) - len(updated_ids)},
|
||
)
|
||
return redirect(safe_next)
|
||
|
||
|
||
async def fetch_single_editor_row(pool, post_id: int):
|
||
return await pool.fetchrow(
|
||
"""
|
||
SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
|
||
u.login AS reviewed_by_login,
|
||
COALESCE(c_id.tag, c_name.tag) AS resolved_category_tag,
|
||
COUNT(rpm.id) AS media_count,
|
||
COALESCE(
|
||
jsonb_agg(
|
||
jsonb_build_object(
|
||
'id', rpm.id,
|
||
'type', rpm.media_type,
|
||
'url', rpm.original_url,
|
||
'status', rpm.status,
|
||
'error', rpm.error,
|
||
'duration_sec', rpm.duration_sec
|
||
)
|
||
ORDER BY rpm.sort_order ASC, rpm.id ASC
|
||
) FILTER (WHERE rpm.id IS NOT NULL),
|
||
'[]'::jsonb
|
||
) AS media_items,
|
||
COALESCE(
|
||
(
|
||
SELECT jsonb_agg(
|
||
jsonb_build_object(
|
||
'platform', pp.platform,
|
||
'status', pp.status,
|
||
'target_id', pp.target_id,
|
||
'external_id', pp.external_id,
|
||
'url', pp.url,
|
||
'error', pp.error,
|
||
'published_at', pp.published_at
|
||
)
|
||
ORDER BY pp.platform
|
||
)
|
||
FROM post_publications pp
|
||
WHERE pp.raw_post_id=rp.id
|
||
),
|
||
'[]'::jsonb
|
||
) AS platform_publications
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
LEFT JOIN admin_users u ON u.id=rp.reviewed_by
|
||
LEFT JOIN content_categories c_id ON c_id.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
|
||
LEFT JOIN content_categories c_name ON lower(c_name.name)=lower(COALESCE(rp.final_category, rp.rewrite_category, ''))
|
||
LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_hidden, FALSE)=FALSE
|
||
WHERE rp.id = $1
|
||
GROUP BY rp.id, s.name, s.tag, s.platform, s.url, u.login, c_id.tag, c_name.tag
|
||
""",
|
||
post_id
|
||
)
|
||
|
||
@app.get("/editor", response_class=HTMLResponse)
|
||
async def editor_feed(
|
||
request: Request,
|
||
status_filter: str = "review",
|
||
category_filter: str = "",
|
||
date_from: str = "",
|
||
date_to: str = "",
|
||
score_min: str = "",
|
||
score_max: str = "",
|
||
sort: str = "rewritten_desc",
|
||
page: int = 1,
|
||
per_page: int = 25,
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
editorial_statuses = selected_values(request, "editorial_status")
|
||
if status_filter.strip() and not editorial_statuses:
|
||
editorial_statuses = [status_filter.strip()]
|
||
categories_selected = selected_values(request, "category")
|
||
if category_filter.strip() and not categories_selected:
|
||
categories_selected = [category_filter.strip()]
|
||
source_ids = selected_int_values(request, "source_id")
|
||
sort_map = {
|
||
"rewritten_desc": "rp.rewritten_at DESC NULLS LAST, rp.id DESC",
|
||
"rewritten_asc": "rp.rewritten_at ASC NULLS LAST, rp.id ASC",
|
||
"score_desc": "rp.qualification_score DESC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
|
||
"score_asc": "rp.qualification_score ASC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
|
||
"posted_desc": "rp.posted_at DESC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
|
||
"posted_asc": "rp.posted_at ASC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
|
||
}
|
||
where_parts = ["rp.rewrite_status='ready'"]
|
||
base_args: list[Any] = []
|
||
if editorial_statuses:
|
||
add_where(where_parts, base_args, "COALESCE(rp.editorial_status, 'review') = ANY(${i}::text[])", editorial_statuses)
|
||
if categories_selected:
|
||
add_where(where_parts, base_args, "COALESCE(rp.final_category, rp.rewrite_category, '') = ANY(${i}::text[])", categories_selected)
|
||
if source_ids:
|
||
add_where(where_parts, base_args, "rp.source_id = ANY(${i}::bigint[])", source_ids)
|
||
parsed_date_from = parse_date_filter(date_from)
|
||
parsed_date_to = parse_date_filter(date_to)
|
||
if parsed_date_from:
|
||
add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date >= ${i}::date", parsed_date_from)
|
||
if parsed_date_to:
|
||
add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date <= ${i}::date", parsed_date_to)
|
||
if score_min.strip().isdigit():
|
||
add_where(where_parts, base_args, "rp.qualification_score >= ${i}::int", int(score_min.strip()))
|
||
if score_max.strip().isdigit():
|
||
add_where(where_parts, base_args, "rp.qualification_score <= ${i}::int", int(score_max.strip()))
|
||
where_sql = " AND ".join(where_parts)
|
||
total = int(
|
||
await pool.fetchval(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE {where_sql}
|
||
""",
|
||
*base_args,
|
||
)
|
||
or 0
|
||
)
|
||
pager = pagination(page, per_page, total)
|
||
limit_index = len(base_args) + 1
|
||
offset_index = len(base_args) + 2
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
|
||
u.login AS reviewed_by_login,
|
||
COALESCE(c_id.tag, c_name.tag) AS resolved_category_tag,
|
||
COUNT(rpm.id) AS media_count,
|
||
COALESCE(
|
||
jsonb_agg(
|
||
jsonb_build_object(
|
||
'id', rpm.id,
|
||
'type', rpm.media_type,
|
||
'url', rpm.original_url,
|
||
'status', rpm.status,
|
||
'error', rpm.error,
|
||
'duration_sec', rpm.duration_sec
|
||
)
|
||
ORDER BY rpm.sort_order ASC, rpm.id ASC
|
||
) FILTER (WHERE rpm.id IS NOT NULL),
|
||
'[]'::jsonb
|
||
) AS media_items,
|
||
COALESCE(
|
||
(
|
||
SELECT jsonb_agg(
|
||
jsonb_build_object(
|
||
'platform', pp.platform,
|
||
'status', pp.status,
|
||
'target_id', pp.target_id,
|
||
'external_id', pp.external_id,
|
||
'url', pp.url,
|
||
'error', pp.error,
|
||
'published_at', pp.published_at
|
||
)
|
||
ORDER BY pp.platform
|
||
)
|
||
FROM post_publications pp
|
||
WHERE pp.raw_post_id=rp.id
|
||
),
|
||
'[]'::jsonb
|
||
) AS platform_publications
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
LEFT JOIN admin_users u ON u.id=rp.reviewed_by
|
||
LEFT JOIN content_categories c_id ON c_id.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
|
||
LEFT JOIN content_categories c_name ON lower(c_name.name)=lower(COALESCE(rp.final_category, rp.rewrite_category, ''))
|
||
LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_hidden, FALSE)=FALSE
|
||
WHERE {where_sql}
|
||
GROUP BY rp.id, s.name, s.tag, s.platform, s.url, u.login, c_id.tag, c_name.tag
|
||
ORDER BY
|
||
CASE COALESCE(rp.editorial_status, 'review')
|
||
WHEN 'review' THEN 0
|
||
WHEN 'edited' THEN 1
|
||
WHEN 'regenerating' THEN 2
|
||
WHEN 'rejected' THEN 3
|
||
WHEN 'accepted' THEN 4
|
||
WHEN 'publish_failed' THEN 5
|
||
WHEN 'published' THEN 6
|
||
ELSE 7
|
||
END,
|
||
{sort_map.get(sort, sort_map["rewritten_desc"])}
|
||
LIMIT ${limit_index} OFFSET ${offset_index}
|
||
""",
|
||
*base_args,
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
posts = [prepare_editor_post(row) for row in rows]
|
||
categories = await writer_category_options()
|
||
counts_rows = await pool.fetch(
|
||
"""
|
||
SELECT COALESCE(editorial_status, 'review') AS status, COUNT(*) AS count
|
||
FROM raw_posts
|
||
WHERE rewrite_status='ready'
|
||
GROUP BY COALESCE(editorial_status, 'review')
|
||
"""
|
||
)
|
||
counts = {str(row["status"]): int(row["count"]) for row in counts_rows}
|
||
template_name = "editor_content.html" if request.headers.get("hx-request") else "editor.html"
|
||
return templates.TemplateResponse(
|
||
template_name,
|
||
base_context(
|
||
request,
|
||
user,
|
||
posts=posts,
|
||
categories=categories,
|
||
status_options=EDITOR_STATUS_OPTIONS,
|
||
status_filter=editorial_statuses[0] if len(editorial_statuses) == 1 else "",
|
||
editorial_statuses=editorial_statuses,
|
||
categories_selected=categories_selected,
|
||
source_ids=source_ids,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
score_min=score_min,
|
||
score_max=score_max,
|
||
sort=sort,
|
||
facets=await editor_filter_facets(pool),
|
||
counts=counts,
|
||
preserved_query=preserved_query(request, {"page", "per_page"}),
|
||
pagination=pager,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/editor/{post_id}/accept")
|
||
async def editor_accept(request: Request, post_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT rp.final_text,
|
||
rp.rewritten_text,
|
||
COALESCE(rp.final_category, rp.rewrite_category, '') AS category_name,
|
||
COALESCE(rp.final_category_id, rp.rewrite_category_id) AS category_id,
|
||
COALESCE(rp.final_source_tag, rp.rewrite_source_tag, s.tag, '') AS source_tag
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
WHERE rp.id=$1 AND rp.rewrite_status='ready'
|
||
""",
|
||
post_id,
|
||
)
|
||
if not row:
|
||
return redirect("/editor")
|
||
category = await resolve_category_for_editor(row["category_name"], row["category_id"])
|
||
source_tag = normalize_hash_tag(row["source_tag"] or "", "source")
|
||
final_text = row["final_text"] or row["rewritten_text"] or ""
|
||
await pool.execute(
|
||
"""
|
||
UPDATE raw_posts
|
||
SET editorial_status='accepted',
|
||
final_text=$2,
|
||
final_category=$3,
|
||
final_category_tag=$4,
|
||
final_source_tag=$5,
|
||
final_category_id=$6,
|
||
reviewed_by=$7,
|
||
reviewed_at=NOW(),
|
||
updated_at=NOW()
|
||
WHERE id=$1 AND rewrite_status='ready'
|
||
""",
|
||
post_id,
|
||
final_text,
|
||
category["name"],
|
||
category["tag"],
|
||
source_tag,
|
||
category["id"],
|
||
user["id"],
|
||
)
|
||
await audit(user["id"], "editor.accept", "raw_post", post_id)
|
||
if request.headers.get("hx-request"):
|
||
row = await fetch_single_editor_row(pool, post_id)
|
||
if row:
|
||
categories = await writer_category_options()
|
||
return templates.TemplateResponse(
|
||
"editor_post.html",
|
||
base_context(request, user, p=prepare_editor_post(row), categories=categories)
|
||
)
|
||
return redirect("/editor")
|
||
|
||
|
||
@app.post("/editor/{post_id}/reject")
|
||
async def editor_reject(request: Request, post_id: int, csrf_token: str = Form(...), editor_notes: str = Form("")):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
UPDATE raw_posts
|
||
SET editorial_status='rejected',
|
||
editor_notes=$2,
|
||
reviewed_by=$3,
|
||
reviewed_at=NOW(),
|
||
updated_at=NOW()
|
||
WHERE id=$1 AND rewrite_status='ready'
|
||
""",
|
||
post_id,
|
||
editor_notes.strip()[:1000],
|
||
user["id"],
|
||
)
|
||
await audit(user["id"], "editor.reject", "raw_post", post_id)
|
||
if request.headers.get("hx-request"):
|
||
row = await fetch_single_editor_row(pool, post_id)
|
||
if row:
|
||
categories = await writer_category_options()
|
||
return templates.TemplateResponse(
|
||
"editor_post.html",
|
||
base_context(request, user, p=prepare_editor_post(row), categories=categories)
|
||
)
|
||
return redirect("/editor")
|
||
|
||
|
||
@app.post("/editor/{post_id}/save")
|
||
async def editor_save(
|
||
request: Request,
|
||
post_id: int,
|
||
csrf_token: str = Form(...),
|
||
final_text: str = Form(...),
|
||
final_category: str = Form(...),
|
||
final_source_tag: str = Form(""),
|
||
editor_notes: str = Form(""),
|
||
action: str = Form("save"),
|
||
delete_media_ids: list[int] | None = Form(default=None),
|
||
media_files: list[UploadFile] | None = File(default=None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
final_category = final_category.strip()
|
||
category = await resolve_category_for_editor(final_category)
|
||
final_category = category["name"]
|
||
final_category_tag = category["tag"]
|
||
final_source_tag = normalize_hash_tag(final_source_tag, "source")
|
||
status_value = "accepted" if action == "accept" else "edited"
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
UPDATE raw_posts
|
||
SET editorial_status=$2,
|
||
final_text=$3,
|
||
final_category=$4,
|
||
final_category_tag=$5,
|
||
final_source_tag=$6,
|
||
editor_notes=$7,
|
||
final_category_id=$8,
|
||
reviewed_by=CASE WHEN $2='accepted' THEN $9 ELSE reviewed_by END,
|
||
reviewed_at=CASE WHEN $2='accepted' THEN NOW() ELSE reviewed_at END,
|
||
edited_at=NOW(),
|
||
updated_at=NOW()
|
||
WHERE id=$1 AND rewrite_status='ready'
|
||
""",
|
||
post_id,
|
||
status_value,
|
||
final_text.strip(),
|
||
final_category,
|
||
final_category_tag,
|
||
final_source_tag,
|
||
editor_notes.strip()[:1000],
|
||
category["id"],
|
||
user["id"],
|
||
)
|
||
delete_media_ids = delete_media_ids or []
|
||
media_files = media_files or []
|
||
if delete_media_ids:
|
||
local_media_paths = [
|
||
uploaded_editor_media_path(str(row["original_url"] or ""))
|
||
for row in await pool.fetch(
|
||
"""
|
||
SELECT original_url
|
||
FROM raw_post_media
|
||
WHERE raw_post_id=$1
|
||
AND id=ANY($2::bigint[])
|
||
AND editor_added=TRUE
|
||
""",
|
||
post_id,
|
||
delete_media_ids,
|
||
)
|
||
]
|
||
await pool.execute(
|
||
"""
|
||
UPDATE raw_post_media
|
||
SET editor_hidden=TRUE,
|
||
updated_at=NOW()
|
||
WHERE raw_post_id=$1 AND id=ANY($2::bigint[])
|
||
""",
|
||
post_id,
|
||
delete_media_ids,
|
||
)
|
||
for path in local_media_paths:
|
||
if path and path.is_file():
|
||
try:
|
||
path.unlink(missing_ok=True)
|
||
except OSError:
|
||
logger.warning("Could not delete editor media file {}", path)
|
||
for media_file in media_files:
|
||
if not media_file.filename:
|
||
continue
|
||
content = await media_file.read()
|
||
if not content or len(content) > 50 * 1024 * 1024:
|
||
continue
|
||
original_name = Path(media_file.filename or "media").name
|
||
suffix = Path(original_name).suffix.lower()
|
||
if not suffix:
|
||
guessed = mimetypes.guess_extension(media_file.content_type or "")
|
||
suffix = guessed or ".bin"
|
||
safe_name = f"{post_id}_{int(time.time())}_{secrets.token_hex(6)}{suffix}"
|
||
target = EDITOR_MEDIA_DIR / safe_name
|
||
target.write_bytes(content)
|
||
public_url = f"/uploads/editor_media/{safe_name}"
|
||
content_type = media_file.content_type or mimetypes.guess_type(original_name)[0] or ""
|
||
if content_type.startswith("image/"):
|
||
media_type = "photo"
|
||
elif content_type.startswith("video/"):
|
||
media_type = "video"
|
||
else:
|
||
media_type = "doc"
|
||
sort_order = int(
|
||
await pool.fetchval("SELECT COALESCE(MAX(sort_order), -1) + 1 FROM raw_post_media WHERE raw_post_id=$1", post_id)
|
||
or 0
|
||
)
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO raw_post_media(
|
||
raw_post_id, platform, media_type, original_url, preview_url, sort_order, status,
|
||
editor_added, editor_uploaded_by, editor_uploaded_at
|
||
)
|
||
VALUES($1, 'manual', $2, $3, $3, $4, 'ready', TRUE, $5, NOW())
|
||
""",
|
||
post_id,
|
||
media_type,
|
||
public_url,
|
||
sort_order,
|
||
user["id"],
|
||
)
|
||
await audit(user["id"], "editor.save" if status_value == "edited" else "editor.save_accept", "raw_post", post_id)
|
||
if request.headers.get("hx-request"):
|
||
row = await fetch_single_editor_row(pool, post_id)
|
||
if row:
|
||
categories = await writer_category_options()
|
||
return templates.TemplateResponse(
|
||
"editor_post.html",
|
||
base_context(request, user, p=prepare_editor_post(row), categories=categories)
|
||
)
|
||
return redirect("/editor")
|
||
|
||
|
||
@app.get("/raw/{post_id}", response_class=HTMLResponse)
|
||
async def raw_post_detail(request: Request, post_id: int):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
|
||
COUNT(rpm.id) AS media_count,
|
||
COALESCE(
|
||
jsonb_agg(
|
||
jsonb_build_object(
|
||
'id', rpm.id,
|
||
'type', rpm.media_type,
|
||
'url', rpm.original_url,
|
||
'status', rpm.status,
|
||
'error', rpm.error,
|
||
'duration_sec', rpm.duration_sec
|
||
)
|
||
ORDER BY rpm.sort_order ASC, rpm.id ASC
|
||
) FILTER (WHERE rpm.id IS NOT NULL),
|
||
'[]'::jsonb
|
||
) AS media_items,
|
||
b.provider AS qualification_provider,
|
||
b.model AS batch_model,
|
||
b.status AS batch_status,
|
||
b.posts_count AS batch_posts_count,
|
||
b.accepted_count AS batch_accepted_count,
|
||
b.rejected_count AS batch_rejected_count,
|
||
b.maybe_count AS batch_maybe_count,
|
||
b.error AS batch_error,
|
||
b.prompt_text AS batch_prompt_text,
|
||
b.created_at AS batch_created_at,
|
||
b.completed_at AS batch_completed_at,
|
||
b.response_json AS batch_response_json,
|
||
b.prompt_tokens AS batch_prompt_tokens,
|
||
b.completion_tokens AS batch_completion_tokens,
|
||
b.total_tokens AS batch_total_tokens,
|
||
b.estimated_cost_usd AS batch_estimated_cost_usd,
|
||
wb.provider AS rewrite_provider,
|
||
wb.model AS writer_batch_model,
|
||
wb.status AS writer_batch_status,
|
||
wb.posts_count AS writer_batch_posts_count,
|
||
wb.ready_count AS writer_batch_ready_count,
|
||
wb.failed_count AS writer_batch_failed_count,
|
||
wb.error AS writer_batch_error,
|
||
wb.prompt_text AS writer_batch_prompt_text,
|
||
wb.created_at AS writer_batch_created_at,
|
||
wb.completed_at AS writer_batch_completed_at,
|
||
wb.response_json AS writer_batch_response_json,
|
||
wb.prompt_tokens AS writer_prompt_tokens,
|
||
wb.completion_tokens AS writer_completion_tokens,
|
||
wb.total_tokens AS writer_total_tokens,
|
||
wb.estimated_cost_usd AS writer_estimated_cost_usd
|
||
FROM raw_posts rp
|
||
JOIN sources s ON s.id=rp.source_id
|
||
LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id
|
||
LEFT JOIN ai_qualification_batches b ON b.id=rp.qualification_batch_id
|
||
LEFT JOIN ai_writer_batches wb ON wb.id=rp.rewrite_batch_id
|
||
WHERE rp.id=$1
|
||
GROUP BY rp.id, s.name, s.tag, s.platform, s.url, b.id, wb.id
|
||
""",
|
||
post_id,
|
||
)
|
||
if not row:
|
||
return redirect("/raw")
|
||
post = prepare_raw_post(row)
|
||
post["batch_created_at_fmt"] = format_dt(post.get("batch_created_at"))
|
||
post["batch_completed_at_fmt"] = format_dt(post.get("batch_completed_at"))
|
||
response_json = post.get("batch_response_json") or {}
|
||
if isinstance(response_json, str):
|
||
try:
|
||
response_json = json.loads(response_json)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
post["batch_response_pretty"] = json.dumps(response_json, ensure_ascii=False, indent=2)
|
||
if not post.get("batch_prompt_text") and post.get("qualification_batch_id"):
|
||
post["batch_prompt_text"] = (
|
||
"Точный prompt для этого старого batch ещё не сохранялся.\n"
|
||
f"Prompt hash: {post.get('qualification_prompt_hash') or '—'}"
|
||
)
|
||
else:
|
||
post["batch_prompt_text"] = post.get("batch_prompt_text") or ""
|
||
writer_response_json = post.get("writer_batch_response_json") or {}
|
||
if isinstance(writer_response_json, str):
|
||
try:
|
||
writer_response_json = json.loads(writer_response_json)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
post["writer_batch_created_at_fmt"] = format_dt(post.get("writer_batch_created_at"))
|
||
post["writer_batch_completed_at_fmt"] = format_dt(post.get("writer_batch_completed_at"))
|
||
post["rewritten_at_fmt"] = format_dt(post.get("rewritten_at"))
|
||
post["writer_batch_response_pretty"] = json.dumps(writer_response_json, ensure_ascii=False, indent=2)
|
||
if not post.get("writer_batch_prompt_text") and post.get("rewrite_batch_id"):
|
||
post["writer_batch_prompt_text"] = (
|
||
"Точный prompt для этого старого writer batch ещё не сохранялся.\n"
|
||
f"Prompt hash: {post.get('rewrite_prompt_hash') or '—'}"
|
||
)
|
||
else:
|
||
post["writer_batch_prompt_text"] = post.get("writer_batch_prompt_text") or ""
|
||
return templates.TemplateResponse("raw_post_detail.html", base_context(request, user, post=post))
|
||
|
||
|
||
@app.get("/prompt-test", response_class=HTMLResponse)
|
||
async def prompt_test(request: Request, post_id: int | None = None, q_status: str = "all"):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
q_status = normalize_prompt_test_status(q_status)
|
||
posts, selected = await prompt_test_posts(post_id, q_status)
|
||
category_payload = await writer_category_payload()
|
||
max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500))
|
||
prompt = str(await fetch_setting("ai_writer_prompt", "") or "")
|
||
provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
|
||
model = str(await fetch_setting("ai_writer_model", "") or "")
|
||
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||
if payload:
|
||
payload["categories"] = category_payload
|
||
return templates.TemplateResponse(
|
||
"prompt_test.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
posts=posts,
|
||
selected=selected,
|
||
prompt=prompt,
|
||
provider=provider,
|
||
model=model,
|
||
q_status=q_status,
|
||
status_options=PROMPT_TEST_STATUS_OPTIONS,
|
||
max_text_chars=max_text_chars,
|
||
payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
|
||
result=None,
|
||
error=None,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/prompt-test", response_class=HTMLResponse)
|
||
async def prompt_test_run(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
post_id: int = Form(...),
|
||
prompt: str = Form(...),
|
||
q_status: str = Form("all"),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
q_status = normalize_prompt_test_status(q_status)
|
||
posts, selected = await prompt_test_posts(post_id, q_status)
|
||
category_payload = await writer_category_payload()
|
||
max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500))
|
||
provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
|
||
model = str(await fetch_setting("ai_writer_model", "") or "").strip()
|
||
api_key = str(await fetch_setting("ai_writer_api_key", "") or "").strip()
|
||
api_base = str(await fetch_setting("ai_writer_api_base", "") or "").strip()
|
||
temperature = max(0.0, float(await fetch_setting("ai_writer_temperature", 0.4) or 0.4))
|
||
timeout = max(10, int(await fetch_setting("ai_writer_timeout_sec", 180) or 180))
|
||
normalized_prompt = prompt.replace("\\r\\n", "\n").replace("\\n", "\n").strip()
|
||
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||
if payload:
|
||
payload["categories"] = category_payload
|
||
result = None
|
||
error = None
|
||
if not selected:
|
||
error = "Пост не найден."
|
||
elif not model or not api_key:
|
||
error = "Не заполнены модель или API-ключ райтера в настройках."
|
||
elif not normalized_prompt:
|
||
error = "Промпт пустой."
|
||
else:
|
||
try:
|
||
import litellm
|
||
|
||
kwargs: dict[str, Any] = {
|
||
"model": normalize_model(provider, model),
|
||
"messages": [
|
||
{"role": "system", "content": normalized_prompt},
|
||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||
],
|
||
"temperature": temperature,
|
||
"timeout": timeout,
|
||
}
|
||
if api_key:
|
||
kwargs["api_key"] = api_key
|
||
if api_base:
|
||
kwargs["api_base"] = api_base
|
||
response = await asyncio.to_thread(litellm.completion, **kwargs)
|
||
content = response.choices[0].message.content
|
||
parsed = None
|
||
raw_json = str(content or "").strip()
|
||
try:
|
||
if raw_json.startswith("```"):
|
||
raw_json = raw_json.strip("`")
|
||
if raw_json.lower().startswith("json"):
|
||
raw_json = raw_json[4:].strip()
|
||
parsed = json.loads(raw_json)
|
||
except Exception:
|
||
parsed = None
|
||
result = {
|
||
"content": content,
|
||
"parsed_json": json.dumps(parsed, ensure_ascii=False, indent=2) if parsed is not None else "",
|
||
"preview_text": prompt_test_preview_text(parsed),
|
||
"preview_category": prompt_test_preview_category(parsed),
|
||
"preview_notes": prompt_test_preview_notes(parsed),
|
||
"usage": response_usage(response),
|
||
"model": kwargs["model"],
|
||
}
|
||
await audit(user["id"], "writer_prompt_test.run", "raw_post", int(selected["id"]), {"provider": provider, "model": model})
|
||
except Exception as exc:
|
||
logger.exception("Prompt test failed")
|
||
error = str(exc)
|
||
return templates.TemplateResponse(
|
||
"prompt_test.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
posts=posts,
|
||
selected=selected,
|
||
prompt=normalized_prompt,
|
||
provider=provider,
|
||
model=model,
|
||
q_status=q_status,
|
||
status_options=PROMPT_TEST_STATUS_OPTIONS,
|
||
max_text_chars=max_text_chars,
|
||
payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
|
||
result=result,
|
||
error=error,
|
||
),
|
||
)
|
||
|
||
|
||
@app.get("/workers", response_class=HTMLResponse)
|
||
async def workers(request: Request):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
rows = await pool.fetch(
|
||
"""
|
||
SELECT wc.name, wc.enabled, wc.settings_json, wh.heartbeat_at, wh.status, wh.current_job_id, wh.meta_json
|
||
FROM worker_controls wc
|
||
LEFT JOIN worker_heartbeats wh ON wh.name=wc.name
|
||
ORDER BY wc.name
|
||
"""
|
||
)
|
||
settings_rows = await pool.fetch("SELECT * FROM app_settings ORDER BY category, key")
|
||
settings = []
|
||
for row in settings_rows:
|
||
item = dict(row)
|
||
if isinstance(item.get("value_json"), str):
|
||
try:
|
||
item["value_json"] = json.loads(item["value_json"])
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if item.get("key") in {"ai_writer_categories", "tg_poster_schedule_json", "vk_poster_schedule_json", *AI_WORKER_ENABLED_SETTINGS.values()}:
|
||
continue
|
||
settings.append(item)
|
||
settings.sort(key=setting_sort_key)
|
||
ai_provider = str(setting_value(settings, "ai_qualifier_provider", "openrouter") or "openrouter")
|
||
ai_model = str(setting_value(settings, "ai_qualifier_model", "") or "")
|
||
ai_api_key = str(setting_value(settings, "ai_qualifier_api_key", "") or "")
|
||
ai_model_options = await model_options_for_provider(ai_provider, ai_api_key)
|
||
writer_provider = str(setting_value(settings, "ai_writer_provider", "anthropic") or "anthropic")
|
||
writer_model = str(setting_value(settings, "ai_writer_model", "") or "")
|
||
writer_api_key = str(setting_value(settings, "ai_writer_api_key", "") or "")
|
||
writer_model_options = await model_options_for_provider(writer_provider, writer_api_key)
|
||
return templates.TemplateResponse(
|
||
"workers.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
workers=[dict(r) for r in rows],
|
||
settings=settings,
|
||
provider_options=PROVIDER_OPTIONS,
|
||
ai_provider=ai_provider,
|
||
ai_model=ai_model,
|
||
ai_model_options=ai_model_options,
|
||
writer_provider=writer_provider,
|
||
writer_model=writer_model,
|
||
writer_model_options=writer_model_options,
|
||
categories=await category_rows(),
|
||
tg_schedule=await tg_poster_schedule_rows(),
|
||
vk_schedule=await vk_poster_schedule_rows(),
|
||
category_titles=CATEGORY_TITLES,
|
||
prompt_hints=PROMPT_HINTS,
|
||
),
|
||
)
|
||
|
||
|
||
@app.post("/tg-poster-schedule/create")
|
||
async def tg_poster_schedule_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
time_value: str = Form(..., alias="time"),
|
||
count: int = Form(1),
|
||
enabled: str | None = Form(default=None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = await tg_poster_schedule_rows()
|
||
rows.append(
|
||
{
|
||
"id": f"slot-{secrets.token_hex(4)}",
|
||
"time": time_value,
|
||
"count": count,
|
||
"enabled": enabled == "on",
|
||
}
|
||
)
|
||
rows = normalize_tg_schedule(rows)
|
||
await save_tg_poster_schedule(rows)
|
||
await audit(user["id"], "tg_schedule.create", "tg_poster_schedule", None, {"rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/tg-poster-schedule/{schedule_id}/update")
|
||
async def tg_poster_schedule_update(
|
||
request: Request,
|
||
schedule_id: str,
|
||
csrf_token: str = Form(...),
|
||
time_value: str = Form(..., alias="time"),
|
||
count: int = Form(1),
|
||
enabled: str | None = Form(default=None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = await tg_poster_schedule_rows()
|
||
for row in rows:
|
||
if row["id"] == schedule_id:
|
||
row.update({"time": time_value, "count": count, "enabled": enabled == "on"})
|
||
break
|
||
rows = normalize_tg_schedule(rows)
|
||
await save_tg_poster_schedule(rows)
|
||
await audit(user["id"], "tg_schedule.update", "tg_poster_schedule", None, {"id": schedule_id, "rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/tg-poster-schedule/{schedule_id}/delete")
|
||
async def tg_poster_schedule_delete(request: Request, schedule_id: str, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = [row for row in await tg_poster_schedule_rows() if row["id"] != schedule_id]
|
||
await save_tg_poster_schedule(rows)
|
||
await audit(user["id"], "tg_schedule.delete", "tg_poster_schedule", None, {"id": schedule_id, "rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/vk-poster-schedule/create")
|
||
async def vk_poster_schedule_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
time_value: str = Form(..., alias="time"),
|
||
count: int = Form(1),
|
||
enabled: str | None = Form(default=None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = await vk_poster_schedule_rows()
|
||
rows.append({"id": f"vk-slot-{secrets.token_hex(4)}", "time": time_value, "count": count, "enabled": enabled == "on"})
|
||
rows = normalize_tg_schedule(rows)
|
||
await save_vk_poster_schedule(rows)
|
||
await audit(user["id"], "vk_schedule.create", "vk_poster_schedule", None, {"rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/vk-poster-schedule/{schedule_id}/update")
|
||
async def vk_poster_schedule_update(
|
||
request: Request,
|
||
schedule_id: str,
|
||
csrf_token: str = Form(...),
|
||
time_value: str = Form(..., alias="time"),
|
||
count: int = Form(1),
|
||
enabled: str | None = Form(default=None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = await vk_poster_schedule_rows()
|
||
for row in rows:
|
||
if row["id"] == schedule_id:
|
||
row.update({"time": time_value, "count": count, "enabled": enabled == "on"})
|
||
break
|
||
rows = normalize_tg_schedule(rows)
|
||
await save_vk_poster_schedule(rows)
|
||
await audit(user["id"], "vk_schedule.update", "vk_poster_schedule", None, {"id": schedule_id, "rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/vk-poster-schedule/{schedule_id}/delete")
|
||
async def vk_poster_schedule_delete(request: Request, schedule_id: str, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
rows = [row for row in await vk_poster_schedule_rows() if row["id"] != schedule_id]
|
||
await save_vk_poster_schedule(rows)
|
||
await audit(user["id"], "vk_schedule.delete", "vk_poster_schedule", None, {"id": schedule_id, "rows": rows})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/categories/create")
|
||
async def category_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
name: str = Form(...),
|
||
tag: str = Form(""),
|
||
site_name: str = Form(...),
|
||
site_slug: str = Form(...),
|
||
site_enabled: str | None = Form(None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
name = name.strip().strip("#")
|
||
if not name:
|
||
return redirect("/workers")
|
||
tag = normalize_hash_tag(tag or name, "category")
|
||
site_name = site_name.strip()
|
||
site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-")
|
||
if not site_name or not site_slug:
|
||
return redirect("/workers")
|
||
pool = await get_pool()
|
||
sort_order = int(await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM content_categories") or 1)
|
||
await pool.execute(
|
||
"""
|
||
INSERT INTO content_categories(name, tag, sort_order, site_name, site_slug, site_enabled)
|
||
VALUES($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (name) DO UPDATE
|
||
SET tag=$2,
|
||
site_name=$4,
|
||
site_slug=$5,
|
||
site_enabled=$6,
|
||
is_active=TRUE,
|
||
updated_at=NOW()
|
||
""",
|
||
name,
|
||
tag,
|
||
sort_order,
|
||
site_name,
|
||
site_slug,
|
||
site_enabled is not None,
|
||
)
|
||
await audit(user["id"], "category.create", "content_category", None, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/categories/{category_id}/update")
|
||
async def category_update(
|
||
request: Request,
|
||
category_id: int,
|
||
csrf_token: str = Form(...),
|
||
name: str = Form(...),
|
||
tag: str = Form(""),
|
||
site_name: str = Form(...),
|
||
site_slug: str = Form(...),
|
||
site_enabled: str | None = Form(None),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
name = name.strip().strip("#")
|
||
if not name:
|
||
return redirect("/workers")
|
||
tag = normalize_hash_tag(tag or name, "category")
|
||
site_name = site_name.strip()
|
||
site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-")
|
||
if not site_name or not site_slug:
|
||
return redirect("/workers")
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"""
|
||
UPDATE content_categories
|
||
SET name=$2,
|
||
tag=$3,
|
||
site_name=$4,
|
||
site_slug=$5,
|
||
site_enabled=$6,
|
||
updated_at=NOW()
|
||
WHERE id=$1
|
||
""",
|
||
category_id,
|
||
name,
|
||
tag,
|
||
site_name,
|
||
site_slug,
|
||
site_enabled is not None,
|
||
)
|
||
await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/categories/{category_id}/toggle")
|
||
async def category_toggle(request: Request, category_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
UPDATE content_categories
|
||
SET is_active=NOT is_active,
|
||
updated_at=NOW()
|
||
WHERE id=$1
|
||
RETURNING name, is_active
|
||
""",
|
||
category_id,
|
||
)
|
||
await audit(
|
||
user["id"],
|
||
"category.toggle",
|
||
"content_category",
|
||
category_id,
|
||
{"name": row["name"] if row else None, "is_active": bool(row["is_active"]) if row else None},
|
||
)
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/categories/{category_id}/delete")
|
||
async def category_delete(request: Request, category_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow(
|
||
"""
|
||
UPDATE content_categories
|
||
SET is_active=FALSE,
|
||
updated_at=NOW()
|
||
WHERE id=$1
|
||
RETURNING name
|
||
""",
|
||
category_id,
|
||
)
|
||
await audit(user["id"], "category.archive", "content_category", category_id, {"name": row["name"] if row else None})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/workers/{worker_name}/toggle")
|
||
async def worker_toggle(request: Request, worker_name: str, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
async with pool.acquire() as conn:
|
||
async with conn.transaction():
|
||
row = await conn.fetchrow(
|
||
"""
|
||
UPDATE worker_controls
|
||
SET enabled=NOT enabled,
|
||
updated_by=$2,
|
||
updated_at=NOW()
|
||
WHERE name=$1
|
||
RETURNING enabled
|
||
""",
|
||
worker_name,
|
||
user["id"],
|
||
)
|
||
if not row:
|
||
return redirect("/workers")
|
||
enabled = bool(row["enabled"])
|
||
setting_key = AI_WORKER_ENABLED_SETTINGS.get(worker_name)
|
||
if setting_key:
|
||
await conn.execute(
|
||
"""
|
||
UPDATE app_settings
|
||
SET value_json=$2::jsonb,
|
||
updated_by=$3,
|
||
updated_at=NOW()
|
||
WHERE key=$1
|
||
""",
|
||
setting_key,
|
||
json.dumps(enabled),
|
||
user["id"],
|
||
)
|
||
await audit(
|
||
user["id"],
|
||
"worker.toggle",
|
||
"worker",
|
||
None,
|
||
{
|
||
"name": worker_name,
|
||
"enabled": enabled,
|
||
"synced_setting": AI_WORKER_ENABLED_SETTINGS.get(worker_name),
|
||
},
|
||
)
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.post("/settings/save")
|
||
async def settings_save(request: Request, csrf_token: str = Form(...), key: str = Form(...), value: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
pool = await get_pool()
|
||
row = await pool.fetchrow("SELECT value_type FROM app_settings WHERE key=$1", key)
|
||
if not row:
|
||
return redirect("/workers")
|
||
value_type = str(row["value_type"])
|
||
if value_type == "int":
|
||
parsed: Any = int(value)
|
||
elif value_type == "float":
|
||
parsed = float(value)
|
||
elif value_type == "bool":
|
||
parsed = str(value).lower() in {"1", "true", "yes", "on"}
|
||
elif value_type == "secret":
|
||
if not value.strip():
|
||
return redirect("/workers")
|
||
parsed = value.strip()
|
||
elif value_type == "json":
|
||
parsed = json.loads(value)
|
||
else:
|
||
parsed = value
|
||
if key in {"ai_qualifier_prompt", "ai_qualifier_contract", "ai_writer_prompt", "ai_writer_contract"}:
|
||
parsed = parsed.replace("\\r\\n", "\n").replace("\\n", "\n")
|
||
await pool.execute(
|
||
"""
|
||
UPDATE app_settings
|
||
SET value_json=$2::jsonb,
|
||
updated_by=$3,
|
||
updated_at=NOW()
|
||
WHERE key=$1
|
||
""",
|
||
key,
|
||
json.dumps(parsed, ensure_ascii=False),
|
||
user["id"],
|
||
)
|
||
await audit(user["id"], "setting.update", "setting", None, {"key": key, "value": parsed})
|
||
return redirect("/workers")
|
||
|
||
|
||
@app.get("/logs", response_class=HTMLResponse)
|
||
async def logs_page(
|
||
request: Request,
|
||
q: str = "",
|
||
action: str = "",
|
||
actor_id: int = 0,
|
||
entity_type: str = "",
|
||
date_from: str = "",
|
||
date_to: str = "",
|
||
page: int = 1,
|
||
per_page: int = 50,
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
await prune_audit_log()
|
||
where_parts: list[str] = []
|
||
args: list[Any] = []
|
||
if q.strip():
|
||
add_where(
|
||
where_parts,
|
||
args,
|
||
"(al.action ILIKE ${i} OR al.entity_type ILIKE ${i} OR COALESCE(al.after_json::text, '') ILIKE ${i})",
|
||
f"%{q.strip()}%",
|
||
)
|
||
if action.strip():
|
||
add_where(where_parts, args, "al.action=${i}", action.strip())
|
||
if actor_id > 0:
|
||
add_where(where_parts, args, "al.actor_id=${i}", actor_id)
|
||
if entity_type.strip():
|
||
add_where(where_parts, args, "al.entity_type=${i}", entity_type.strip())
|
||
parsed_date_from = parse_date_filter(date_from)
|
||
parsed_date_to = parse_date_filter(date_to)
|
||
if parsed_date_from:
|
||
add_where(where_parts, args, "timezone('Asia/Yekaterinburg', al.created_at)::date >= ${i}::date", parsed_date_from)
|
||
if parsed_date_to:
|
||
add_where(where_parts, args, "timezone('Asia/Yekaterinburg', al.created_at)::date <= ${i}::date", parsed_date_to)
|
||
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
|
||
total = int(
|
||
await pool.fetchval(
|
||
f"""
|
||
SELECT COUNT(*)
|
||
FROM audit_log al
|
||
LEFT JOIN admin_users u ON u.id=al.actor_id
|
||
WHERE {where_sql}
|
||
""",
|
||
*args,
|
||
)
|
||
or 0
|
||
)
|
||
pager = pagination(page, per_page, total)
|
||
limit_index = len(args) + 1
|
||
offset_index = len(args) + 2
|
||
rows = await pool.fetch(
|
||
f"""
|
||
SELECT al.*, u.login AS actor_login
|
||
FROM audit_log al
|
||
LEFT JOIN admin_users u ON u.id=al.actor_id
|
||
WHERE {where_sql}
|
||
ORDER BY al.created_at DESC, al.id DESC
|
||
LIMIT ${limit_index} OFFSET ${offset_index}
|
||
""",
|
||
*args,
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
action_rows = await pool.fetch(
|
||
"""
|
||
SELECT action, COUNT(*) AS count
|
||
FROM audit_log
|
||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||
GROUP BY action
|
||
ORDER BY COUNT(*) DESC, action
|
||
LIMIT 80
|
||
"""
|
||
)
|
||
actor_rows = await pool.fetch(
|
||
"""
|
||
SELECT u.id, u.login, COUNT(al.id) AS count
|
||
FROM audit_log al
|
||
JOIN admin_users u ON u.id=al.actor_id
|
||
WHERE al.created_at >= NOW() - INTERVAL '30 days'
|
||
GROUP BY u.id, u.login
|
||
ORDER BY COUNT(al.id) DESC, u.login
|
||
"""
|
||
)
|
||
entity_rows = await pool.fetch(
|
||
"""
|
||
SELECT entity_type, COUNT(*) AS count
|
||
FROM audit_log
|
||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||
GROUP BY entity_type
|
||
ORDER BY COUNT(*) DESC, entity_type
|
||
LIMIT 50
|
||
"""
|
||
)
|
||
return templates.TemplateResponse(
|
||
"logs.html",
|
||
base_context(
|
||
request,
|
||
user,
|
||
logs=[prepare_audit_row(row) for row in rows],
|
||
q=q,
|
||
action=action,
|
||
actor_id=actor_id,
|
||
entity_type=entity_type,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
actions=[dict(row) for row in action_rows],
|
||
actors=[dict(row) for row in actor_rows],
|
||
entity_types=[dict(row) for row in entity_rows],
|
||
preserved_query=preserved_query(request, {"page", "per_page"}),
|
||
pagination=pager,
|
||
),
|
||
)
|
||
|
||
|
||
@app.get("/users", response_class=HTMLResponse)
|
||
async def users(request: Request, page: int = 1, per_page: int = 50):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
pool = await get_pool()
|
||
total = int(await pool.fetchval("SELECT COUNT(*) FROM admin_users") or 0)
|
||
pager = pagination(page, per_page, total)
|
||
rows = await pool.fetch(
|
||
"SELECT id, login, role, is_active, created_at FROM admin_users ORDER BY id LIMIT $1 OFFSET $2",
|
||
pager["per_page"],
|
||
pager["offset"],
|
||
)
|
||
users_rows = []
|
||
for row in rows:
|
||
item = dict(row)
|
||
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||
users_rows.append(item)
|
||
return templates.TemplateResponse("users.html", base_context(request, user, users=users_rows, pagination=pager))
|
||
|
||
|
||
@app.post("/users/create")
|
||
async def user_create(
|
||
request: Request,
|
||
csrf_token: str = Form(...),
|
||
login: str = Form(...),
|
||
password: str = Form(...),
|
||
role: str = Form("editor"),
|
||
):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
if user["role"] != "admin":
|
||
return redirect("/users")
|
||
role = role if role in {"admin", "editor", "viewer"} else "editor"
|
||
pool = await get_pool()
|
||
await pool.execute(
|
||
"INSERT INTO admin_users(login, password_hash, role) VALUES($1, $2, $3)",
|
||
login.strip().lower(),
|
||
hash_password(password),
|
||
role,
|
||
)
|
||
await audit(user["id"], "user.create", "user", None, {"login": login, "role": role})
|
||
return redirect("/users")
|
||
|
||
|
||
@app.post("/users/{user_id}/toggle")
|
||
async def user_toggle(request: Request, user_id: int, csrf_token: str = Form(...)):
|
||
user = await get_current_user(request)
|
||
if not user:
|
||
return redirect("/login")
|
||
require_csrf(user, csrf_token)
|
||
if user["role"] != "admin" or int(user["id"]) == user_id:
|
||
return redirect("/users")
|
||
pool = await get_pool()
|
||
await pool.execute("UPDATE admin_users SET is_active=NOT is_active, updated_at=NOW() WHERE id=$1", user_id)
|
||
await audit(user["id"], "user.toggle", "user", user_id)
|
||
return redirect("/users")
|