Files
new_vk_parser/src/vk_parser_app/admin.py
T

3861 lines
147 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")