Compare commits

..

No commits in common. "d4263c8420926c0adfdb2a3cfd70206de1e8a571" and "d593e35921a277e74e2cd8c1d709b5a931ae8d9a" have entirely different histories.

20 changed files with 130 additions and 672 deletions

View File

@ -59,11 +59,8 @@ 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

@ -15,7 +15,7 @@ Telegram-бот для генерации и редактирования изо
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга. - **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать. - **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы. - **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
- **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/restart` начинает новую. - **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/reset` начинает новую.
- Глобальная очередь с одной активной задачей на пользователя. - Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI. - Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- PostgreSQL: запросы, статусы, seed, пути к файлам. - PostgreSQL: запросы, статусы, seed, пути к файлам.
@ -158,7 +158,7 @@ BLOCKED_PUBLIC_FIGURES=
## Управление пользователями ## Управление пользователями
В админ-панели есть раздел **«Пользователи»** (`/admin/users`): В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
- поиск по ID или username; - поиск по ID или username;
- установка точного баланса генераций; - установка точного баланса генераций;
@ -202,7 +202,7 @@ TELEGRAM_HEALTH_INTERVAL=60
- Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется. - Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется.
- `/balance` — текущий баланс. - `/balance` — текущий баланс.
- `/invite` — реферальная ссылка. - `/invite` — реферальная ссылка.
- `/restart` — сбросить контекст и начать новую сцену (алиас `/reset`). - `/reset` — сбросить контекст и начать новую сцену.
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление. - За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи. Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
@ -211,10 +211,10 @@ TELEGRAM_HEALTH_INTERVAL=60
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт. - Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение. - Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
- `/restart` очищает контекст; следующий запрос начнёт новую сцену. - `/reset` очищает контекст; следующий запрос начнёт новую сцену.
- Новое фото с подписью автоматически начинает новую цепочку. - Новое фото с подписью автоматически начинает новую цепочку.
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением. - Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
- `/restart` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста. - `/reset` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.
Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата. Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата.
@ -248,8 +248,6 @@ http://192.168.31.158:8099
Возможности: Возможности:
Публичная страница `/` показывает только историю:
- фильтр по Telegram `user_id` (с подсказкой известных пользователей); - фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- в списке показывается `@username`, если он известен, иначе Telegram ID; - в списке показывается `@username`, если он известен, иначе Telegram ID;
- фильтр по статусу и режиму (`t2i`/`i2i`); - фильтр по статусу и режиму (`t2i`/`i2i`);
@ -257,21 +255,14 @@ http://192.168.31.158:8099
- превью результата в списке (кэшируются в `storage/.thumbnails`); - превью результата в списке (кэшируются в `storage/.thumbnails`);
- карточка запроса с полным промптом, ошибкой, seed и изображениями. - карточка запроса с полным промптом, ошибкой, seed и изображениями.
Админ-панель находится на `/admin` и защищена HTTP Basic: Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
- `/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
``` ```
Если `WEB_ADMIN_PASSWORD` пустой, админ-панель отключена. Basic Auth передаёт пароль без шифрования, поэтому используй только LAN или VPN; не публикуй порт `8099` наружу. Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере.
После успешной сборки обновление контейнера: После успешной сборки обновление контейнера:
@ -290,27 +281,6 @@ 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

19
bot.py
View File

@ -43,19 +43,6 @@ logger = logging.getLogger(__name__)
MSK = timezone(timedelta(hours=3)) MSK = timezone(timedelta(hours=3))
POLLING_WATCHDOG_INTERVAL = 60.0 POLLING_WATCHDOG_INTERVAL = 60.0
POLLING_WATCHDOG_STUCK_AFTER = 300.0 POLLING_WATCHDOG_STUCK_AFTER = 300.0
RESTART_HINT = "/restart — сброс контекста"
CAPTION_LIMIT = 1024
def result_caption(prompt: str, *, limit: int = CAPTION_LIMIT) -> str:
value = (prompt or "").strip()
suffix = f"\n\n{RESTART_HINT}"
available = max(0, limit - len(suffix))
if len(value) > available:
value = value[: max(0, available - 1)].rstrip()
if value:
value += "…"
return f"{value}{suffix}" if value else RESTART_HINT
def polling_is_stuck( def polling_is_stuck(
@ -77,7 +64,7 @@ START_TEXT = (
"/balance — баланс генераций.\n" "/balance — баланс генераций.\n"
"/invite — пригласить друга.\n" "/invite — пригласить друга.\n"
"/status — состояние текущей задачи.\n" "/status — состояние текущей задачи.\n"
"/restart — сброс контекста." "/reset — начать новую сцену."
) )
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны." BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
AGE_TEXT = ( AGE_TEXT = (
@ -138,7 +125,6 @@ class MpakoBot:
application.add_handler(CommandHandler("status", self._status)) application.add_handler(CommandHandler("status", self._status))
application.add_handler(CommandHandler("balance", self._balance)) application.add_handler(CommandHandler("balance", self._balance))
application.add_handler(CommandHandler("invite", self._invite)) application.add_handler(CommandHandler("invite", self._invite))
application.add_handler(CommandHandler("restart", self._reset))
application.add_handler(CommandHandler("reset", self._reset)) application.add_handler(CommandHandler("reset", self._reset))
application.add_handler( application.add_handler(
PreCheckoutQueryHandler(self._pre_checkout) PreCheckoutQueryHandler(self._pre_checkout)
@ -1298,8 +1284,7 @@ class MpakoBot:
if self._bot is None: if self._bot is None:
return return
prompt = request.enhanced_prompt or request.prompt_text caption = f"Готово. Seed: {request.seed}" if request.seed is not None else "Готово."
caption = result_caption(prompt)
filename = Path(output_path).name filename = Path(output_path).name
try: try:
content = await asyncio.to_thread(Path(output_path).read_bytes) content = await asyncio.to_thread(Path(output_path).read_bytes)

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 и веб-история.
- 69 тестов: 42 проходят локально без БД, 27 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории. - 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в 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. Допущения и решения по умолчанию
@ -387,24 +387,10 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
- [x] Снимок контекста сохраняется в `requests` и `pending_payments`. - [x] Снимок контекста сохраняется в `requests` и `pending_payments`.
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом. - [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
- [x] Контекст обновляется только после успешной отправки результата. - [x] Контекст обновляется только после успешной отправки результата.
- [x] `/restart` очищает контекст; новое фото начинает новую цепочку. - [x] `/reset` очищает контекст; новое фото начинает новую цепочку.
- [x] В подписи результата итоговый промпт и подсказка `/restart`. - [x] Тесты БД, enhancer и очереди.
- [x] Тесты БД, enhancer, очереди и подписи.
**DoD:** продолжение сцены работает по тексту, `/restart` возвращает к новой сцене. **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 чистится после генерации.
## Порядок работ ## Порядок работ

View File

@ -246,26 +246,6 @@ 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,8 +104,6 @@ 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:
@ -179,11 +177,6 @@ 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,7 +70,6 @@ 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
@ -98,7 +97,6 @@ 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"],
@ -252,7 +250,6 @@ 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,
@ -341,10 +338,6 @@ 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"
@ -786,7 +779,6 @@ 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] = []
@ -799,8 +791,6 @@ 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
@ -810,13 +800,10 @@ 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( where, params = self._filters(user_id, status, mode)
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"
) )
@ -831,11 +818,8 @@ 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( where, params = self._filters(user_id, status, mode)
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
@ -843,12 +827,10 @@ 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, *, include_hidden: bool = True) -> dict[str, Any]: async def stats(self) -> 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" "SELECT status, COUNT(*) AS total FROM requests GROUP BY status"
f"{where} GROUP BY status"
) )
rows = await cursor.fetchall() rows = await cursor.fetchall()
by_status = { by_status = {
@ -856,7 +838,6 @@ 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 {
@ -865,16 +846,10 @@ class Database:
"by_status": by_status, "by_status": by_status,
} }
async def list_users( async def list_users(self, limit: int = 100) -> list[UserStat]:
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,
@ -883,11 +858,9 @@ 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
@ -976,14 +949,6 @@ 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,15 +311,6 @@ 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

@ -1,26 +0,0 @@
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,8 +56,6 @@ 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,19 +0,0 @@
from __future__ import annotations
from bot import RESTART_HINT, result_caption
def test_result_caption_contains_prompt_and_restart_hint() -> None:
caption = result_caption("A red apple on a table")
assert caption == f"A red apple on a table\n\n{RESTART_HINT}"
def test_result_caption_truncates_long_prompt() -> None:
caption = result_caption("x" * 2000, limit=100)
assert len(caption) <= 100
assert caption.endswith(RESTART_HINT)
assert "…" in caption
def test_result_caption_empty_prompt() -> None:
assert result_caption("") == RESTART_HINT

View File

@ -1,7 +1,5 @@
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
@ -46,26 +44,3 @@ 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,48 +273,6 @@ 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,7 +20,6 @@ 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
@ -45,9 +44,6 @@ 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
@ -158,7 +154,6 @@ 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(
@ -171,7 +166,6 @@ 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,6 +1,5 @@
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
@ -11,11 +10,6 @@ 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,
@ -27,15 +21,14 @@ 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, hidden) status, mode, seed, output_file_path)
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s)
RETURNING id RETURNING id
""", """,
( (
@ -47,7 +40,6 @@ def insert_request(
status, status,
mode, mode,
output, output,
hidden,
), ),
).fetchone() ).fetchone()
if row is None: if row is None:
@ -169,36 +161,17 @@ def test_user_management(web_settings: Settings) -> None:
credits=0, credits=0,
) )
page = client.get("/admin/users", headers=ADMIN_HEADERS) page = client.get("/users")
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( client.post("/users/1/credits", data={"credits": "9"}).status_code
"/admin/users/1/credits", == 200
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
@ -219,77 +192,6 @@ 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:
@ -300,9 +202,7 @@ 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( page = client.get("/users?q=alice")
"/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,7 +4,6 @@ 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
@ -12,9 +11,8 @@ 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 APIRouter, Depends, FastAPI, Form, HTTPException, Request from fastapi import 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
@ -31,7 +29,6 @@ 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:
@ -99,48 +96,67 @@ 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")
async def require_admin( @app.get("/healthz")
request: Request, async def healthz() -> dict[str, bool]:
credentials: Annotated[ return {"ok": True}
HTTPBasicCredentials | None, Depends(security)
], @app.get("/users", response_class=HTMLResponse)
) -> None: async def users(request: Request, q: str = ""):
current: Settings = request.app.state.settings db: Database = request.app.state.db
if not current.web_admin_password: rows = await db.list_users_admin(1000)
raise HTTPException( needle = q.strip().lower().lstrip("@")
status_code=503, if needle:
detail="Админ-панель отключена: задай WEB_ADMIN_PASSWORD", rows = [
) user
if credentials is None: for user in rows
raise HTTPException( if needle in str(user.user_id)
status_code=401, or (user.username and needle in user.username.lower())
detail="Требуется авторизация", ]
headers={"WWW-Authenticate": "Basic"}, return templates.TemplateResponse(
) request=request,
username_ok = secrets.compare_digest( name="users.html",
credentials.username, current.web_admin_username context={"users": rows, "q": q},
)
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"},
) )
async def history_page( @app.post("/users/{user_id}/credits")
async def user_credits(
request: Request, request: Request,
*, user_id: int,
admin: bool, credits: Annotated[int, Form()],
user_id: str, ) -> RedirectResponse:
status: str, db: Database = request.app.state.db
mode: str, if not await db.set_credits(user_id, max(0, credits)):
page: int, 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,
): ):
db: Database = request.app.state.db db: Database = request.app.state.db
current: Settings = request.app.state.settings settings: Settings = request.app.state.settings
error = "" error = ""
uid: int | None = None uid: int | None = None
@ -154,13 +170,8 @@ 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( total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None)
user_id=uid, page_size = settings.web_page_size
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
@ -169,12 +180,11 @@ 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(include_hidden=admin) users = await db.list_users()
stats = await db.stats(include_hidden=admin) stats = await db.stats()
base_filters = { base_filters = {
key: value key: value
@ -185,15 +195,13 @@ 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"{history_path}?{query}" if query else history_path return f"/?{query}" if query else "/"
context: dict[str, Any] = { context: dict[str, Any] = {
"rows": rows, "rows": rows,
@ -208,9 +216,6 @@ 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,
} }
@ -218,68 +223,54 @@ def create_app(settings: Settings | None = None) -> FastAPI:
request=request, name="index.html", context=context request=request, name="index.html", context=context
) )
async def detail_page( @app.get("/request/{request_id}", response_class=HTMLResponse)
request: Request, request_id: int, *, admin: bool async def detail(request: Request, request_id: int):
):
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 or (item.hidden and not admin): if item is None:
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={ context={"item": item},
"item": item,
"admin": admin,
"base_path": "/admin" if admin else "",
},
) )
async def media_response( @app.get("/media/{request_id}/{kind}")
request: Request, request_id: int, kind: str, *, admin: bool async def media(request: Request, request_id: int, kind: str):
):
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
current: Settings = request.app.state.settings settings: 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 (item.hidden and not admin): if item is None:
raise HTTPException(status_code=404, detail="Запрос не найден") raise HTTPException(status_code=404, detail="Запрос не найден")
raw_path = ( raw_path = item.input_file_path if kind == "input" else item.output_file_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 = current.storage_dir.resolve() storage_root = settings.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)
async def thumbnail_response( @app.get("/thumb/{request_id}")
request: Request, request_id: int, *, admin: bool async def thumbnail(request: Request, request_id: int):
):
db: Database = request.app.state.db db: Database = request.app.state.db
current: Settings = request.app.state.settings settings: 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 (item.hidden and not admin): if item is None or not item.output_file_path:
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 = current.storage_dir.resolve() storage_root = settings.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 ( if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime:
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",
@ -289,9 +280,7 @@ 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( logger.exception("Не удалось создать превью для запроса %s", request_id)
"Не удалось создать превью для запроса %s", request_id
)
return FileResponse(source) return FileResponse(source)
return FileResponse( return FileResponse(
@ -300,147 +289,6 @@ 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,7 +10,8 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/">MpakoImageAIbot</a> <a class="brand" href="/">MpakoImageAIbot</a>
<nav class="nav"> <nav class="nav">
{% block nav %}<a href="/">История</a>{% endblock %} <a href="/">История</a>
<a href="/users">Пользователи</a>
</nav> </nav>
<span class="muted">История запросов</span> <span class="muted">История запросов</span>
</header> </header>

View File

@ -1,27 +1,12 @@
{% 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="{{ base_path or '/' }}">← К истории</a> <a class="back" href="/">← К истории</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="{{ base_path or '/' }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd> <dd><a href="/?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>
@ -51,16 +36,16 @@
<div class="images"> <div class="images">
{% if item.input_file_path %} {% if item.input_file_path %}
<figure> <figure>
<a href="{{ base_path }}/media/{{ item.id }}/input" target="_blank" rel="noreferrer"> <a href="/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
<img src="{{ base_path }}/media/{{ item.id }}/input" alt="Входное изображение"> <img src="/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="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer"> <a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img src="{{ base_path }}/media/{{ item.id }}/output" alt="Результат"> <img src="/media/{{ item.id }}/output" alt="Результат">
</a> </a>
<figcaption>Результат</figcaption> <figcaption>Результат</figcaption>
</figure> </figure>

View File

@ -1,14 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ "Админ-история" if admin else "История запросов" }} — MpakoImageAIbot{% endblock %} {% block title %}История запросов — 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>
@ -17,7 +8,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="{{ history_path }}"> <form class="filters" method="get" action="/">
<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">
@ -50,7 +41,7 @@
<div class="filter-actions"> <div class="filter-actions">
<button type="submit">Фильтровать</button> <button type="submit">Фильтровать</button>
<a class="reset" href="{{ history_path }}">Сбросить</a> <a class="reset" href="/">Сбросить</a>
</div> </div>
</form> </form>
@ -68,7 +59,6 @@
<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>
@ -78,15 +68,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="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer"> <a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
<img class="thumb" src="{{ base_path }}/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy"> <img class="thumb" src="/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="{{ history_path }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td> <td><a href="/?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 }}">
@ -98,18 +88,10 @@
{% 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>
{% if admin %} <td><a class="details" href="/request/{{ item.id }}">Детали</a></td>
<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="{% if admin %}10{% else %}9{% endif %}" class="empty">Ничего не найдено</td></tr> <tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -1,14 +1,9 @@
{% 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="/admin/users"> <form class="search" method="get" action="/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>
@ -33,24 +28,24 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td class="mono"> <td class="mono">
<a href="/admin?user_id={{ user.user_id }}">{{ user.user_id }}</a> <a href="/?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="/admin/users/{{ user.user_id }}/credits"> <form class="inline-form" method="post" action="/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="/admin/users/{{ user.user_id }}/admin"> <form class="inline-form" method="post" action="/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="/admin/users/{{ user.user_id }}/block"> <form class="inline-form" method="post" action="/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>
@ -61,7 +56,7 @@
</td> </td>
<td> <td>
{% if user.referrer_id %} {% if user.referrer_id %}
<a href="/admin/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a> <a href="/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>