Compare commits
2 Commits
d593e35921
...
d4263c8420
| Author | SHA1 | Date |
|---|---|---|
|
|
d4263c8420 | |
|
|
67d3180ef6 |
|
|
@ -59,8 +59,11 @@ PAYMENTS_ENABLED=true
|
||||||
STARS_PRICE=5
|
STARS_PRICE=5
|
||||||
PREMIUM_STARS_PRICE=15
|
PREMIUM_STARS_PRICE=15
|
||||||
|
|
||||||
# Веб-история: только локальная сеть, без авторизации
|
# Веб-история: только локальная сеть
|
||||||
WEB_BIND_IP=192.168.31.158
|
WEB_BIND_IP=192.168.31.158
|
||||||
WEB_PAGE_SIZE=50
|
WEB_PAGE_SIZE=50
|
||||||
|
# Админ-панель /admin: HTTP Basic
|
||||||
|
WEB_ADMIN_USERNAME=admin
|
||||||
|
WEB_ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -15,7 +15,7 @@ Telegram-бот для генерации и редактирования изо
|
||||||
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
||||||
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
||||||
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
|
- **Подтверждение 18+**: без подтверждения возраста бот не принимает запросы.
|
||||||
- **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/reset` начинает новую.
|
- **Контекст**: следующий текстовый запрос продолжает предыдущую сцену; `/restart` начинает новую.
|
||||||
- Глобальная очередь с одной активной задачей на пользователя.
|
- Глобальная очередь с одной активной задачей на пользователя.
|
||||||
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
||||||
- PostgreSQL: запросы, статусы, seed, пути к файлам.
|
- PostgreSQL: запросы, статусы, seed, пути к файлам.
|
||||||
|
|
@ -158,7 +158,7 @@ BLOCKED_PUBLIC_FIGURES=
|
||||||
|
|
||||||
## Управление пользователями
|
## Управление пользователями
|
||||||
|
|
||||||
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
|
В админ-панели есть раздел **«Пользователи»** (`/admin/users`):
|
||||||
|
|
||||||
- поиск по ID или username;
|
- поиск по ID или username;
|
||||||
- установка точного баланса генераций;
|
- установка точного баланса генераций;
|
||||||
|
|
@ -202,7 +202,7 @@ TELEGRAM_HEALTH_INTERVAL=60
|
||||||
- Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется.
|
- Премиум-очередь всегда оплачивается 15 звёздами; баланс при этом не расходуется.
|
||||||
- `/balance` — текущий баланс.
|
- `/balance` — текущий баланс.
|
||||||
- `/invite` — реферальная ссылка.
|
- `/invite` — реферальная ссылка.
|
||||||
- `/reset` — сбросить контекст и начать новую сцену.
|
- `/restart` — сбросить контекст и начать новую сцену (алиас `/reset`).
|
||||||
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
|
- За первую оплату приглашённого друга начисляется +3 генерации, реферер получает уведомление.
|
||||||
|
|
||||||
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
|
Баланс и рефералы хранятся в таблице `users`. Пропущенные дни начисляются при следующем запуске/полуночи.
|
||||||
|
|
@ -211,10 +211,10 @@ TELEGRAM_HEALTH_INTERVAL=60
|
||||||
|
|
||||||
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
|
- Первый текстовый запрос создаёт контекст: базовую сцену и текущий улучшенный промпт.
|
||||||
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
|
- Следующий текст обрабатывается вместе с предыдущим промптом: бот сохраняет сцену и применяет только запрошенное изменение.
|
||||||
- `/reset` очищает контекст; следующий запрос начнёт новую сцену.
|
- `/restart` очищает контекст; следующий запрос начнёт новую сцену.
|
||||||
- Новое фото с подписью автоматически начинает новую цепочку.
|
- Новое фото с подписью автоматически начинает новую цепочку.
|
||||||
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
|
- Для точного редактирования конкретной картинки ответь на сообщение бота с изображением.
|
||||||
- `/reset` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.
|
- `/restart` не отменяет уже запущенную генерацию: она завершится со своим снимком контекста.
|
||||||
|
|
||||||
Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата.
|
Контекст хранится в таблице `user_contexts` и обновляется только после успешной отправки результата.
|
||||||
|
|
||||||
|
|
@ -248,6 +248,8 @@ http://192.168.31.158:8099
|
||||||
|
|
||||||
Возможности:
|
Возможности:
|
||||||
|
|
||||||
|
Публичная страница `/` показывает только историю:
|
||||||
|
|
||||||
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
||||||
- в списке показывается `@username`, если он известен, иначе Telegram ID;
|
- в списке показывается `@username`, если он известен, иначе Telegram ID;
|
||||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||||
|
|
@ -255,14 +257,21 @@ http://192.168.31.158:8099
|
||||||
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
||||||
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
|
- карточка запроса с полным промптом, ошибкой, seed и изображениями.
|
||||||
|
|
||||||
Доступ только с локальной сети: Compose публикует порт на `WEB_BIND_IP`. Для доступа из LAN:
|
Админ-панель находится на `/admin` и защищена HTTP Basic:
|
||||||
|
|
||||||
|
- `/admin` — история, включая скрытые запросы;
|
||||||
|
- `/admin/users` — управление пользователями;
|
||||||
|
- скрытие/показ запроса из публичной истории;
|
||||||
|
- скрытые запросы не видны в публичном списке, их карточки и медиа отдают `404`.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
WEB_BIND_IP=192.168.31.158
|
WEB_BIND_IP=192.168.31.158
|
||||||
WEB_PAGE_SIZE=50
|
WEB_PAGE_SIZE=50
|
||||||
|
WEB_ADMIN_USERNAME=admin
|
||||||
|
WEB_ADMIN_PASSWORD=change-me
|
||||||
```
|
```
|
||||||
|
|
||||||
Авторизации нет. Не публикуй порт `8099` наружу и не делай проброс на роутере.
|
Если `WEB_ADMIN_PASSWORD` пустой, админ-панель отключена. Basic Auth передаёт пароль без шифрования, поэтому используй только LAN или VPN; не публикуй порт `8099` наружу.
|
||||||
|
|
||||||
После успешной сборки обновление контейнера:
|
После успешной сборки обновление контейнера:
|
||||||
|
|
||||||
|
|
@ -281,6 +290,27 @@ docker compose up -d
|
||||||
|
|
||||||
Если меняются workflow, обновите `mapping.json`. При старте бот проверяет, что все узлы и поля существуют.
|
Если меняются workflow, обновите `mapping.json`. При старте бот проверяет, что все узлы и поля существуют.
|
||||||
|
|
||||||
|
## Очистка ComfyUI
|
||||||
|
|
||||||
|
После успешного скачивания результата бот удаляет запись истории ComfyUI по `prompt_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /history
|
||||||
|
{"delete": ["<prompt_id>"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Это убирает запрос и его ассеты из истории ComfyUI, но не удаляет файлы с диска. Для файлов `output/` и `temp/` есть скрипт для машины с ComfyUI:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Запускай его по расписанию, например раз в час. Сначала можно проверить без удаления:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\cleanup_comfy_assets.ps1 -ComfyRoot "C:\ComfyUI" -RetentionHours 6 -WhatIf
|
||||||
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
19
bot.py
19
bot.py
|
|
@ -43,6 +43,19 @@ 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(
|
||||||
|
|
@ -64,7 +77,7 @@ START_TEXT = (
|
||||||
"/balance — баланс генераций.\n"
|
"/balance — баланс генераций.\n"
|
||||||
"/invite — пригласить друга.\n"
|
"/invite — пригласить друга.\n"
|
||||||
"/status — состояние текущей задачи.\n"
|
"/status — состояние текущей задачи.\n"
|
||||||
"/reset — начать новую сцену."
|
"/restart — сброс контекста."
|
||||||
)
|
)
|
||||||
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
|
BLOCKED_TEXT = "Запрос нарушает правила. Звёзды не списаны."
|
||||||
AGE_TEXT = (
|
AGE_TEXT = (
|
||||||
|
|
@ -125,6 +138,7 @@ 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)
|
||||||
|
|
@ -1284,7 +1298,8 @@ class MpakoBot:
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
caption = f"Готово. Seed: {request.seed}" if request.seed is not None else "Готово."
|
prompt = request.enhanced_prompt or request.prompt_text
|
||||||
|
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)
|
||||||
|
|
|
||||||
22
checklist.md
22
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).
|
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17), управление пользователями (18), возрастной gate (19) и надёжность Telegram (20).
|
||||||
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
||||||
- 62 теста: 38 проходят локально без БД, 24 теста БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст.
|
- 69 тестов: 42 проходят локально без БД, 27 тестов БД выполняются в CI на PostgreSQL через `TEST_DATABASE_URL`. Покрыты workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями, retry, миграция, watchdog, контекст, админ-панель, скрытие истории.
|
||||||
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
|
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото. PostgreSQL-миграция и тесты БД ещё не запускались вживую — это сделает CI и первый деплой.
|
||||||
|
|
||||||
## 0. Допущения и решения по умолчанию
|
## 0. Допущения и решения по умолчанию
|
||||||
|
|
@ -387,10 +387,24 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
||||||
- [x] Снимок контекста сохраняется в `requests` и `pending_payments`.
|
- [x] Снимок контекста сохраняется в `requests` и `pending_payments`.
|
||||||
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
|
- [x] Следующий текстовый запрос обрабатывается вместе с предыдущим промптом.
|
||||||
- [x] Контекст обновляется только после успешной отправки результата.
|
- [x] Контекст обновляется только после успешной отправки результата.
|
||||||
- [x] `/reset` очищает контекст; новое фото начинает новую цепочку.
|
- [x] `/restart` очищает контекст; новое фото начинает новую цепочку.
|
||||||
- [x] Тесты БД, enhancer и очереди.
|
- [x] В подписи результата итоговый промпт и подсказка `/restart`.
|
||||||
|
- [x] Тесты БД, enhancer, очереди и подписи.
|
||||||
|
|
||||||
**DoD:** продолжение сцены работает по тексту, `/reset` возвращает к новой сцене.
|
**DoD:** продолжение сцены работает по тексту, `/restart` возвращает к новой сцене.
|
||||||
|
|
||||||
|
## 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 чистится после генерации.
|
||||||
|
|
||||||
## Порядок работ
|
## Порядок работ
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,26 @@ class ComfyClient:
|
||||||
entry = payload.get(prompt_id)
|
entry = payload.get(prompt_id)
|
||||||
return entry if isinstance(entry, dict) else None
|
return entry if isinstance(entry, dict) else None
|
||||||
|
|
||||||
|
async def delete_history(self, prompt_id: str) -> None:
|
||||||
|
try:
|
||||||
|
response = await self._client.post(
|
||||||
|
"/history",
|
||||||
|
json={"delete": [prompt_id]},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ComfyConnectionError(f"POST /history: {exc}") from exc
|
||||||
|
if response.status_code in {400, 404, 405}:
|
||||||
|
logger.warning(
|
||||||
|
"ComfyUI не поддерживает удаление истории по prompt_id"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise ComfyHTTPError(
|
||||||
|
f"POST /history: HTTP {response.status_code}:"
|
||||||
|
f" {response.text[:500]}"
|
||||||
|
)
|
||||||
|
|
||||||
async def wait_for_history(self, prompt_id: str, *, timeout: float) -> dict[str, Any]:
|
async def wait_for_history(self, prompt_id: str, *, timeout: float) -> dict[str, Any]:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
deadline = loop.time() + timeout
|
deadline = loop.time() + timeout
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ class Settings:
|
||||||
premium_stars_price: int = 15
|
premium_stars_price: int = 15
|
||||||
web_page_size: int = 50
|
web_page_size: int = 50
|
||||||
web_bind_ip: str = "127.0.0.1"
|
web_bind_ip: str = "127.0.0.1"
|
||||||
|
web_admin_username: str = "admin"
|
||||||
|
web_admin_password: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def comfy_ws_url(self) -> str:
|
def comfy_ws_url(self) -> str:
|
||||||
|
|
@ -177,6 +179,11 @@ class Settings:
|
||||||
web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))),
|
web_page_size=max(10, min(200, _int("WEB_PAGE_SIZE", 50))),
|
||||||
web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip()
|
web_bind_ip=os.getenv("WEB_BIND_IP", "127.0.0.1").strip()
|
||||||
or "127.0.0.1",
|
or "127.0.0.1",
|
||||||
|
web_admin_username=os.getenv(
|
||||||
|
"WEB_ADMIN_USERNAME", "admin"
|
||||||
|
).strip()
|
||||||
|
or "admin",
|
||||||
|
web_admin_password=os.getenv("WEB_ADMIN_PASSWORD", "").strip(),
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
|
||||||
47
db.py
47
db.py
|
|
@ -70,6 +70,7 @@ class Request:
|
||||||
payment_charge_id: str | None
|
payment_charge_id: str | None
|
||||||
payment_refunded: bool
|
payment_refunded: bool
|
||||||
is_premium: bool
|
is_premium: bool
|
||||||
|
hidden: bool
|
||||||
mode: str
|
mode: str
|
||||||
seed: int | None
|
seed: int | None
|
||||||
error_text: str | None
|
error_text: str | None
|
||||||
|
|
@ -97,6 +98,7 @@ class Request:
|
||||||
payment_charge_id=row["payment_charge_id"],
|
payment_charge_id=row["payment_charge_id"],
|
||||||
payment_refunded=bool(row["payment_refunded"]),
|
payment_refunded=bool(row["payment_refunded"]),
|
||||||
is_premium=bool(row["is_premium"]),
|
is_premium=bool(row["is_premium"]),
|
||||||
|
hidden=bool(row["hidden"]),
|
||||||
mode=row["mode"],
|
mode=row["mode"],
|
||||||
seed=row["seed"],
|
seed=row["seed"],
|
||||||
error_text=row["error_text"],
|
error_text=row["error_text"],
|
||||||
|
|
@ -250,6 +252,7 @@ CREATE TABLE IF NOT EXISTS requests (
|
||||||
payment_charge_id TEXT,
|
payment_charge_id TEXT,
|
||||||
payment_refunded BOOLEAN NOT NULL DEFAULT FALSE,
|
payment_refunded BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')),
|
mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')),
|
||||||
seed BIGINT,
|
seed BIGINT,
|
||||||
error_text TEXT,
|
error_text TEXT,
|
||||||
|
|
@ -338,6 +341,10 @@ MIGRATIONS = (
|
||||||
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT",
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS enhanced_prompt TEXT",
|
||||||
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_base_prompt TEXT",
|
||||||
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT",
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS context_prompt TEXT",
|
||||||
|
(
|
||||||
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS hidden BOOLEAN"
|
||||||
|
" NOT NULL DEFAULT FALSE"
|
||||||
|
),
|
||||||
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT",
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_charge_id TEXT",
|
||||||
(
|
(
|
||||||
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN"
|
"ALTER TABLE requests ADD COLUMN IF NOT EXISTS payment_refunded BOOLEAN"
|
||||||
|
|
@ -779,6 +786,7 @@ class Database:
|
||||||
user_id: int | None,
|
user_id: int | None,
|
||||||
status: str | None,
|
status: str | None,
|
||||||
mode: str | None,
|
mode: str | None,
|
||||||
|
include_hidden: bool = True,
|
||||||
) -> tuple[str, list[Any]]:
|
) -> tuple[str, list[Any]]:
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
params: list[Any] = []
|
params: list[Any] = []
|
||||||
|
|
@ -791,6 +799,8 @@ class Database:
|
||||||
if mode:
|
if mode:
|
||||||
clauses.append("mode = %s")
|
clauses.append("mode = %s")
|
||||||
params.append(mode)
|
params.append(mode)
|
||||||
|
if not include_hidden:
|
||||||
|
clauses.append("hidden = FALSE")
|
||||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
return where, params
|
return where, params
|
||||||
|
|
||||||
|
|
@ -800,10 +810,13 @@ class Database:
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
mode: str | None = None,
|
mode: str | None = None,
|
||||||
|
include_hidden: bool = True,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> list[Request]:
|
) -> list[Request]:
|
||||||
where, params = self._filters(user_id, status, mode)
|
where, params = self._filters(
|
||||||
|
user_id, status, mode, include_hidden=include_hidden
|
||||||
|
)
|
||||||
query = (
|
query = (
|
||||||
f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s"
|
f"SELECT * FROM requests{where} ORDER BY id DESC LIMIT %s OFFSET %s"
|
||||||
)
|
)
|
||||||
|
|
@ -818,8 +831,11 @@ class Database:
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
mode: str | None = None,
|
mode: str | None = None,
|
||||||
|
include_hidden: bool = True,
|
||||||
) -> int:
|
) -> int:
|
||||||
where, params = self._filters(user_id, status, mode)
|
where, params = self._filters(
|
||||||
|
user_id, status, mode, include_hidden=include_hidden
|
||||||
|
)
|
||||||
async with self._connection_pool().connection() as conn:
|
async with self._connection_pool().connection() as conn:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
f"SELECT COUNT(*) AS total FROM requests{where}", params
|
f"SELECT COUNT(*) AS total FROM requests{where}", params
|
||||||
|
|
@ -827,10 +843,12 @@ class Database:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return int(row["total"]) if row is not None else 0
|
return int(row["total"]) if row is not None else 0
|
||||||
|
|
||||||
async def stats(self) -> dict[str, Any]:
|
async def stats(self, *, include_hidden: bool = True) -> dict[str, Any]:
|
||||||
|
where = "" if include_hidden else " WHERE hidden = FALSE"
|
||||||
async with self._connection_pool().connection() as conn:
|
async with self._connection_pool().connection() as conn:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT status, COUNT(*) AS total FROM requests GROUP BY status"
|
"SELECT status, COUNT(*) AS total FROM requests"
|
||||||
|
f"{where} GROUP BY status"
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
by_status = {
|
by_status = {
|
||||||
|
|
@ -838,6 +856,7 @@ class Database:
|
||||||
}
|
}
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT COUNT(DISTINCT user_id) AS total FROM requests"
|
"SELECT COUNT(DISTINCT user_id) AS total FROM requests"
|
||||||
|
f"{where}"
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return {
|
return {
|
||||||
|
|
@ -846,10 +865,16 @@ class Database:
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def list_users(self, limit: int = 100) -> list[UserStat]:
|
async def list_users(
|
||||||
|
self, limit: int = 100, *, include_hidden: bool = True
|
||||||
|
) -> list[UserStat]:
|
||||||
|
where = "" if include_hidden else " WHERE hidden = FALSE"
|
||||||
|
username_filter = (
|
||||||
|
"" if include_hidden else " AND r2.hidden = FALSE"
|
||||||
|
)
|
||||||
async with self._connection_pool().connection() as conn:
|
async with self._connection_pool().connection() as conn:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
|
|
@ -858,9 +883,11 @@ class Database:
|
||||||
SELECT username FROM requests AS r2
|
SELECT username FROM requests AS r2
|
||||||
WHERE r2.user_id = requests.user_id
|
WHERE r2.user_id = requests.user_id
|
||||||
AND r2.username IS NOT NULL
|
AND r2.username IS NOT NULL
|
||||||
|
{username_filter}
|
||||||
ORDER BY r2.id DESC LIMIT 1
|
ORDER BY r2.id DESC LIMIT 1
|
||||||
) AS username
|
) AS username
|
||||||
FROM requests
|
FROM requests
|
||||||
|
{where}
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY last_seen DESC
|
ORDER BY last_seen DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
|
|
@ -949,6 +976,14 @@ class Database:
|
||||||
(request_id,),
|
(request_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_hidden(self, request_id: int, hidden: bool) -> bool:
|
||||||
|
async with self._connection_pool().connection() as conn:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"UPDATE requests SET hidden = %s WHERE id = %s RETURNING id",
|
||||||
|
(hidden, request_id),
|
||||||
|
)
|
||||||
|
return await cursor.fetchone() is not None
|
||||||
|
|
||||||
async def create_pending_payment(
|
async def create_pending_payment(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,15 @@ class QueueManager:
|
||||||
)
|
)
|
||||||
await self._comfy.download_output(entry, output_path)
|
await self._comfy.download_output(entry, output_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._comfy.delete_history(prompt_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось очистить историю ComfyUI для %s",
|
||||||
|
prompt_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
await self._db.set_done(request_id, str(output_path))
|
await self._db.set_done(request_id, str(output_path))
|
||||||
request = await self._refresh(request_id)
|
request = await self._refresh(request_id)
|
||||||
self._active_users.discard(request.user_id)
|
self._active_users.discard(request.user_id)
|
||||||
|
|
|
||||||
|
|
@ -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="",
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from comfy_client import ComfyClient, _format_api_error
|
from comfy_client import ComfyClient, _format_api_error
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,3 +46,26 @@ def test_find_output_text() -> None:
|
||||||
entry = {"outputs": {"3": {"text": ["A red apple"]}}}
|
entry = {"outputs": {"3": {"text": ["A red apple"]}}}
|
||||||
text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type]
|
text = ComfyClient.find_output_text(None, entry) # type: ignore[arg-type]
|
||||||
assert text == "A red apple"
|
assert text == "A red apple"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_history_posts_prompt_id() -> None:
|
||||||
|
seen: dict[str, object] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["path"] = request.url.path
|
||||||
|
seen["json"] = request.content.decode()
|
||||||
|
return httpx.Response(200)
|
||||||
|
|
||||||
|
client = ComfyClient("http://comfy", "ws://comfy")
|
||||||
|
await client.close()
|
||||||
|
client._client = httpx.AsyncClient( # type: ignore[attr-defined]
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
base_url="http://comfy",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.delete_history("prompt-1")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert seen["path"] == "/history"
|
||||||
|
assert '"prompt-1"' in str(seen["json"])
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,48 @@ async def test_user_credits_referral_and_daily_accrual(db: Database) -> None:
|
||||||
assert await db.accrue_daily_credits(future) == 0
|
assert await db.accrue_daily_credits(future) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hidden_requests_are_excluded_from_public_queries(
|
||||||
|
db: Database,
|
||||||
|
) -> None:
|
||||||
|
visible = await db.create_request(
|
||||||
|
user_id=1,
|
||||||
|
username="alice",
|
||||||
|
mode="t2i",
|
||||||
|
prompt_text="visible",
|
||||||
|
input_file_path=None,
|
||||||
|
seed=1,
|
||||||
|
)
|
||||||
|
await db.set_done(visible.id, "/tmp/visible.png")
|
||||||
|
hidden = await db.create_request(
|
||||||
|
user_id=2,
|
||||||
|
username="bob",
|
||||||
|
mode="t2i",
|
||||||
|
prompt_text="hidden",
|
||||||
|
input_file_path=None,
|
||||||
|
seed=2,
|
||||||
|
)
|
||||||
|
await db.set_done(hidden.id, "/tmp/hidden.png")
|
||||||
|
assert await db.set_hidden(hidden.id, True) is True
|
||||||
|
|
||||||
|
public = await db.list_requests(include_hidden=False)
|
||||||
|
assert [item.id for item in public] == [visible.id]
|
||||||
|
assert await db.count_requests(include_hidden=False) == 1
|
||||||
|
|
||||||
|
admin = await db.list_requests(include_hidden=True)
|
||||||
|
assert {item.id for item in admin} == {visible.id, hidden.id}
|
||||||
|
|
||||||
|
stats = await db.stats(include_hidden=False)
|
||||||
|
assert stats["total"] == 1
|
||||||
|
assert stats["users"] == 1
|
||||||
|
|
||||||
|
public_users = await db.list_users(include_hidden=False)
|
||||||
|
assert {item.user_id for item in public_users} == {1}
|
||||||
|
|
||||||
|
loaded = await db.get_request(hidden.id)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.hidden is True
|
||||||
|
|
||||||
|
|
||||||
async def test_user_context_lifecycle(db: Database) -> None:
|
async def test_user_context_lifecycle(db: Database) -> None:
|
||||||
assert await db.get_user_context(1) is None
|
assert await db.get_user_context(1) is None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class FakeComfy:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.workflows: list[dict] = []
|
self.workflows: list[dict] = []
|
||||||
self.uploads: list[tuple[Path, str]] = []
|
self.uploads: list[tuple[Path, str]] = []
|
||||||
|
self.deleted_history: list[str] = []
|
||||||
|
|
||||||
async def open_progress(self, client_id: str):
|
async def open_progress(self, client_id: str):
|
||||||
return None
|
return None
|
||||||
|
|
@ -44,6 +45,9 @@ class FakeComfy:
|
||||||
self.uploads.append((path, remote_name))
|
self.uploads.append((path, remote_name))
|
||||||
return remote_name
|
return remote_name
|
||||||
|
|
||||||
|
async def delete_history(self, prompt_id: str) -> None:
|
||||||
|
self.deleted_history.append(prompt_id)
|
||||||
|
|
||||||
|
|
||||||
class FakeEnhancer:
|
class FakeEnhancer:
|
||||||
enabled = True
|
enabled = True
|
||||||
|
|
@ -154,6 +158,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None:
|
||||||
settings = make_settings(tmp_path)
|
settings = make_settings(tmp_path)
|
||||||
queue = make_queue(db, settings, events)
|
queue = make_queue(db, settings, events)
|
||||||
|
|
||||||
|
comfy = queue._comfy # type: ignore[attr-defined]
|
||||||
await queue.start()
|
await queue.start()
|
||||||
try:
|
try:
|
||||||
request, _ = await queue.enqueue(
|
request, _ = await queue.enqueue(
|
||||||
|
|
@ -166,6 +171,7 @@ async def test_text_to_image_job(db: Database, tmp_path: Path) -> None:
|
||||||
output = Path(event.request.output_file_path or "")
|
output = Path(event.request.output_file_path or "")
|
||||||
assert output.exists()
|
assert output.exists()
|
||||||
assert output.name == f"{request.id}_output.png"
|
assert output.name == f"{request.id}_output.png"
|
||||||
|
assert comfy.deleted_history == ["prompt-1"]
|
||||||
finally:
|
finally:
|
||||||
await queue.stop()
|
await queue.stop()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
@ -10,6 +11,11 @@ from psycopg.rows import dict_row
|
||||||
from config import Settings
|
from config import Settings
|
||||||
from web import create_app
|
from web import create_app
|
||||||
|
|
||||||
|
ADMIN_HEADERS = {
|
||||||
|
"Authorization": "Basic "
|
||||||
|
+ base64.b64encode(b"admin:secret").decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def insert_request(
|
def insert_request(
|
||||||
database_url: str,
|
database_url: str,
|
||||||
|
|
@ -21,14 +27,15 @@ def insert_request(
|
||||||
output: str | None = None,
|
output: str | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
enhanced: str | None = None,
|
enhanced: str | None = None,
|
||||||
|
hidden: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
with psycopg.connect(database_url, row_factory=dict_row) as connection:
|
with psycopg.connect(database_url, row_factory=dict_row) as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO requests
|
INSERT INTO requests
|
||||||
(user_id, username, timestamp, prompt_text, enhanced_prompt,
|
(user_id, username, timestamp, prompt_text, enhanced_prompt,
|
||||||
status, mode, seed, output_file_path)
|
status, mode, seed, output_file_path, hidden)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -40,6 +47,7 @@ def insert_request(
|
||||||
status,
|
status,
|
||||||
mode,
|
mode,
|
||||||
output,
|
output,
|
||||||
|
hidden,
|
||||||
),
|
),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -161,17 +169,36 @@ def test_user_management(web_settings: Settings) -> None:
|
||||||
credits=0,
|
credits=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
page = client.get("/users")
|
page = client.get("/admin/users", headers=ADMIN_HEADERS)
|
||||||
assert page.status_code == 200
|
assert page.status_code == 200
|
||||||
assert "@alice" in page.text
|
assert "@alice" in page.text
|
||||||
assert "@bob" in page.text
|
assert "@bob" in page.text
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
client.post("/users/1/credits", data={"credits": "9"}).status_code
|
client.post(
|
||||||
== 200
|
"/admin/users/1/credits",
|
||||||
|
data={"credits": "9"},
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
follow_redirects=False,
|
||||||
|
).status_code
|
||||||
|
== 303
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/admin/users/1/admin",
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
follow_redirects=False,
|
||||||
|
).status_code
|
||||||
|
== 303
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/admin/users/2/block",
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
follow_redirects=False,
|
||||||
|
).status_code
|
||||||
|
== 303
|
||||||
)
|
)
|
||||||
assert client.post("/users/1/admin").status_code == 200
|
|
||||||
assert client.post("/users/2/block").status_code == 200
|
|
||||||
|
|
||||||
with psycopg.connect(
|
with psycopg.connect(
|
||||||
web_settings.database_url, row_factory=dict_row
|
web_settings.database_url, row_factory=dict_row
|
||||||
|
|
@ -192,6 +219,77 @@ def test_user_management(web_settings: Settings) -> None:
|
||||||
assert second["is_blocked"] is True
|
assert second["is_blocked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_auth_and_public_users_redirect(
|
||||||
|
web_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
app = create_app(web_settings)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert (
|
||||||
|
client.get("/admin", follow_redirects=False).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
"/admin/users", follow_redirects=False
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
redirect = client.get("/users", follow_redirects=False)
|
||||||
|
assert redirect.status_code == 307
|
||||||
|
assert redirect.headers["location"] == "/admin/users"
|
||||||
|
assert (
|
||||||
|
client.get("/admin", headers=ADMIN_HEADERS).status_code == 200
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
"/admin/users", headers=ADMIN_HEADERS
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_requests_are_admin_only(web_settings: Settings) -> None:
|
||||||
|
app = create_app(web_settings)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
visible = insert_request(
|
||||||
|
web_settings.database_url,
|
||||||
|
user_id=1,
|
||||||
|
prompt="visible prompt",
|
||||||
|
)
|
||||||
|
hidden = insert_request(
|
||||||
|
web_settings.database_url,
|
||||||
|
user_id=2,
|
||||||
|
prompt="hidden prompt",
|
||||||
|
hidden=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
public = client.get("/")
|
||||||
|
assert "visible prompt" in public.text
|
||||||
|
assert "hidden prompt" not in public.text
|
||||||
|
assert client.get(f"/request/{hidden}").status_code == 404
|
||||||
|
assert client.get(f"/media/{hidden}/output").status_code == 404
|
||||||
|
assert client.get(f"/thumb/{hidden}").status_code == 404
|
||||||
|
|
||||||
|
admin = client.get("/admin", headers=ADMIN_HEADERS)
|
||||||
|
assert "hidden prompt" in admin.text
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
f"/admin/request/{hidden}", headers=ADMIN_HEADERS
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/requests/{visible}/hide",
|
||||||
|
headers=ADMIN_HEADERS,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
public = client.get("/")
|
||||||
|
assert "visible prompt" not in public.text
|
||||||
|
admin = client.get("/admin", headers=ADMIN_HEADERS)
|
||||||
|
assert "visible prompt" in admin.text
|
||||||
|
|
||||||
|
|
||||||
def test_user_search(web_settings: Settings) -> None:
|
def test_user_search(web_settings: Settings) -> None:
|
||||||
app = create_app(web_settings)
|
app = create_app(web_settings)
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
|
|
@ -202,7 +300,9 @@ def test_user_search(web_settings: Settings) -> None:
|
||||||
web_settings.database_url, user_id=2, username="bob"
|
web_settings.database_url, user_id=2, username="bob"
|
||||||
)
|
)
|
||||||
|
|
||||||
page = client.get("/users?q=alice")
|
page = client.get(
|
||||||
|
"/admin/users?q=alice", headers=ADMIN_HEADERS
|
||||||
|
)
|
||||||
assert page.status_code == 200
|
assert page.status_code == 200
|
||||||
assert "@alice" in page.text
|
assert "@alice" in page.text
|
||||||
assert "@bob" not in page.text
|
assert "@bob" not in page.text
|
||||||
|
|
|
||||||
310
web.py
310
web.py
|
|
@ -4,6 +4,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -11,8 +12,9 @@ from typing import Annotated, Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
@ -29,6 +31,7 @@ STATUSES = ("queued", "running", "done", "failed")
|
||||||
MODES = ("t2i", "i2i")
|
MODES = ("t2i", "i2i")
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
security = HTTPBasic(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None:
|
def _safe_storage_path(raw_path: str, storage_root: Path) -> Path | None:
|
||||||
|
|
@ -96,67 +99,48 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
@app.get("/healthz")
|
async def require_admin(
|
||||||
async def healthz() -> dict[str, bool]:
|
request: Request,
|
||||||
return {"ok": True}
|
credentials: Annotated[
|
||||||
|
HTTPBasicCredentials | None, Depends(security)
|
||||||
@app.get("/users", response_class=HTMLResponse)
|
],
|
||||||
async def users(request: Request, q: str = ""):
|
) -> None:
|
||||||
db: Database = request.app.state.db
|
current: Settings = request.app.state.settings
|
||||||
rows = await db.list_users_admin(1000)
|
if not current.web_admin_password:
|
||||||
needle = q.strip().lower().lstrip("@")
|
raise HTTPException(
|
||||||
if needle:
|
status_code=503,
|
||||||
rows = [
|
detail="Админ-панель отключена: задай WEB_ADMIN_PASSWORD",
|
||||||
user
|
)
|
||||||
for user in rows
|
if credentials is None:
|
||||||
if needle in str(user.user_id)
|
raise HTTPException(
|
||||||
or (user.username and needle in user.username.lower())
|
status_code=401,
|
||||||
]
|
detail="Требуется авторизация",
|
||||||
return templates.TemplateResponse(
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
request=request,
|
)
|
||||||
name="users.html",
|
username_ok = secrets.compare_digest(
|
||||||
context={"users": rows, "q": q},
|
credentials.username, current.web_admin_username
|
||||||
)
|
)
|
||||||
|
password_ok = secrets.compare_digest(
|
||||||
|
credentials.password, current.web_admin_password
|
||||||
|
)
|
||||||
|
if not (username_ok and password_ok):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Неверный логин или пароль",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/users/{user_id}/credits")
|
async def history_page(
|
||||||
async def user_credits(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
user_id: int,
|
*,
|
||||||
credits: Annotated[int, Form()],
|
admin: bool,
|
||||||
) -> RedirectResponse:
|
user_id: str,
|
||||||
db: Database = request.app.state.db
|
status: str,
|
||||||
if not await db.set_credits(user_id, max(0, credits)):
|
mode: str,
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
page: int,
|
||||||
return RedirectResponse("/users", status_code=303)
|
|
||||||
|
|
||||||
@app.post("/users/{user_id}/admin")
|
|
||||||
async def user_admin(request: Request, user_id: int) -> RedirectResponse:
|
|
||||||
db: Database = request.app.state.db
|
|
||||||
user = await db.get_user(user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
await db.set_admin(user_id, not user.is_admin)
|
|
||||||
return RedirectResponse("/users", status_code=303)
|
|
||||||
|
|
||||||
@app.post("/users/{user_id}/block")
|
|
||||||
async def user_block(request: Request, user_id: int) -> RedirectResponse:
|
|
||||||
db: Database = request.app.state.db
|
|
||||||
user = await db.get_user(user_id)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
await db.set_blocked(user_id, not user.is_blocked)
|
|
||||||
return RedirectResponse("/users", status_code=303)
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def index(
|
|
||||||
request: Request,
|
|
||||||
user_id: str = "",
|
|
||||||
status: str = "",
|
|
||||||
mode: str = "",
|
|
||||||
page: int = 1,
|
|
||||||
):
|
):
|
||||||
db: Database = request.app.state.db
|
db: Database = request.app.state.db
|
||||||
settings: Settings = request.app.state.settings
|
current: Settings = request.app.state.settings
|
||||||
|
|
||||||
error = ""
|
error = ""
|
||||||
uid: int | None = None
|
uid: int | None = None
|
||||||
|
|
@ -170,8 +154,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
status = status if status in STATUSES else ""
|
status = status if status in STATUSES else ""
|
||||||
mode = mode if mode in MODES else ""
|
mode = mode if mode in MODES else ""
|
||||||
|
|
||||||
total = await db.count_requests(user_id=uid, status=status or None, mode=mode or None)
|
total = await db.count_requests(
|
||||||
page_size = settings.web_page_size
|
user_id=uid,
|
||||||
|
status=status or None,
|
||||||
|
mode=mode or None,
|
||||||
|
include_hidden=admin,
|
||||||
|
)
|
||||||
|
page_size = current.web_page_size
|
||||||
pages = max(1, math.ceil(total / page_size))
|
pages = max(1, math.ceil(total / page_size))
|
||||||
page = max(1, min(page, pages))
|
page = max(1, min(page, pages))
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
@ -180,11 +169,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
user_id=uid,
|
user_id=uid,
|
||||||
status=status or None,
|
status=status or None,
|
||||||
mode=mode or None,
|
mode=mode or None,
|
||||||
|
include_hidden=admin,
|
||||||
limit=page_size,
|
limit=page_size,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
users = await db.list_users()
|
users = await db.list_users(include_hidden=admin)
|
||||||
stats = await db.stats()
|
stats = await db.stats(include_hidden=admin)
|
||||||
|
|
||||||
base_filters = {
|
base_filters = {
|
||||||
key: value
|
key: value
|
||||||
|
|
@ -195,13 +185,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
)
|
)
|
||||||
if value
|
if value
|
||||||
}
|
}
|
||||||
|
base_path = "/admin" if admin else ""
|
||||||
|
history_path = base_path or "/"
|
||||||
|
|
||||||
def page_url(number: int) -> str:
|
def page_url(number: int) -> str:
|
||||||
params = dict(base_filters)
|
params = dict(base_filters)
|
||||||
if number > 1:
|
if number > 1:
|
||||||
params["page"] = str(number)
|
params["page"] = str(number)
|
||||||
query = urlencode(params)
|
query = urlencode(params)
|
||||||
return f"/?{query}" if query else "/"
|
return f"{history_path}?{query}" if query else history_path
|
||||||
|
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
|
|
@ -216,6 +208,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
"page": page,
|
"page": page,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"admin": admin,
|
||||||
|
"base_path": base_path,
|
||||||
|
"history_path": history_path,
|
||||||
"prev_url": page_url(page - 1) if page > 1 else None,
|
"prev_url": page_url(page - 1) if page > 1 else None,
|
||||||
"next_url": page_url(page + 1) if page < pages else None,
|
"next_url": page_url(page + 1) if page < pages else None,
|
||||||
}
|
}
|
||||||
|
|
@ -223,54 +218,68 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
request=request, name="index.html", context=context
|
request=request, name="index.html", context=context
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/request/{request_id}", response_class=HTMLResponse)
|
async def detail_page(
|
||||||
async def detail(request: Request, request_id: int):
|
request: Request, request_id: int, *, admin: bool
|
||||||
|
):
|
||||||
db: Database = request.app.state.db
|
db: Database = request.app.state.db
|
||||||
item = await db.get_request(request_id)
|
item = await db.get_request(request_id)
|
||||||
if item is None:
|
if item is None or (item.hidden and not admin):
|
||||||
raise HTTPException(status_code=404, detail="Запрос не найден")
|
raise HTTPException(status_code=404, detail="Запрос не найден")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="detail.html",
|
name="detail.html",
|
||||||
context={"item": item},
|
context={
|
||||||
|
"item": item,
|
||||||
|
"admin": admin,
|
||||||
|
"base_path": "/admin" if admin else "",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/media/{request_id}/{kind}")
|
async def media_response(
|
||||||
async def media(request: Request, request_id: int, kind: str):
|
request: Request, request_id: int, kind: str, *, admin: bool
|
||||||
|
):
|
||||||
if kind not in {"input", "output"}:
|
if kind not in {"input", "output"}:
|
||||||
raise HTTPException(status_code=404, detail="Не найдено")
|
raise HTTPException(status_code=404, detail="Не найдено")
|
||||||
db: Database = request.app.state.db
|
db: Database = request.app.state.db
|
||||||
settings: Settings = request.app.state.settings
|
current: Settings = request.app.state.settings
|
||||||
item: HistoryRequest | None = await db.get_request(request_id)
|
item: HistoryRequest | None = await db.get_request(request_id)
|
||||||
if item is None:
|
if item is None or (item.hidden and not admin):
|
||||||
raise HTTPException(status_code=404, detail="Запрос не найден")
|
raise HTTPException(status_code=404, detail="Запрос не найден")
|
||||||
|
|
||||||
raw_path = item.input_file_path if kind == "input" else item.output_file_path
|
raw_path = (
|
||||||
|
item.input_file_path if kind == "input" else item.output_file_path
|
||||||
|
)
|
||||||
if not raw_path:
|
if not raw_path:
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
storage_root = settings.storage_dir.resolve()
|
storage_root = current.storage_dir.resolve()
|
||||||
path = _safe_storage_path(raw_path, storage_root)
|
path = _safe_storage_path(raw_path, storage_root)
|
||||||
if path is None or not path.is_file():
|
if path is None or not path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
return FileResponse(path)
|
return FileResponse(path)
|
||||||
|
|
||||||
@app.get("/thumb/{request_id}")
|
async def thumbnail_response(
|
||||||
async def thumbnail(request: Request, request_id: int):
|
request: Request, request_id: int, *, admin: bool
|
||||||
|
):
|
||||||
db: Database = request.app.state.db
|
db: Database = request.app.state.db
|
||||||
settings: Settings = request.app.state.settings
|
current: Settings = request.app.state.settings
|
||||||
item: HistoryRequest | None = await db.get_request(request_id)
|
item: HistoryRequest | None = await db.get_request(request_id)
|
||||||
if item is None or not item.output_file_path:
|
if item is None or (item.hidden and not admin):
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
if not item.output_file_path:
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
storage_root = settings.storage_dir.resolve()
|
storage_root = current.storage_dir.resolve()
|
||||||
source = _safe_storage_path(item.output_file_path, storage_root)
|
source = _safe_storage_path(item.output_file_path, storage_root)
|
||||||
if source is None or not source.is_file():
|
if source is None or not source.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
cache_dir = storage_root / ".thumbnails"
|
cache_dir = storage_root / ".thumbnails"
|
||||||
cache_path = cache_dir / f"{request_id}.jpg"
|
cache_path = cache_dir / f"{request_id}.jpg"
|
||||||
if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime:
|
if (
|
||||||
|
cache_path.is_file()
|
||||||
|
and cache_path.stat().st_mtime >= source.stat().st_mtime
|
||||||
|
):
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
cache_path,
|
cache_path,
|
||||||
media_type="image/jpeg",
|
media_type="image/jpeg",
|
||||||
|
|
@ -280,7 +289,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_make_thumbnail, source, cache_path)
|
await asyncio.to_thread(_make_thumbnail, source, cache_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Не удалось создать превью для запроса %s", request_id)
|
logger.exception(
|
||||||
|
"Не удалось создать превью для запроса %s", request_id
|
||||||
|
)
|
||||||
return FileResponse(source)
|
return FileResponse(source)
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
|
|
@ -289,6 +300,147 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
headers={"Cache-Control": "public, max-age=3600"},
|
headers={"Cache-Control": "public, max-age=3600"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
async def users_redirect() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/admin/users", status_code=307)
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(
|
||||||
|
request: Request,
|
||||||
|
user_id: str = "",
|
||||||
|
status: str = "",
|
||||||
|
mode: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
return await history_page(
|
||||||
|
request,
|
||||||
|
admin=False,
|
||||||
|
user_id=user_id,
|
||||||
|
status=status,
|
||||||
|
mode=mode,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/request/{request_id}", response_class=HTMLResponse)
|
||||||
|
async def detail(request: Request, request_id: int):
|
||||||
|
return await detail_page(request, request_id, admin=False)
|
||||||
|
|
||||||
|
@app.get("/media/{request_id}/{kind}")
|
||||||
|
async def media(request: Request, request_id: int, kind: str):
|
||||||
|
return await media_response(request, request_id, kind, admin=False)
|
||||||
|
|
||||||
|
@app.get("/thumb/{request_id}")
|
||||||
|
async def thumbnail(request: Request, request_id: int):
|
||||||
|
return await thumbnail_response(request, request_id, admin=False)
|
||||||
|
|
||||||
|
admin_router = APIRouter(
|
||||||
|
prefix="/admin", dependencies=[Depends(require_admin)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin_router.get("", response_class=HTMLResponse)
|
||||||
|
@admin_router.get("/", response_class=HTMLResponse)
|
||||||
|
async def admin_index(
|
||||||
|
request: Request,
|
||||||
|
user_id: str = "",
|
||||||
|
status: str = "",
|
||||||
|
mode: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
return await history_page(
|
||||||
|
request,
|
||||||
|
admin=True,
|
||||||
|
user_id=user_id,
|
||||||
|
status=status,
|
||||||
|
mode=mode,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin_router.get("/users", response_class=HTMLResponse)
|
||||||
|
async def admin_users(request: Request, q: str = ""):
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
rows = await db.list_users_admin(1000)
|
||||||
|
needle = q.strip().lower().lstrip("@")
|
||||||
|
if needle:
|
||||||
|
rows = [
|
||||||
|
user
|
||||||
|
for user in rows
|
||||||
|
if needle in str(user.user_id)
|
||||||
|
or (user.username and needle in user.username.lower())
|
||||||
|
]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="users.html",
|
||||||
|
context={"users": rows, "q": q, "admin": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin_router.post("/users/{user_id}/credits")
|
||||||
|
async def admin_user_credits(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
credits: Annotated[int, Form()],
|
||||||
|
) -> RedirectResponse:
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
if not await db.set_credits(user_id, max(0, credits)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Пользователь не найден"
|
||||||
|
)
|
||||||
|
return RedirectResponse("/admin/users", status_code=303)
|
||||||
|
|
||||||
|
@admin_router.post("/users/{user_id}/admin")
|
||||||
|
async def admin_user_admin(
|
||||||
|
request: Request, user_id: int
|
||||||
|
) -> RedirectResponse:
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
user = await db.get_user(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Пользователь не найден"
|
||||||
|
)
|
||||||
|
await db.set_admin(user_id, not user.is_admin)
|
||||||
|
return RedirectResponse("/admin/users", status_code=303)
|
||||||
|
|
||||||
|
@admin_router.post("/users/{user_id}/block")
|
||||||
|
async def admin_user_block(
|
||||||
|
request: Request, user_id: int
|
||||||
|
) -> RedirectResponse:
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
user = await db.get_user(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Пользователь не найден"
|
||||||
|
)
|
||||||
|
await db.set_blocked(user_id, not user.is_blocked)
|
||||||
|
return RedirectResponse("/admin/users", status_code=303)
|
||||||
|
|
||||||
|
@admin_router.post("/requests/{request_id}/hide")
|
||||||
|
async def admin_hide_request(
|
||||||
|
request: Request, request_id: int
|
||||||
|
) -> RedirectResponse:
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
item = await db.get_request(request_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Запрос не найден")
|
||||||
|
if not await db.set_hidden(request_id, not item.hidden):
|
||||||
|
raise HTTPException(status_code=404, detail="Запрос не найден")
|
||||||
|
return RedirectResponse("/admin", status_code=303)
|
||||||
|
|
||||||
|
@admin_router.get("/request/{request_id}", response_class=HTMLResponse)
|
||||||
|
async def admin_detail(request: Request, request_id: int):
|
||||||
|
return await detail_page(request, request_id, admin=True)
|
||||||
|
|
||||||
|
@admin_router.get("/media/{request_id}/{kind}")
|
||||||
|
async def admin_media(request: Request, request_id: int, kind: str):
|
||||||
|
return await media_response(request, request_id, kind, admin=True)
|
||||||
|
|
||||||
|
@admin_router.get("/thumb/{request_id}")
|
||||||
|
async def admin_thumbnail(request: Request, request_id: int):
|
||||||
|
return await thumbnail_response(request, request_id, admin=True)
|
||||||
|
|
||||||
|
app.include_router(admin_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/">MpakoImageAIbot</a>
|
<a class="brand" href="/">MpakoImageAIbot</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/">История</a>
|
{% block nav %}<a href="/">История</a>{% endblock %}
|
||||||
<a href="/users">Пользователи</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
<span class="muted">История запросов</span>
|
<span class="muted">История запросов</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %}
|
{% block title %}Запрос #{{ item.id }} — MpakoImageAIbot{% endblock %}
|
||||||
|
{% block nav %}
|
||||||
|
{% if admin %}
|
||||||
|
<a href="/admin">Админ-история</a>
|
||||||
|
<a href="/admin/users">Пользователи</a>
|
||||||
|
<a href="/">Публичная история</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/">История</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a class="back" href="/">← К истории</a>
|
<a class="back" href="{{ base_path or '/' }}">← К истории</a>
|
||||||
<h1>Запрос #{{ item.id }}</h1>
|
<h1>Запрос #{{ item.id }}</h1>
|
||||||
|
{% if admin %}
|
||||||
|
<form class="inline-form" method="post" action="/admin/requests/{{ item.id }}/hide">
|
||||||
|
<button type="submit">{{ "Показать в публичной истории" if item.hidden else "Скрыть из публичной истории" }}</button>
|
||||||
|
</form>
|
||||||
|
{% if item.hidden %}<div class="alert">Запрос скрыт из публичной истории.</div>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<dl class="meta">
|
<dl class="meta">
|
||||||
<dt>Пользователь</dt>
|
<dt>Пользователь</dt>
|
||||||
<dd><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd>
|
<dd><a href="{{ base_path or '/' }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd>
|
||||||
<dt>Статус</dt>
|
<dt>Статус</dt>
|
||||||
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
|
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
|
||||||
<dt>Режим</dt>
|
<dt>Режим</dt>
|
||||||
|
|
@ -36,16 +51,16 @@
|
||||||
<div class="images">
|
<div class="images">
|
||||||
{% if item.input_file_path %}
|
{% if item.input_file_path %}
|
||||||
<figure>
|
<figure>
|
||||||
<a href="/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
|
<a href="{{ base_path }}/media/{{ item.id }}/input" target="_blank" rel="noreferrer">
|
||||||
<img src="/media/{{ item.id }}/input" alt="Входное изображение">
|
<img src="{{ base_path }}/media/{{ item.id }}/input" alt="Входное изображение">
|
||||||
</a>
|
</a>
|
||||||
<figcaption>Вход</figcaption>
|
<figcaption>Вход</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.output_file_path %}
|
{% if item.output_file_path %}
|
||||||
<figure>
|
<figure>
|
||||||
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
<a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||||
<img src="/media/{{ item.id }}/output" alt="Результат">
|
<img src="{{ base_path }}/media/{{ item.id }}/output" alt="Результат">
|
||||||
</a>
|
</a>
|
||||||
<figcaption>Результат</figcaption>
|
<figcaption>Результат</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}История запросов — MpakoImageAIbot{% endblock %}
|
{% block title %}{{ "Админ-история" if admin else "История запросов" }} — MpakoImageAIbot{% endblock %}
|
||||||
|
{% block nav %}
|
||||||
|
{% if admin %}
|
||||||
|
<a href="/admin">Админ-история</a>
|
||||||
|
<a href="/admin/users">Пользователи</a>
|
||||||
|
<a href="/">Публичная история</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/">История</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cards">
|
<section class="cards">
|
||||||
<div class="card"><span class="card-value">{{ stats.total }}</span><span class="card-label">Всего запросов</span></div>
|
<div class="card"><span class="card-value">{{ stats.total }}</span><span class="card-label">Всего запросов</span></div>
|
||||||
|
|
@ -8,7 +17,7 @@
|
||||||
<div class="card"><span class="card-value">{{ stats.by_status.get('failed', 0) }}</span><span class="card-label">Ошибок</span></div>
|
<div class="card"><span class="card-value">{{ stats.by_status.get('failed', 0) }}</span><span class="card-label">Ошибок</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form class="filters" method="get" action="/">
|
<form class="filters" method="get" action="{{ history_path }}">
|
||||||
<label>
|
<label>
|
||||||
Telegram user_id
|
Telegram user_id
|
||||||
<input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616">
|
<input name="user_id" value="{{ user_id }}" list="known-users" inputmode="numeric" placeholder="например 7467236616">
|
||||||
|
|
@ -41,7 +50,7 @@
|
||||||
|
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button type="submit">Фильтровать</button>
|
<button type="submit">Фильтровать</button>
|
||||||
<a class="reset" href="/">Сбросить</a>
|
<a class="reset" href="{{ history_path }}">Сбросить</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -59,6 +68,7 @@
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Промпт</th>
|
<th>Промпт</th>
|
||||||
<th>Seed</th>
|
<th>Seed</th>
|
||||||
|
{% if admin %}<th>Скрыто</th>{% endif %}
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -68,15 +78,15 @@
|
||||||
<td class="mono">#{{ item.id }}</td>
|
<td class="mono">#{{ item.id }}</td>
|
||||||
<td class="thumb-cell">
|
<td class="thumb-cell">
|
||||||
{% if item.output_file_path %}
|
{% if item.output_file_path %}
|
||||||
<a href="/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
<a href="{{ base_path }}/media/{{ item.id }}/output" target="_blank" rel="noreferrer">
|
||||||
<img class="thumb" src="/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
|
<img class="thumb" src="{{ base_path }}/thumb/{{ item.id }}" alt="Результат #{{ item.id }}" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted">—</span>
|
<span class="muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
|
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
|
||||||
<td><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td>
|
<td><a href="{{ history_path }}?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td>
|
||||||
<td class="mono">{{ item.mode }}{% if item.is_premium %} <span class="mark">P</span>{% endif %}</td>
|
<td class="mono">{{ item.mode }}{% if item.is_premium %} <span class="mark">P</span>{% endif %}</td>
|
||||||
<td><span class="badge {{ item.status }}">{{ item.status }}</span></td>
|
<td><span class="badge {{ item.status }}">{{ item.status }}</span></td>
|
||||||
<td class="prompt" title="{{ item.prompt_text }}">
|
<td class="prompt" title="{{ item.prompt_text }}">
|
||||||
|
|
@ -88,10 +98,18 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono">{{ item.seed if item.seed is not none else '—' }}</td>
|
<td class="mono">{{ item.seed if item.seed is not none else '—' }}</td>
|
||||||
<td><a class="details" href="/request/{{ item.id }}">Детали</a></td>
|
{% if admin %}
|
||||||
|
<td>
|
||||||
|
{% if item.hidden %}<span class="badge">скрыт</span>{% else %}<span class="muted">—</span>{% endif %}
|
||||||
|
<form class="inline-form" method="post" action="/admin/requests/{{ item.id }}/hide">
|
||||||
|
<button type="submit">{{ "Показать" if item.hidden else "Скрыть" }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td><a class="details" href="{{ base_path }}/request/{{ item.id }}">Детали</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="9" class="empty">Ничего не найдено</td></tr>
|
<tr><td colspan="{% if admin %}10{% else %}9{% endif %}" class="empty">Ничего не найдено</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
|
{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
|
||||||
|
{% block nav %}
|
||||||
|
<a href="/admin">Админ-история</a>
|
||||||
|
<a href="/admin/users">Пользователи</a>
|
||||||
|
<a href="/">Публичная история</a>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>Пользователи</h1>
|
<h1>Пользователи</h1>
|
||||||
<form class="search" method="get" action="/users">
|
<form class="search" method="get" action="/admin/users">
|
||||||
<input name="q" value="{{ q }}" placeholder="ID или username">
|
<input name="q" value="{{ q }}" placeholder="ID или username">
|
||||||
<button type="submit">Найти</button>
|
<button type="submit">Найти</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -28,24 +33,24 @@
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">
|
<td class="mono">
|
||||||
<a href="/?user_id={{ user.user_id }}">{{ user.user_id }}</a>
|
<a href="/admin?user_id={{ user.user_id }}">{{ user.user_id }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.display_user }}</td>
|
<td>{{ user.display_user }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/credits">
|
<form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/credits">
|
||||||
<input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}">
|
<input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}">
|
||||||
<button type="submit">Сохранить</button>
|
<button type="submit">Сохранить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %}
|
{% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %}
|
||||||
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/admin">
|
<form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/admin">
|
||||||
<button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button>
|
<button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %}
|
{% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %}
|
||||||
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/block">
|
<form class="inline-form" method="post" action="/admin/users/{{ user.user_id }}/block">
|
||||||
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
|
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -56,7 +61,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.referrer_id %}
|
{% if user.referrer_id %}
|
||||||
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
|
<a href="/admin/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.first_paid_at|fmt_dt }}</td>
|
<td>{{ user.first_paid_at|fmt_dt }}</td>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue