Админ-панель и очистка ComfyUI

Публичная история отделена от админки /admin с HTTP Basic, управление пользователями перенесено в /admin/users. Добавлено скрытие запросов из публичной истории. После успешной генерации бот удаляет запись ComfyUI по prompt_id; для файлов добавлен скрипт очистки.
This commit is contained in:
MpakobecEx 2026-10-19 20:39:12 +05:00
parent d593e35921
commit 67d3180ef6
18 changed files with 627 additions and 120 deletions

View File

@ -59,8 +59,11 @@ PAYMENTS_ENABLED=true
STARS_PRICE=5 STARS_PRICE=5
PREMIUM_STARS_PRICE=15 PREMIUM_STARS_PRICE=15
# Веб-история: только локальная сеть, без авторизации # Веб-история: только локальная сеть
WEB_BIND_IP=192.168.31.158 WEB_BIND_IP=192.168.31.158
WEB_PAGE_SIZE=50 WEB_PAGE_SIZE=50
# Админ-панель /admin: HTTP Basic
WEB_ADMIN_USERNAME=admin
WEB_ADMIN_PASSWORD=change-me
LOG_LEVEL=INFO LOG_LEVEL=INFO

View File

@ -158,7 +158,7 @@ BLOCKED_PUBLIC_FIGURES=
## Управление пользователями ## Управление пользователями
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`): В админ-панели есть раздел **«Пользователи»** (`/admin/users`):
- поиск по ID или username; - поиск по ID или username;
- установка точного баланса генераций; - установка точного баланса генераций;
@ -248,6 +248,8 @@ http://192.168.31.158:8099
Возможности: Возможности:
Публичная страница `/` показывает только историю:
- фильтр по Telegram `user_id` (с подсказкой известных пользователей); - фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- в списке показывается `@username`, если он известен, иначе Telegram ID; - в списке показывается `@username`, если он известен, иначе Telegram ID;
- фильтр по статусу и режиму (`t2i`/`i2i`); - фильтр по статусу и режиму (`t2i`/`i2i`);
@ -255,14 +257,21 @@ http://192.168.31.158:8099
- превью результата в списке (кэшируются в `storage/.thumbnails`); - превью результата в списке (кэшируются в `storage/.thumbnails`);
- карточка запроса с полным промптом, ошибкой, seed и изображениями. - карточка запроса с полным промптом, ошибкой, seed и изображениями.
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN: Админ-панель находится на `/admin` и защищена HTTP Basic:
- `/admin` — история, включая скрытые запросы;
- `/admin/users` — управление пользователями;
- скрытие/показ запроса из публичной истории;
- скрытые запросы не видны в публичном списке, их карточки и медиа отдают `404`.
```dotenv ```dotenv
WEB_BIND_IP=192.168.31.158 WEB_BIND_IP=192.168.31.158
WEB_PAGE_SIZE=50 WEB_PAGE_SIZE=50
WEB_ADMIN_USERNAME=admin
WEB_ADMIN_PASSWORD=change-me
``` ```
Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере. Если `WEB_ADMIN_PASSWORD` пустой, админ-панель отключена. Basic Auth передаёт пароль без шифрования, поэтому используй только LAN или VPN; не публикуй порт `8099` наружу.
После успешной сборки обновление контейнера: После успешной сборки обновление контейнера:
@ -281,6 +290,27 @@ docker compose up -d
Если меняются workflow, обновите `mapping.json`. При старте бот проверяет, что все узлы и поля существуют. Если меняются workflow, обновите `mapping.json`. При старте бот проверяет, что все узлы и поля существуют.
## Очистка ComfyUI
После успешного скачивания результата бот удаляет запись истории ComfyUI по `prompt_id`:
```json
POST /history
{"delete": ["<prompt_id>"]}
```
Это убирает запрос и его ассеты из истории ComfyUI, но не удаляет файлы с диска. Для файлов `output/` и `temp/` есть скрипт для машины с ComfyUI:
```powershell
.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6
```
Запускай его по расписанию, например раз в час. Сначала можно проверить без удаления:
```powershell
.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6 -WhatIf
```
## Тесты ## Тесты
```powershell ```powershell

View File

@ -8,7 +8,7 @@
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20). - Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история. - Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
- 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст. - 66 тестов: 39 проходят локально без БД, 27 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой. - Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
## 0. Допущения и решения по умолчанию ## 0. Допущения и решения по умолчанию
@ -392,6 +392,19 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене. **DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене.
## 22. Админ-панель и очистка ComfyUI
- [x] Публичная страница `/` показывает только видимую историю.
- [x] `/admin` и `/admin/users` защищены HTTP Basic (`WEB_ADMIN_USERNAME`/`WEB_ADMIN_PASSWORD`).
- [x] Управление пользователями перенесено в `/admin/users`.
- [x] Скрытие/показ запросов из публичной истории; админ видит скрытые.
- [x] Скрытые карточки и медиа недоступны публично (`404`).
- [x] После успешного скачивания бот удаляет историю ComfyUI по `prompt_id`.
- [x] Скрипт очистки файлов `scripts/cleanup_comfy_assets.ps1`.
- [x] Тесты БД и веб-маршрутов.
**DoD:** публичная история не показывает скрытые запросы, админ-панель требует пароль, история ComfyUI чистится после генерации.
## Порядок работ ## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow. 1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

View File

@ -246,6 +246,26 @@ class ComfyClient:
entry = payload.get(prompt_id) entry = payload.get(prompt_id)
return entry if isinstance(entry, dict) else None return entry if isinstance(entry, dict) else None
async def delete_history(self, prompt_id: str) -> None:
try:
response = await self._client.post(
"/history",
json={"delete": [prompt_id]},
timeout=10.0,
)
except httpx.HTTPError as exc:
raise ComfyConnectionError(f"POST /history: {exc}") from exc
if response.status_code in {400, 404, 405}:
logger.warning(
"ComfyUI не поддерживает удаление истории по prompt_id"
)
return
if response.status_code >= 400:
raise ComfyHTTPError(
f"POST /history: HTTP {response.status_code}:"
f" {response.text[:500]}"
)
async def wait_for_history(self, prompt_id: str, *, timeout: float) -> dict[str, Any]: async def wait_for_history(self, prompt_id: str, *, timeout: float) -> dict[str, Any]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
deadline = loop.time() + timeout deadline = loop.time() + timeout

View File

@ -104,6 +104,8 @@ class Settings:
premium_stars_price: int = 15 premium_stars_price: int = 15
web_page_size: int = 50 web_page_size: int = 50
web_bind_ip: str = "127.0.0.1" web_bind_ip: str = "127.0.0.1"
web_admin_username: str = "admin"
web_admin_password: str = ""
@property @property
def comfy_ws_url(self) -> str: def comfy_ws_url(self) -> str:
@ -177,6 +179,11 @@ class Settings:
web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))), web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))),
web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip() web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip()
or "127.0.0.1", or "127.0.0.1",
web_admin_username=os.getenv(
"WEB_ADMIN_USERNAME", "admin"
).strip()
or "admin",
web_admin_password=os.getenv("WEB_ADMIN_PASSWORD", "").strip(),
) )
return settings return settings

47
db.py
View File

@ -70,6 +70,7 @@ class Request:
payment_charge_id: str | None payment_charge_id: str | None
payment_refunded: bool payment_refunded: bool
is_premium: bool is_premium: bool
hidden: bool
mode: str mode: str
seed: int | None seed: int | None
error_text: str | None error_text: str | None
@ -97,6 +98,7 @@ class Request:
payment_charge_id=row["payment_charge_id"], payment_charge_id=row["payment_charge_id"],
payment_refunded=bool(row["payment_refunded"]), payment_refunded=bool(row["payment_refunded"]),
is_premium=bool(row["is_premium"]), is_premium=bool(row["is_premium"]),
hidden=bool(row["hidden"]),
mode=row["mode"], mode=row["mode"],
seed=row["seed"], seed=row["seed"],
error_text=row["error_text"], error_text=row["error_text"],
@ -250,6 +252,7 @@ CREATE TABLE IF NOT EXISTS requests (
payment_charge_id TEXT, payment_charge_id TEXT,
payment_refunded BOOLEAN NOT NULL DEFAULT FALSE, payment_refunded BOOLEAN NOT NULL DEFAULT FALSE,
is_premium BOOLEAN NOT NULL DEFAULT FALSE, is_premium BOOLEAN NOT NULL DEFAULT FALSE,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')), mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')),
seed BIGINT, seed BIGINT,
error_text TEXT, error_text TEXT,
@ -338,6 +341,10 @@ MIGRATIONS = (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT",
(
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS hidden BOOLEAN"
" NOT NULL DEFAULT FALSE"
),
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT", "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT",
( (
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN" "ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN"
@ -779,6 +786,7 @@ class Database:
user_id: int | None, user_id: int | None,
status: str | None, status: str | None,
mode: str | None, mode: str | None,
include_hidden: bool = True,
) -> tuple[str, list[Any]]: ) -> tuple[str, list[Any]]:
clauses: list[str] = [] clauses: list[str] = []
params: list[Any] = [] params: list[Any] = []
@ -791,6 +799,8 @@ class Database:
if mode: if mode:
clauses.append("mode = %s") clauses.append("mode = %s")
params.append(mode) params.append(mode)
if not include_hidden:
clauses.append("hidden = FALSE")
where = f" WHERE {' AND '.join(clauses)}" if clauses else "" where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
return where, params return where, params
@ -800,10 +810,13 @@ class Database:
user_id: int | None = None, user_id: int | None = None,
status: str | None = None, status: str | None = None,
mode: str | None = None, mode: str | None = None,
include_hidden: bool = True,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> list[Request]: ) -> list[Request]:
where, params = self._filters(user_id, status, mode) where, params = self._filters(
user_id, status, mode, include_hidden=include_hidden
)
query = ( query = (
f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s" f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s"
) )
@ -818,8 +831,11 @@ class Database:
user_id: int | None = None, user_id: int | None = None,
status: str | None = None, status: str | None = None,
mode: str | None = None, mode: str | None = None,
include_hidden: bool = True,
) -> int: ) -> int:
where, params = self._filters(user_id, status, mode) where, params = self._filters(
user_id, status, mode, include_hidden=include_hidden
)
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
cursor = await conn.execute( cursor = await conn.execute(
f"SELECT COUNT(*) AS total FROM requests{where}", params f"SELECT COUNT(*) AS total FROM requests{where}", params
@ -827,10 +843,12 @@ class Database:
row = await cursor.fetchone() row = await cursor.fetchone()
return int(row["total"]) if row is not None else 0 return int(row["total"]) if row is not None else 0
async def stats(self) -> dict[str, Any]: async def stats(self, *, include_hidden: bool = True) -> dict[str, Any]:
where = "" if include_hidden else " WHERE hidden = FALSE"
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
cursor = await conn.execute( cursor = await conn.execute(
"SELECT status, COUNT(*) AS total FROM requests GROUP BY status" "SELECT status, COUNT(*) AS total FROM requests"
f"{where} GROUP BY status"
) )
rows = await cursor.fetchall() rows = await cursor.fetchall()
by_status = { by_status = {
@ -838,6 +856,7 @@ class Database:
} }
cursor = await conn.execute( cursor = await conn.execute(
"SELECT COUNT(DISTINCT user_id) AS total FROM requests" "SELECT COUNT(DISTINCT user_id) AS total FROM requests"
f"{where}"
) )
row = await cursor.fetchone() row = await cursor.fetchone()
return { return {
@ -846,10 +865,16 @@ class Database:
"by_status": by_status, "by_status": by_status,
} }
async def list_users(self, limit: int = 100) -> list[UserStat]: async def list_users(
self, limit: int = 100, *, include_hidden: bool = True
) -> list[UserStat]:
where = "" if include_hidden else " WHERE hidden = FALSE"
username_filter = (
"" if include_hidden else " AND r2.hidden = FALSE"
)
async with self._connection_pool().connection() as conn: async with self._connection_pool().connection() as conn:
cursor = await conn.execute( cursor = await conn.execute(
""" f"""
SELECT SELECT
user_id, user_id,
COUNT(*) AS total, COUNT(*) AS total,
@ -858,9 +883,11 @@ class Database:
SELECT username FROM requests AS r2 SELECT username FROM requests AS r2
WHERE r2.user_id = requests.user_id WHERE r2.user_id = requests.user_id
AND r2.username IS NOT NULL AND r2.username IS NOT NULL
{username_filter}
ORDER BY r2.id DESC LIMIT 1 ORDER BY r2.id DESC LIMIT 1
) AS username ) AS username
FROM requests FROM requests
{where}
GROUP BY user_id GROUP BY user_id
ORDER BY last_seen DESC ORDER BY last_seen DESC
LIMIT %s LIMIT %s
@ -949,6 +976,14 @@ class Database:
(request_id,), (request_id,),
) )
async def set_hidden(self, request_id: int, hidden: bool) -> bool:
async with self._connection_pool().connection() as conn:
cursor = await conn.execute(
"UPDATE requests SET hidden = %s WHERE id = %s RETURNING id",
(hidden, request_id),
)
return await cursor.fetchone() is not None
async def create_pending_payment( async def create_pending_payment(
self, self,
*, *,

View File

@ -311,6 +311,15 @@ class QueueManager:
) )
await self._comfy.download_output(entry, output_path) await self._comfy.download_output(entry, output_path)
try:
await self._comfy.delete_history(prompt_id)
except Exception:
logger.warning(
"Не удалось очистить историю ComfyUI для %s",
prompt_id,
exc_info=True,
)
await self._db.set_done(request_id, str(output_path)) await self._db.set_done(request_id, str(output_path))
request = await self._refresh(request_id) request = await self._refresh(request_id)
self._active_users.discard(request.user_id) self._active_users.discard(request.user_id)

View File

@ -0,0 +1,26 @@
param(
[string]$ComfyRoot = "C:\ComfyUI",
[int]$RetentionHours = 6,
[switch]$WhatIf
)
$cutoff = (Get-Date).AddHours(-[Math]::Abs($RetentionHours))
$paths = @(
(Join-Path $ComfyRoot "output"),
(Join-Path $ComfyRoot "temp")
)
foreach ($path in $paths) {
if (-not (Test-Path -LiteralPath $path)) {
continue
}
Get-ChildItem -LiteralPath $path -Recurse -File |
Where-Object { $_.LastWriteTime -lt $cutoff } |
ForEach-Object {
if ($WhatIf) {
Write-Output $_.FullName
} else {
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
}
}
}

View File

@ -56,6 +56,8 @@ def web_settings(tmp_path: Path) -> Iterator[Settings]:
bot_token="", bot_token="",
database_url=url, database_url=url,
storage_dir=tmp_path / "storage", storage_dir=tmp_path / "storage",
web_admin_username="admin",
web_admin_password="secret",
) )
try: try:
yield settings yield settings

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import httpx
from comfy_client import ComfyClient, _format_api_error from comfy_client import ComfyClient, _format_api_error
@ -44,3 +46,26 @@ def test_find_output_text() -> None:
entry = {"outputs": {"3": {"text": ["A red apple"]}}} entry = {"outputs": {"3": {"text": ["A red apple"]}}}
text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type] text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type]
assert text == "A red apple" assert text == "A red apple"
async def test_delete_history_posts_prompt_id() -> None:
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["json"] = request.content.decode()
return httpx.Response(200)
client = ComfyClient("http://comfy", "ws://comfy")
await client.close()
client._client = httpx.AsyncClient( # type: ignore[attr-defined]
transport=httpx.MockTransport(handler),
base_url="http://comfy",
)
try:
await client.delete_history("prompt-1")
finally:
await client.close()
assert seen["path"] == "/history"
assert '"prompt-1"' in str(seen["json"])

View File

@ -273,6 +273,48 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None:
assert await db.accrue_daily_credits(future) == 0 assert await db.accrue_daily_credits(future) == 0
async def test_hidden_requests_are_excluded_from_public_queries(
db: Database,
) -> None:
visible = await db.create_request(
user_id=1,
username="alice",
mode="t2i",
prompt_text="visible",
input_file_path=None,
seed=1,
)
await db.set_done(visible.id, "/tmp/visible.png")
hidden = await db.create_request(
user_id=2,
username="bob",
mode="t2i",
prompt_text="hidden",
input_file_path=None,
seed=2,
)
await db.set_done(hidden.id, "/tmp/hidden.png")
assert await db.set_hidden(hidden.id, True) is True
public = await db.list_requests(include_hidden=False)
assert [item.id for item in public] == [visible.id]
assert await db.count_requests(include_hidden=False) == 1
admin = await db.list_requests(include_hidden=True)
assert {item.id for item in admin} == {visible.id, hidden.id}
stats = await db.stats(include_hidden=False)
assert stats["total"] == 1
assert stats["users"] == 1
public_users = await db.list_users(include_hidden=False)
assert {item.user_id for item in public_users} == {1}
loaded = await db.get_request(hidden.id)
assert loaded is not None
assert loaded.hidden is True
async def test_user_context_lifecycle(db: Database) -> None: async def test_user_context_lifecycle(db: Database) -> None:
assert await db.get_user_context(1) is None assert await db.get_user_context(1) is None

View File

@ -20,6 +20,7 @@ class FakeComfy:
def __init__(self) -> None: def __init__(self) -> None:
self.workflows: list[dict] = [] self.workflows: list[dict] = []
self.uploads: list[tuple[Path, str]] = [] self.uploads: list[tuple[Path, str]] = []
self.deleted_history: list[str] = []
async def open_progress(self, client_id: str): async def open_progress(self, client_id: str):
return None return None
@ -44,6 +45,9 @@ class FakeComfy:
self.uploads.append((path, remote_name)) self.uploads.append((path, remote_name))
return remote_name return remote_name
async def delete_history(self, prompt_id: str) -> None:
self.deleted_history.append(prompt_id)
class FakeEnhancer: class FakeEnhancer:
enabled = True enabled = True
@ -154,6 +158,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None:
settings = make_settings(tmp_path) settings = make_settings(tmp_path)
queue = make_queue(db, settings, events) queue = make_queue(db, settings, events)
comfy = queue._comfy # type: ignore[attr-defined]
await queue.start() await queue.start()
try: try:
request, _ = await queue.enqueue( request, _ = await queue.enqueue(
@ -166,6 +171,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None:
output = Path(event.request.output_file_path or "") output = Path(event.request.output_file_path or "")
assert output.exists() assert output.exists()
assert output.name == f"{request.id}_output.png" assert output.name == f"{request.id}_output.png"
assert comfy.deleted_history == ["prompt-1"]
finally: finally:
await queue.stop() await queue.stop()

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
import psycopg import psycopg
@ -10,6 +11,11 @@ from psycopg.rows import dict_row
from config import Settings from config import Settings
from web import create_app from web import create_app
ADMIN_HEADERS = {
"Authorization": "Basic "
+ base64.b64encode(b"admin:secret").decode()
}
def insert_request( def insert_request(
database_url: str, database_url: str,
@ -21,14 +27,15 @@ def insert_request(
output: str | None = None, output: str | None = None,
username: str | None = None, username: str | None = None,
enhanced: str | None = None, enhanced: str | None = None,
hidden: bool = False,
) -> int: ) -> int:
with psycopg.connect(database_url, row_factory=dict_row) as connection: with psycopg.connect(database_url, row_factory=dict_row) as connection:
row = connection.execute( row = connection.execute(
""" """
INSERT INTO requests INSERT INTO requests
(user_id, username, timestamp, prompt_text, enhanced_prompt, (user_id, username, timestamp, prompt_text, enhanced_prompt,
status, mode, seed, output_file_path) status, mode, seed, output_file_path, hidden)
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s)
RETURNING id RETURNING id
""", """,
( (
@ -40,6 +47,7 @@ def insert_request(
status, status,
mode, mode,
output, output,
hidden,
), ),
).fetchone() ).fetchone()
if row is None: if row is None:
@ -161,17 +169,36 @@ def test_user_management(web_settings: Settings) -> None:
credits=0, credits=0,
) )
page = client.get("/users") page = client.get("/admin/users", headers=ADMIN_HEADERS)
assert page.status_code == 200 assert page.status_code == 200
assert "@alice" in page.text assert "@alice" in page.text
assert "@bob" in page.text assert "@bob" in page.text
assert ( assert (
client.post("/users/1/credits", data={"credits": "9"}).status_code client.post(
== 200 "/admin/users/1/credits",
data={"credits": "9"},
headers=ADMIN_HEADERS,
follow_redirects=False,
).status_code
== 303
)
assert (
client.post(
"/admin/users/1/admin",
headers=ADMIN_HEADERS,
follow_redirects=False,
).status_code
== 303
)
assert (
client.post(
"/admin/users/2/block",
headers=ADMIN_HEADERS,
follow_redirects=False,
).status_code
== 303
) )
assert client.post("/users/1/admin").status_code == 200
assert client.post("/users/2/block").status_code == 200
with psycopg.connect( with psycopg.connect(
web_settings.database_url, row_factory=dict_row web_settings.database_url, row_factory=dict_row
@ -192,6 +219,77 @@ def test_user_management(web_settings: Settings) -> None:
assert second["is_blocked"] is True assert second["is_blocked"] is True
def test_admin_auth_and_public_users_redirect(
web_settings: Settings,
) -> None:
app = create_app(web_settings)
with TestClient(app) as client:
assert (
client.get("/admin", follow_redirects=False).status_code == 401
)
assert (
client.get(
"/admin/users", follow_redirects=False
).status_code
== 401
)
redirect = client.get("/users", follow_redirects=False)
assert redirect.status_code == 307
assert redirect.headers["location"] == "/admin/users"
assert (
client.get("/admin", headers=ADMIN_HEADERS).status_code == 200
)
assert (
client.get(
"/admin/users", headers=ADMIN_HEADERS
).status_code
== 200
)
def test_hidden_requests_are_admin_only(web_settings: Settings) -> None:
app = create_app(web_settings)
with TestClient(app) as client:
visible = insert_request(
web_settings.database_url,
user_id=1,
prompt="visible prompt",
)
hidden = insert_request(
web_settings.database_url,
user_id=2,
prompt="hidden prompt",
hidden=True,
)
public = client.get("/")
assert "visible prompt" in public.text
assert "hidden prompt" not in public.text
assert client.get(f"/request/{hidden}").status_code == 404
assert client.get(f"/media/{hidden}/output").status_code == 404
assert client.get(f"/thumb/{hidden}").status_code == 404
admin = client.get("/admin", headers=ADMIN_HEADERS)
assert "hidden prompt" in admin.text
assert (
client.get(
f"/admin/request/{hidden}", headers=ADMIN_HEADERS
).status_code
== 200
)
response = client.post(
f"/admin/requests/{visible}/hide",
headers=ADMIN_HEADERS,
follow_redirects=False,
)
assert response.status_code == 303
public = client.get("/")
assert "visible prompt" not in public.text
admin = client.get("/admin", headers=ADMIN_HEADERS)
assert "visible prompt" in admin.text
def test_user_search(web_settings: Settings) -> None: def test_user_search(web_settings: Settings) -> None:
app = create_app(web_settings) app = create_app(web_settings)
with TestClient(app) as client: with TestClient(app) as client:
@ -202,7 +300,9 @@ def test_user_search(web_settings: Settings) -> None:
web_settings.database_url, user_id=2, username="bob" web_settings.database_url, user_id=2, username="bob"
) )
page = client.get("/users?q=alice") page = client.get(
"/admin/users?q=alice", headers=ADMIN_HEADERS
)
assert page.status_code == 200 assert page.status_code == 200
assert "@alice" in page.text assert "@alice" in page.text
assert "@bob" not in page.text assert "@bob" not in page.text

310
web.py
View File

@ -4,6 +4,7 @@ import asyncio
import logging import logging
import math import math
import os import os
import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -11,8 +12,9 @@ from typing import Annotated, Any
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI, Form, HTTPException, Request from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps from PIL import Image, ImageOps
@ -29,6 +31,7 @@ STATUSES = ("queued", "running", "done", "failed")
MODES = ("t2i", "i2i") MODES = ("t2i", "i2i")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
security = HTTPBasic(auto_error=False)
def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None: def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None:
@ -96,67 +99,48 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings app.state.settings = settings
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/healthz") async def require_admin(
async def healthz() -> dict[str, bool]: request: Request,
return {"ok": True} credentials: Annotated[
HTTPBasicCredentials | None, Depends(security)
@app.get("/users", response_class=HTMLResponse) ],
async def users(request: Request, q: str = ""): ) -> None:
db: Database = request.app.state.db current: Settings = request.app.state.settings
rows = await db.list_users_admin(1000) if not current.web_admin_password:
needle = q.strip().lower().lstrip("@") raise HTTPException(
if needle: status_code=503,
rows = [ detail="Админ-панель отключена: задай WEB_ADMIN_PASSWORD",
user )
for user in rows if credentials is None:
if needle in str(user.user_id) raise HTTPException(
or (user.username and needle in user.username.lower()) status_code=401,
] detail="Требуется авторизация",
return templates.TemplateResponse( headers={"WWW-Authenticate": "Basic"},
request=request, )
name="users.html", username_ok = secrets.compare_digest(
context={"users": rows, "q": q}, credentials.username, current.web_admin_username
) )
password_ok = secrets.compare_digest(
credentials.password, current.web_admin_password
)
if not (username_ok and password_ok):
raise HTTPException(
status_code=401,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Basic"},
)
@app.post("/users/{user_id}/credits") async def history_page(
async def user_credits(
request: Request, request: Request,
user_id: int, *,
credits: Annotated[int, Form()], admin: bool,
) -> RedirectResponse: user_id: str,
db: Database = request.app.state.db status: str,
if not await db.set_credits(user_id, max(0, credits)): mode: str,
raise HTTPException(status_code=404, detail="Пользователь не найден") page: int,
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/admin")
async def user_admin(request: Request, user_id: int) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
await db.set_admin(user_id, not user.is_admin)
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/block")
async def user_block(request: Request, user_id: int) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
await db.set_blocked(user_id, not user.is_blocked)
return RedirectResponse("/users", status_code=303)
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user_id: str = "",
status: str = "",
mode: str = "",
page: int = 1,
): ):
db: Database = request.app.state.db db: Database = request.app.state.db
settings: Settings = request.app.state.settings current: Settings = request.app.state.settings
error = "" error = ""
uid: int | None = None uid: int | None = None
@ -170,8 +154,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
status = status if status in STATUSES else "" status = status if status in STATUSES else ""
mode = mode if mode in MODES else "" mode = mode if mode in MODES else ""
total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None) total = await db.count_requests(
page_size = settings.web_page_size user_id=uid,
status=status or None,
mode=mode or None,
include_hidden=admin,
)
page_size = current.web_page_size
pages = max(1, math.ceil(total / page_size)) pages = max(1, math.ceil(total / page_size))
page = max(1, min(page, pages)) page = max(1, min(page, pages))
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -180,11 +169,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
user_id=uid, user_id=uid,
status=status or None, status=status or None,
mode=mode or None, mode=mode or None,
include_hidden=admin,
limit=page_size, limit=page_size,
offset=offset, offset=offset,
) )
users = await db.list_users() users = await db.list_users(include_hidden=admin)
stats = await db.stats() stats = await db.stats(include_hidden=admin)
base_filters = { base_filters = {
key: value key: value
@ -195,13 +185,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
) )
if value if value
} }
base_path = "/admin" if admin else ""
history_path = base_path or "/"
def page_url(number: int) -> str: def page_url(number: int) -> str:
params = dict(base_filters) params = dict(base_filters)
if number > 1: if number > 1:
params["page"] = str(number) params["page"] = str(number)
query = urlencode(params) query = urlencode(params)
return f"/?{query}" if query else "/" return f"{history_path}?{query}" if query else history_path
context: dict[str, Any] = { context: dict[str, Any] = {
"rows": rows, "rows": rows,
@ -216,6 +208,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"page": page, "page": page,
"pages": pages, "pages": pages,
"total": total, "total": total,
"admin": admin,
"base_path": base_path,
"history_path": history_path,
"prev_url": page_url(page - 1) if page > 1 else None, "prev_url": page_url(page - 1) if page > 1 else None,
"next_url": page_url(page + 1) if page < pages else None, "next_url": page_url(page + 1) if page < pages else None,
} }
@ -223,54 +218,68 @@ def create_app(settings: Settings | None = None) -> FastAPI:
request=request, name="index.html", context=context request=request, name="index.html", context=context
) )
@app.get("/request/{request_id}", response_class=HTMLResponse) async def detail_page(
async def detail(request: Request, request_id: int): request: Request, request_id: int, *, admin: bool
):
db: Database = request.app.state.db db: Database = request.app.state.db
item = await db.get_request(request_id) item = await db.get_request(request_id)
if item is None: if item is None or (item.hidden and not admin):
raise HTTPException(status_code=404, detail="Запрос не найден") raise HTTPException(status_code=404, detail="Запрос не найден")
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="detail.html", name="detail.html",
context={"item": item}, context={
"item": item,
"admin": admin,
"base_path": "/admin" if admin else "",
},
) )
@app.get("/media/{request_id}/{kind}") async def media_response(
async def media(request: Request, request_id: int, kind: str): request: Request, request_id: int, kind: str, *, admin: bool
):
if kind not in {"input", "output"}: if kind not in {"input", "output"}:
raise HTTPException(status_code=404, detail="Не найдено") raise HTTPException(status_code=404, detail="Не найдено")
db: Database = request.app.state.db db: Database = request.app.state.db
settings: Settings = request.app.state.settings current: Settings = request.app.state.settings
item: HistoryRequest | None = await db.get_request(request_id) item: HistoryRequest | None = await db.get_request(request_id)
if item is None: if item is None or (item.hidden and not admin):
raise HTTPException(status_code=404, detail="Запрос не найден") raise HTTPException(status_code=404, detail="Запрос не найден")
raw_path = item.input_file_path if kind == "input" else item.output_file_path raw_path = (
item.input_file_path if kind == "input" else item.output_file_path
)
if not raw_path: if not raw_path:
raise HTTPException(status_code=404, detail="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
storage_root = settings.storage_dir.resolve() storage_root = current.storage_dir.resolve()
path = _safe_storage_path(raw_path, storage_root) path = _safe_storage_path(raw_path, storage_root)
if path is None or not path.is_file(): if path is None or not path.is_file():
raise HTTPException(status_code=404, detail="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path) return FileResponse(path)
@app.get("/thumb/{request_id}") async def thumbnail_response(
async def thumbnail(request: Request, request_id: int): request: Request, request_id: int, *, admin: bool
):
db: Database = request.app.state.db db: Database = request.app.state.db
settings: Settings = request.app.state.settings current: Settings = request.app.state.settings
item: HistoryRequest | None = await db.get_request(request_id) item: HistoryRequest | None = await db.get_request(request_id)
if item is None or not item.output_file_path: if item is None or (item.hidden and not admin):
raise HTTPException(status_code=404, detail="Файл не найден")
if not item.output_file_path:
raise HTTPException(status_code=404, detail="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
storage_root = settings.storage_dir.resolve() storage_root = current.storage_dir.resolve()
source = _safe_storage_path(item.output_file_path, storage_root) source = _safe_storage_path(item.output_file_path, storage_root)
if source is None or not source.is_file(): if source is None or not source.is_file():
raise HTTPException(status_code=404, detail="Файл не найден") raise HTTPException(status_code=404, detail="Файл не найден")
cache_dir = storage_root / ".thumbnails" cache_dir = storage_root / ".thumbnails"
cache_path = cache_dir / f"{request_id}.jpg" cache_path = cache_dir / f"{request_id}.jpg"
if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime: if (
cache_path.is_file()
and cache_path.stat().st_mtime >= source.stat().st_mtime
):
return FileResponse( return FileResponse(
cache_path, cache_path,
media_type="image/jpeg", media_type="image/jpeg",
@ -280,7 +289,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
try: try:
await asyncio.to_thread(_make_thumbnail, source, cache_path) await asyncio.to_thread(_make_thumbnail, source, cache_path)
except Exception: except Exception:
logger.exception("Не удалось создать превью для запроса %s", request_id) logger.exception(
"Не удалось создать превью для запроса %s", request_id
)
return FileResponse(source) return FileResponse(source)
return FileResponse( return FileResponse(
@ -289,6 +300,147 @@ def create_app(settings: Settings | None = None) -> FastAPI:
headers={"Cache-Control": "public, max-age=3600"}, headers={"Cache-Control": "public, max-age=3600"},
) )
@app.get("/healthz")
async def healthz() -> dict[str, bool]:
return {"ok": True}
@app.get("/users")
async def users_redirect() -> RedirectResponse:
return RedirectResponse("/admin/users", status_code=307)
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user_id: str = "",
status: str = "",
mode: str = "",
page: int = 1,
):
return await history_page(
request,
admin=False,
user_id=user_id,
status=status,
mode=mode,
page=page,
)
@app.get("/request/{request_id}", response_class=HTMLResponse)
async def detail(request: Request, request_id: int):
return await detail_page(request, request_id, admin=False)
@app.get("/media/{request_id}/{kind}")
async def media(request: Request, request_id: int, kind: str):
return await media_response(request, request_id, kind, admin=False)
@app.get("/thumb/{request_id}")
async def thumbnail(request: Request, request_id: int):
return await thumbnail_response(request, request_id, admin=False)
admin_router = APIRouter(
prefix="/admin", dependencies=[Depends(require_admin)]
)
@admin_router.get("", response_class=HTMLResponse)
@admin_router.get("/", response_class=HTMLResponse)
async def admin_index(
request: Request,
user_id: str = "",
status: str = "",
mode: str = "",
page: int = 1,
):
return await history_page(
request,
admin=True,
user_id=user_id,
status=status,
mode=mode,
page=page,
)
@admin_router.get("/users", response_class=HTMLResponse)
async def admin_users(request: Request, q: str = ""):
db: Database = request.app.state.db
rows = await db.list_users_admin(1000)
needle = q.strip().lower().lstrip("@")
if needle:
rows = [
user
for user in rows
if needle in str(user.user_id)
or (user.username and needle in user.username.lower())
]
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": rows, "q": q, "admin": True},
)
@admin_router.post("/users/{user_id}/credits")
async def admin_user_credits(
request: Request,
user_id: int,
credits: Annotated[int, Form()],
) -> RedirectResponse:
db: Database = request.app.state.db
if not await db.set_credits(user_id, max(0, credits)):
raise HTTPException(
status_code=404, detail="Пользователь не найден"
)
return RedirectResponse("/admin/users", status_code=303)
@admin_router.post("/users/{user_id}/admin")
async def admin_user_admin(
request: Request, user_id: int
) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(
status_code=404, detail="Пользователь не найден"
)
await db.set_admin(user_id, not user.is_admin)
return RedirectResponse("/admin/users", status_code=303)
@admin_router.post("/users/{user_id}/block")
async def admin_user_block(
request: Request, user_id: int
) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(
status_code=404, detail="Пользователь не найден"
)
await db.set_blocked(user_id, not user.is_blocked)
return RedirectResponse("/admin/users", status_code=303)
@admin_router.post("/requests/{request_id}/hide")
async def admin_hide_request(
request: Request, request_id: int
) -> RedirectResponse:
db: Database = request.app.state.db
item = await db.get_request(request_id)
if item is None:
raise HTTPException(status_code=404, detail="Запрос не найден")
if not await db.set_hidden(request_id, not item.hidden):
raise HTTPException(status_code=404, detail="Запрос не найден")
return RedirectResponse("/admin", status_code=303)
@admin_router.get("/request/{request_id}", response_class=HTMLResponse)
async def admin_detail(request: Request, request_id: int):
return await detail_page(request, request_id, admin=True)
@admin_router.get("/media/{request_id}/{kind}")
async def admin_media(request: Request, request_id: int, kind: str):
return await media_response(request, request_id, kind, admin=True)
@admin_router.get("/thumb/{request_id}")
async def admin_thumbnail(request: Request, request_id: int):
return await thumbnail_response(request, request_id, admin=True)
app.include_router(admin_router)
return app return app

View File

@ -10,8 +10,7 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/">MpakoImageAIbot</a> <a class="brand" href="/">MpakoImageAIbot</a>
<nav class="nav"> <nav class="nav">
<a href="/">История</a> {% block nav %}<a href="/">История</a>{% endblock %}
<a href="/users">Пользователи</a>
</nav> </nav>
<span class="muted">История запросов</span> <span class="muted">История запросов</span>
</header> </header>

View File

@ -1,12 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %} {% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %}
{% block nav %}
{% if admin %}
<a href="/admin">Админ-история</a>
<a href="/admin/users">Пользователи</a>
<a href="/">Публичная история</a>
{% else %}
<a href="/">История</a>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<a class="back" href="/">← К истории</a> <a class="back" href="{{ base_path or '/' }}">← К истории</a>
<h1>Запрос #{{ item.id }}</h1> <h1>Запрос #{{ item.id }}</h1>
{% if admin %}
<form class="inline-form" method="post" action="/admin/requests/{{ item.id }}/hide">
<button type="submit">{{ "Показать в публичной истории" if item.hidden else "Скрыть из публичной истории" }}</button>
</form>
{% if item.hidden %}<div class="alert">Запрос скрыт из публичной истории.</div>{% endif %}
{% endif %}
<dl class="meta"> <dl class="meta">
<dt>Пользователь</dt> <dt>Пользователь</dt>
<dd><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd> <dd><a href="{{ base_path or '/' }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd>
<dt>Статус</dt> <dt>Статус</dt>
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd> <dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
<dt>Режим</dt> <dt>Режим</dt>
@ -36,16 +51,16 @@
<div class="images"> <div class="images">
{% if item.input_file_path %} {% if item.input_file_path %}
<figure> <figure>
<a href="/media/{{ item.id }}/input" target="_blank" rel="noreferrer"> <a href="{{ base_path }}/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
<img src="/media/{{ item.id }}/input" alt="Входное изображение"> <img src="{{ base_path }}/media/{{ item.id }}/input" alt="Входное изображение">
</a> </a>
<figcaption>Вход</figcaption> <figcaption>Вход</figcaption>
</figure> </figure>
{% endif %} {% endif %}
{% if item.output_file_path %} {% if item.output_file_path %}
<figure> <figure>
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer"> <a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img src="/media/{{ item.id }}/output" alt="Результат"> <img src="{{ base_path }}/media/{{ item.id }}/output" alt="Результат">
</a> </a>
<figcaption>Результат</figcaption> <figcaption>Результат</figcaption>
</figure> </figure>

View File

@ -1,5 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}История запросов — MpakoImageAIbot{% endblock %} {% block title %}{{ "Админ-история" if admin else "История запросов" }} — MpakoImageAIbot{% endblock %}
{% block nav %}
{% if admin %}
<a href="/admin">Админ-история</a>
<a href="/admin/users">Пользователи</a>
<a href="/">Публичная история</a>
{% else %}
<a href="/">История</a>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<section class="cards"> <section class="cards">
<div class="card"><span class="card-value">{{ stats.total }}</span><span class="card-label">Всего запросов</span></div> <div class="card"><span class="card-value">{{ stats.total }}</span><span class="card-label">Всего запросов</span></div>
@ -8,7 +17,7 @@
<div class="card"><span class="card-value">{{ stats.by_status.get('failed', 0) }}</span><span class="card-label">Ошибок</span></div> <div class="card"><span class="card-value">{{ stats.by_status.get('failed', 0) }}</span><span class="card-label">Ошибок</span></div>
</section> </section>
<form class="filters" method="get" action="/"> <form class="filters" method="get" action="{{ history_path }}">
<label> <label>
Telegram user_id Telegram user_id
<input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616"> <input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616">
@ -41,7 +50,7 @@
<div class="filter-actions"> <div class="filter-actions">
<button type="submit">Фильтровать</button> <button type="submit">Фильтровать</button>
<a class="reset" href="/">Сбросить</a> <a class="reset" href="{{ history_path }}">Сбросить</a>
</div> </div>
</form> </form>
@ -59,6 +68,7 @@
<th>Статус</th> <th>Статус</th>
<th>Промпт</th> <th>Промпт</th>
<th>Seed</th> <th>Seed</th>
{% if admin %}<th>Скрыто</th>{% endif %}
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -68,15 +78,15 @@
<td class="mono">#{{ item.id }}</td> <td class="mono">#{{ item.id }}</td>
<td class="thumb-cell"> <td class="thumb-cell">
{% if item.output_file_path %} {% if item.output_file_path %}
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer"> <a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img class="thumb" src="/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy"> <img class="thumb" src="{{ base_path }}/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
</a> </a>
{% else %} {% else %}
<span class="muted">—</span> <span class="muted">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td> <td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
<td><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td> <td><a href="{{ history_path }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td>
<td class="mono">{{ item.mode }}{% if item.is_premium %} <span class="mark">P</span>{% endif %}</td> <td class="mono">{{ item.mode }}{% if item.is_premium %} <span class="mark">P</span>{% endif %}</td>
<td><span class="badge {{ item.status }}">{{ item.status }}</span></td> <td><span class="badge {{ item.status }}">{{ item.status }}</span></td>
<td class="prompt" title="{{ item.prompt_text }}"> <td class="prompt" title="{{ item.prompt_text }}">
@ -88,10 +98,18 @@
{% endif %} {% endif %}
</td> </td>
<td class="mono">{{ item.seed if item.seed is not none else '—' }}</td> <td class="mono">{{ item.seed if item.seed is not none else '—' }}</td>
<td><a class="details" href="/request/{{ item.id }}">Детали</a></td> {% if admin %}
<td>
{% if item.hidden %}<span class="badge">скрыт</span>{% else %}<span class="muted">—</span>{% endif %}
<form class="inline-form" method="post" action="/admin/requests/{{ item.id }}/hide">
<button type="submit">{{ "Показать" if item.hidden else "Скрыть" }}</button>
</form>
</td>
{% endif %}
<td><a class="details" href="{{ base_path }}/request/{{ item.id }}">Детали</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr> <tr><td colspan="{% if admin %}10{% else %}9{% endif %}" class="empty">Ничего не найдено</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -1,9 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Пользователи — MpakoImageAIbot{% endblock %} {% block title %}Пользователи — MpakoImageAIbot{% endblock %}
{% block nav %}
<a href="/admin">Админ-история</a>
<a href="/admin/users">Пользователи</a>
<a href="/">Публичная история</a>
{% endblock %}
{% block content %} {% block content %}
<div class="page-head"> <div class="page-head">
<h1>Пользователи</h1> <h1>Пользователи</h1>
<form class="search" method="get" action="/users"> <form class="search" method="get" action="/admin/users">
<input name="q" value="{{ q }}" placeholder="ID или username"> <input name="q" value="{{ q }}" placeholder="ID или username">
<button type="submit">Найти</button> <button type="submit">Найти</button>
</form> </form>
@ -28,24 +33,24 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td class="mono"> <td class="mono">
<a href="/?user_id={{ user.user_id }}">{{ user.user_id }}</a> <a href="/admin?user_id={{ user.user_id }}">{{ user.user_id }}</a>
</td> </td>
<td>{{ user.display_user }}</td> <td>{{ user.display_user }}</td>
<td> <td>
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/credits"> <form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/credits">
<input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}"> <input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}">
<button type="submit">Сохранить</button> <button type="submit">Сохранить</button>
</form> </form>
</td> </td>
<td> <td>
{% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %} {% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %}
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/admin"> <form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/admin">
<button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button> <button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button>
</form> </form>
</td> </td>
<td> <td>
{% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %} {% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %}
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/block"> <form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/block">
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button> <button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
</form> </form>
</td> </td>
@ -56,7 +61,7 @@
</td> </td>
<td> <td>
{% if user.referrer_id %} {% if user.referrer_id %}
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a> <a href="/admin/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td>{{ user.first_paid_at|fmt_dt }}</td> <td>{{ user.first_paid_at|fmt_dt }}</td>