Админ-панель и очистка ComfyUI
Публичная история отделена от админки /admin с HTTP Basic, управление пользователями перенесено в /admin/users. Добавлено скрытие запросов из публичной истории. После успешной генерации бот удаляет запись ComfyUI по prompt_id; для файлов добавлен скрипт очистки.
This commit is contained in:
parent
d593e35921
commit
67d3180ef6
|
|
@ -59,8 +59,11 @@ PAYMENTS_ENABLED=true
|
|||
STARS_PRICE=5
|
||||
PREMIUM_STARS_PRICE=15
|
||||
|
||||
# Веб-история: только локальная сеть, без авторизации
|
||||
# Веб-история: только локальная сеть
|
||||
WEB_BIND_IP=192.168.31.158
|
||||
WEB_PAGE_SIZE=50
|
||||
# Админ-панель /admin: HTTP Basic
|
||||
WEB_ADMIN_USERNAME=admin
|
||||
WEB_ADMIN_PASSWORD=change-me
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -158,7 +158,7 @@ BLOCKED_PUBLIC_FIGURES=
|
|||
|
||||
## Управление пользователями
|
||||
|
||||
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
|
||||
В админ-панели есть раздел **«Пользователи»** (`/admin/users`):
|
||||
|
||||
- поиск по ID или username;
|
||||
- установка точного баланса генераций;
|
||||
|
|
@ -248,6 +248,8 @@ http://192.168.31.158:8099
|
|||
|
||||
Возможности:
|
||||
|
||||
Публичная страница `/` показывает только историю:
|
||||
|
||||
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
||||
- в списке показывается `@username`, если он известен, иначе Telegram ID;
|
||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||
|
|
@ -255,14 +257,21 @@ http://192.168.31.158:8099
|
|||
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
||||
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
|
||||
|
||||
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
|
||||
Админ-панель находится на `/admin` и защищена HTTP Basic:
|
||||
|
||||
- `/admin` — история, включая скрытые запросы;
|
||||
- `/admin/users` — управление пользователями;
|
||||
- скрытие/показ запроса из публичной истории;
|
||||
- скрытые запросы не видны в публичном списке, их карточки и медиа отдают `404`.
|
||||
|
||||
```dotenv
|
||||
WEB_BIND_IP=192.168.31.158
|
||||
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`. При старте бот проверяет, что все узлы и поля существуют.
|
||||
|
||||
## Очистка 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
|
||||
|
|
|
|||
15
checklist.md
15
checklist.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
- Реализованы блоки 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 и веб-история.
|
||||
- 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 и первый деплой.
|
||||
|
||||
## 0. Допущения и решения по умолчанию
|
||||
|
|
@ -392,6 +392,19 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
|||
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -246,6 +246,26 @@ class ComfyClient:
|
|||
entry = payload.get(prompt_id)
|
||||
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]:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ class Settings:
|
|||
premium_stars_price: int = 15
|
||||
web_page_size: int = 50
|
||||
web_bind_ip: str = "127.0.0.1"
|
||||
web_admin_username: str = "admin"
|
||||
web_admin_password: str = ""
|
||||
|
||||
@property
|
||||
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_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip()
|
||||
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
|
||||
|
||||
|
|
|
|||
47
db.py
47
db.py
|
|
@ -70,6 +70,7 @@ class Request:
|
|||
payment_charge_id: str | None
|
||||
payment_refunded: bool
|
||||
is_premium: bool
|
||||
hidden: bool
|
||||
mode: str
|
||||
seed: int | None
|
||||
error_text: str | None
|
||||
|
|
@ -97,6 +98,7 @@ class Request:
|
|||
payment_charge_id=row["payment_charge_id"],
|
||||
payment_refunded=bool(row["payment_refunded"]),
|
||||
is_premium=bool(row["is_premium"]),
|
||||
hidden=bool(row["hidden"]),
|
||||
mode=row["mode"],
|
||||
seed=row["seed"],
|
||||
error_text=row["error_text"],
|
||||
|
|
@ -250,6 +252,7 @@ CREATE TABLE IF NOT EXISTS requests (
|
|||
payment_charge_id TEXT,
|
||||
payment_refunded 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')),
|
||||
seed BIGINT,
|
||||
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 context_base_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_refunded BOOLEAN"
|
||||
|
|
@ -779,6 +786,7 @@ class Database:
|
|||
user_id: int | None,
|
||||
status: str | None,
|
||||
mode: str | None,
|
||||
include_hidden: bool = True,
|
||||
) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
|
@ -791,6 +799,8 @@ class Database:
|
|||
if mode:
|
||||
clauses.append("mode = %s")
|
||||
params.append(mode)
|
||||
if not include_hidden:
|
||||
clauses.append("hidden = FALSE")
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return where, params
|
||||
|
||||
|
|
@ -800,10 +810,13 @@ class Database:
|
|||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
mode: str | None = None,
|
||||
include_hidden: bool = True,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Request]:
|
||||
where, params = self._filters(user_id, status, mode)
|
||||
where, params = self._filters(
|
||||
user_id, status, mode, include_hidden=include_hidden
|
||||
)
|
||||
query = (
|
||||
f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s"
|
||||
)
|
||||
|
|
@ -818,8 +831,11 @@ class Database:
|
|||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
mode: str | None = None,
|
||||
include_hidden: bool = True,
|
||||
) -> 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:
|
||||
cursor = await conn.execute(
|
||||
f"SELECT COUNT(*) AS total FROM requests{where}", params
|
||||
|
|
@ -827,10 +843,12 @@ class Database:
|
|||
row = await cursor.fetchone()
|
||||
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:
|
||||
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()
|
||||
by_status = {
|
||||
|
|
@ -838,6 +856,7 @@ class Database:
|
|||
}
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(DISTINCT user_id) AS total FROM requests"
|
||||
f"{where}"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return {
|
||||
|
|
@ -846,10 +865,16 @@ class Database:
|
|||
"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:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
user_id,
|
||||
COUNT(*) AS total,
|
||||
|
|
@ -858,9 +883,11 @@ class Database:
|
|||
SELECT username FROM requests AS r2
|
||||
WHERE r2.user_id = requests.user_id
|
||||
AND r2.username IS NOT NULL
|
||||
{username_filter}
|
||||
ORDER BY r2.id DESC LIMIT 1
|
||||
) AS username
|
||||
FROM requests
|
||||
{where}
|
||||
GROUP BY user_id
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT %s
|
||||
|
|
@ -949,6 +976,14 @@ class Database:
|
|||
(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(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -311,6 +311,15 @@ class QueueManager:
|
|||
)
|
||||
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))
|
||||
request = await self._refresh(request_id)
|
||||
self._active_users.discard(request.user_id)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,8 @@ def web_settings(tmp_path: Path) -> Iterator[Settings]:
|
|||
bot_token="",
|
||||
database_url=url,
|
||||
storage_dir=tmp_path / "storage",
|
||||
web_admin_username="admin",
|
||||
web_admin_password="secret",
|
||||
)
|
||||
try:
|
||||
yield settings
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
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"]}}}
|
||||
text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type]
|
||||
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"])
|
||||
|
|
|
|||
|
|
@ -273,6 +273,48 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None:
|
|||
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:
|
||||
assert await db.get_user_context(1) is None
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class FakeComfy:
|
|||
def __init__(self) -> None:
|
||||
self.workflows: list[dict] = []
|
||||
self.uploads: list[tuple[Path, str]] = []
|
||||
self.deleted_history: list[str] = []
|
||||
|
||||
async def open_progress(self, client_id: str):
|
||||
return None
|
||||
|
|
@ -44,6 +45,9 @@ class FakeComfy:
|
|||
self.uploads.append((path, remote_name))
|
||||
return remote_name
|
||||
|
||||
async def delete_history(self, prompt_id: str) -> None:
|
||||
self.deleted_history.append(prompt_id)
|
||||
|
||||
|
||||
class FakeEnhancer:
|
||||
enabled = True
|
||||
|
|
@ -154,6 +158,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None:
|
|||
settings = make_settings(tmp_path)
|
||||
queue = make_queue(db, settings, events)
|
||||
|
||||
comfy = queue._comfy # type: ignore[attr-defined]
|
||||
await queue.start()
|
||||
try:
|
||||
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 "")
|
||||
assert output.exists()
|
||||
assert output.name == f"{request.id}_output.png"
|
||||
assert comfy.deleted_history == ["prompt-1"]
|
||||
finally:
|
||||
await queue.stop()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import psycopg
|
||||
|
|
@ -10,6 +11,11 @@ from psycopg.rows import dict_row
|
|||
from config import Settings
|
||||
from web import create_app
|
||||
|
||||
ADMIN_HEADERS = {
|
||||
"Authorization": "Basic "
|
||||
+ base64.b64encode(b"admin:secret").decode()
|
||||
}
|
||||
|
||||
|
||||
def insert_request(
|
||||
database_url: str,
|
||||
|
|
@ -21,14 +27,15 @@ def insert_request(
|
|||
output: str | None = None,
|
||||
username: str | None = None,
|
||||
enhanced: str | None = None,
|
||||
hidden: bool = False,
|
||||
) -> int:
|
||||
with psycopg.connect(database_url, row_factory=dict_row) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO requests
|
||||
(user_id, username, timestamp, prompt_text, enhanced_prompt,
|
||||
status, mode, seed, output_file_path)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s)
|
||||
status, mode, seed, output_file_path, hidden)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
|
|
@ -40,6 +47,7 @@ def insert_request(
|
|||
status,
|
||||
mode,
|
||||
output,
|
||||
hidden,
|
||||
),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
|
|
@ -161,17 +169,36 @@ def test_user_management(web_settings: Settings) -> None:
|
|||
credits=0,
|
||||
)
|
||||
|
||||
page = client.get("/users")
|
||||
page = client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||
assert page.status_code == 200
|
||||
assert "@alice" in page.text
|
||||
assert "@bob" in page.text
|
||||
|
||||
assert (
|
||||
client.post("/users/1/credits", data={"credits": "9"}).status_code
|
||||
== 200
|
||||
client.post(
|
||||
"/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(
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
app = create_app(web_settings)
|
||||
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"
|
||||
)
|
||||
|
||||
page = client.get("/users?q=alice")
|
||||
page = client.get(
|
||||
"/admin/users?q=alice", headers=ADMIN_HEADERS
|
||||
)
|
||||
assert page.status_code == 200
|
||||
assert "@alice" in page.text
|
||||
assert "@bob" not in page.text
|
||||
|
|
|
|||
310
web.py
310
web.py
|
|
@ -4,6 +4,7 @@ import asyncio
|
|||
import logging
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
|
@ -11,8 +12,9 @@ from typing import Annotated, Any
|
|||
from urllib.parse import urlencode
|
||||
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.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image, ImageOps
|
||||
|
|
@ -29,6 +31,7 @@ STATUSES = ("queued", "running", "done", "failed")
|
|||
MODES = ("t2i", "i2i")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
security = HTTPBasic(auto_error=False)
|
||||
|
||||
|
||||
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.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def 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},
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
credentials: Annotated[
|
||||
HTTPBasicCredentials | None, Depends(security)
|
||||
],
|
||||
) -> None:
|
||||
current: Settings = request.app.state.settings
|
||||
if not current.web_admin_password:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Админ-панель отключена: задай WEB_ADMIN_PASSWORD",
|
||||
)
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Требуется авторизация",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
username_ok = secrets.compare_digest(
|
||||
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 user_credits(
|
||||
async def history_page(
|
||||
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("/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,
|
||||
*,
|
||||
admin: bool,
|
||||
user_id: str,
|
||||
status: str,
|
||||
mode: str,
|
||||
page: int,
|
||||
):
|
||||
db: Database = request.app.state.db
|
||||
settings: Settings = request.app.state.settings
|
||||
current: Settings = request.app.state.settings
|
||||
|
||||
error = ""
|
||||
uid: int | None = None
|
||||
|
|
@ -170,8 +154,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
status = status if status in STATUSES else ""
|
||||
mode = mode if mode in MODES else ""
|
||||
|
||||
total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None)
|
||||
page_size = settings.web_page_size
|
||||
total = await db.count_requests(
|
||||
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))
|
||||
page = max(1, min(page, pages))
|
||||
offset = (page - 1) * page_size
|
||||
|
|
@ -180,11 +169,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
user_id=uid,
|
||||
status=status or None,
|
||||
mode=mode or None,
|
||||
include_hidden=admin,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
users = await db.list_users()
|
||||
stats = await db.stats()
|
||||
users = await db.list_users(include_hidden=admin)
|
||||
stats = await db.stats(include_hidden=admin)
|
||||
|
||||
base_filters = {
|
||||
key: value
|
||||
|
|
@ -195,13 +185,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
)
|
||||
if value
|
||||
}
|
||||
base_path = "/admin" if admin else ""
|
||||
history_path = base_path or "/"
|
||||
|
||||
def page_url(number: int) -> str:
|
||||
params = dict(base_filters)
|
||||
if number > 1:
|
||||
params["page"] = str(number)
|
||||
query = urlencode(params)
|
||||
return f"/?{query}" if query else "/"
|
||||
return f"{history_path}?{query}" if query else history_path
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"rows": rows,
|
||||
|
|
@ -216,6 +208,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
"page": page,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"admin": admin,
|
||||
"base_path": base_path,
|
||||
"history_path": history_path,
|
||||
"prev_url": page_url(page - 1) if page > 1 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
|
||||
)
|
||||
|
||||
@app.get("/request/{request_id}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, request_id: int):
|
||||
async def detail_page(
|
||||
request: Request, request_id: int, *, admin: bool
|
||||
):
|
||||
db: Database = request.app.state.db
|
||||
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="Запрос не найден")
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
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(request: Request, request_id: int, kind: str):
|
||||
async def media_response(
|
||||
request: Request, request_id: int, kind: str, *, admin: bool
|
||||
):
|
||||
if kind not in {"input", "output"}:
|
||||
raise HTTPException(status_code=404, detail="Не найдено")
|
||||
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)
|
||||
if item is None:
|
||||
if item is None or (item.hidden and not admin):
|
||||
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:
|
||||
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)
|
||||
if path is None or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||
return FileResponse(path)
|
||||
|
||||
@app.get("/thumb/{request_id}")
|
||||
async def thumbnail(request: Request, request_id: int):
|
||||
async def thumbnail_response(
|
||||
request: Request, request_id: int, *, admin: bool
|
||||
):
|
||||
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)
|
||||
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="Файл не найден")
|
||||
|
||||
storage_root = settings.storage_dir.resolve()
|
||||
storage_root = current.storage_dir.resolve()
|
||||
source = _safe_storage_path(item.output_file_path, storage_root)
|
||||
if source is None or not source.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||
|
||||
cache_dir = storage_root / ".thumbnails"
|
||||
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(
|
||||
cache_path,
|
||||
media_type="image/jpeg",
|
||||
|
|
@ -280,7 +289,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
try:
|
||||
await asyncio.to_thread(_make_thumbnail, source, cache_path)
|
||||
except Exception:
|
||||
logger.exception("Не удалось создать превью для запроса %s", request_id)
|
||||
logger.exception(
|
||||
"Не удалось создать превью для запроса %s", request_id
|
||||
)
|
||||
return FileResponse(source)
|
||||
|
||||
return FileResponse(
|
||||
|
|
@ -289,6 +300,147 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
<header class="topbar">
|
||||
<a class="brand" href="/">MpakoImageAIbot</a>
|
||||
<nav class="nav">
|
||||
<a href="/">История</a>
|
||||
<a href="/users">Пользователи</a>
|
||||
{% block nav %}<a href="/">История</a>{% endblock %}
|
||||
</nav>
|
||||
<span class="muted">История запросов</span>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
<a class="back" href="/">← К истории</a>
|
||||
<a class="back" href="{{ base_path or '/' }}">← К истории</a>
|
||||
<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">
|
||||
<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>
|
||||
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
|
||||
<dt>Режим</dt>
|
||||
|
|
@ -36,16 +51,16 @@
|
|||
<div class="images">
|
||||
{% if item.input_file_path %}
|
||||
<figure>
|
||||
<a href="/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
|
||||
<img src="/media/{{ item.id }}/input" alt="Входное изображение">
|
||||
<a href="{{ base_path }}/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
|
||||
<img src="{{ base_path }}/media/{{ item.id }}/input" alt="Входное изображение">
|
||||
</a>
|
||||
<figcaption>Вход</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if item.output_file_path %}
|
||||
<figure>
|
||||
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||
<img src="/media/{{ item.id }}/output" alt="Результат">
|
||||
<a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||
<img src="{{ base_path }}/media/{{ item.id }}/output" alt="Результат">
|
||||
</a>
|
||||
<figcaption>Результат</figcaption>
|
||||
</figure>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
{% 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 %}
|
||||
<section class="cards">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<form class="filters" method="get" action="/">
|
||||
<form class="filters" method="get" action="{{ history_path }}">
|
||||
<label>
|
||||
Telegram user_id
|
||||
<input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616">
|
||||
|
|
@ -41,7 +50,7 @@
|
|||
|
||||
<div class="filter-actions">
|
||||
<button type="submit">Фильтровать</button>
|
||||
<a class="reset" href="/">Сбросить</a>
|
||||
<a class="reset" href="{{ history_path }}">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -59,6 +68,7 @@
|
|||
<th>Статус</th>
|
||||
<th>Промпт</th>
|
||||
<th>Seed</th>
|
||||
{% if admin %}<th>Скрыто</th>{% endif %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -68,15 +78,15 @@
|
|||
<td class="mono">#{{ item.id }}</td>
|
||||
<td class="thumb-cell">
|
||||
{% if item.output_file_path %}
|
||||
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||
<img class="thumb" src="/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
|
||||
<a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||
<img class="thumb" src="{{ base_path }}/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</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><span class="badge {{ item.status }}">{{ item.status }}</span></td>
|
||||
<td class="prompt" title="{{ item.prompt_text }}">
|
||||
|
|
@ -88,10 +98,18 @@
|
|||
{% endif %}
|
||||
</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>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
|
||||
<tr><td colspan="{% if admin %}10{% else %}9{% endif %}" class="empty">Ничего не найдено</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
|
||||
{% block nav %}
|
||||
<a href="/admin">Админ-история</a>
|
||||
<a href="/admin/users">Пользователи</a>
|
||||
<a href="/">Публичная история</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<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">
|
||||
<button type="submit">Найти</button>
|
||||
</form>
|
||||
|
|
@ -28,24 +33,24 @@
|
|||
{% for user in users %}
|
||||
<tr>
|
||||
<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>{{ user.display_user }}</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 }}">
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% 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>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% 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>
|
||||
</form>
|
||||
</td>
|
||||
|
|
@ -56,7 +61,7 @@
|
|||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>{{ user.first_paid_at|fmt_dt }}</td>
|
||||
|
|
|
|||
Loading…
Reference in New Issue