Управление пользователями
В вебе можно искать пользователей, менять баланс, выдавать админку и блокировать.
This commit is contained in:
parent
eb681a6733
commit
4339e126a2
16
README.md
16
README.md
|
|
@ -13,6 +13,7 @@ Telegram-бот для генерации и редактирования изо
|
||||||
- **Оплата**: 5 Telegram Stars за обычную очередь, 15 за премиум; админам бесплатно, возврат при ошибке.
|
- **Оплата**: 5 Telegram Stars за обычную очередь, 15 за премиум; админам бесплатно, возврат при ошибке.
|
||||||
- **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая.
|
- **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая.
|
||||||
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
||||||
|
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
||||||
- Глобальная очередь с одной активной задачей на пользователя.
|
- Глобальная очередь с одной активной задачей на пользователя.
|
||||||
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
||||||
- SQLite: запросы, статусы, seed, пути к файлам.
|
- SQLite: запросы, статусы, seed, пути к файлам.
|
||||||
|
|
@ -124,6 +125,19 @@ BLOCKED_PUBLIC_FIGURES=
|
||||||
|
|
||||||
При блокировке пользователь получает «Запрос нарушает правила. Звёзды не списаны.», а запись с `user_id`, временем, промптом и причиной сохраняется в таблицу `blocked_requests`.
|
При блокировке пользователь получает «Запрос нарушает правила. Звёзды не списаны.», а запись с `user_id`, временем, промптом и причиной сохраняется в таблицу `blocked_requests`.
|
||||||
|
|
||||||
|
## Управление пользователями
|
||||||
|
|
||||||
|
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
|
||||||
|
|
||||||
|
- поиск по ID или username;
|
||||||
|
- установка точного баланса генераций;
|
||||||
|
- назначение/снятие админа;
|
||||||
|
- блокировка/разблокировка.
|
||||||
|
|
||||||
|
Заблокированный пользователь не может отправлять запросы, получать счета и оплачивать. Админ-флаг из веб-интерфейса действует сразу.
|
||||||
|
|
||||||
|
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
|
||||||
|
|
||||||
## Баланс и рефералы
|
## Баланс и рефералы
|
||||||
|
|
||||||
- Новому пользователю начисляется 3 генерации.
|
- Новому пользователю начисляется 3 генерации.
|
||||||
|
|
@ -149,7 +163,7 @@ PREMIUM_STARS_PRICE=15
|
||||||
- Перед оплатой бот показывает количество людей в обычной и премиум-очередях.
|
- Перед оплатой бот показывает количество людей в обычной и премиум-очередях.
|
||||||
- Пользователь выбирает очередь кнопкой, после чего получает счёт.
|
- Пользователь выбирает очередь кнопкой, после чего получает счёт.
|
||||||
- Премиум- и обычные задачи чередуются: одна премиум, одна обычная; если одна очередь пуста, берётся из другой.
|
- Премиум- и обычные задачи чередуются: одна премиум, одна обычная; если одна очередь пуста, берётся из другой.
|
||||||
- Админы из `ADMIN_USER_IDS` генерируют бесплатно и попадают в премиум-очередь.
|
- Админы из `ADMIN_USER_IDS` генерируют бесплатно в обычной очереди.
|
||||||
- Не-админу выставляется счёт через Telegram Stars (`XTR`).
|
- Не-админу выставляется счёт через Telegram Stars (`XTR`).
|
||||||
- Фильтрация промпта выполняется до выставления счёта.
|
- Фильтрация промпта выполняется до выставления счёта.
|
||||||
- Если поставить задачу в очередь не удалось, звёзды возвращаются.
|
- Если поставить задачу в очередь не удалось, звёзды возвращаются.
|
||||||
|
|
|
||||||
28
bot.py
28
bot.py
|
|
@ -113,6 +113,7 @@ class MpakoBot:
|
||||||
self._settings.comfy_ws_url,
|
self._settings.comfy_ws_url,
|
||||||
)
|
)
|
||||||
await self._db.connect()
|
await self._db.connect()
|
||||||
|
await self._db.seed_admins(self._settings.admin_user_ids)
|
||||||
await self._db.accrue_daily_credits()
|
await self._db.accrue_daily_credits()
|
||||||
self._daily_task = asyncio.create_task(
|
self._daily_task = asyncio.create_task(
|
||||||
self._daily_credits_loop(), name="daily-credits"
|
self._daily_credits_loop(), name="daily-credits"
|
||||||
|
|
@ -324,12 +325,14 @@ class MpakoBot:
|
||||||
if query is None:
|
if query is None:
|
||||||
return
|
return
|
||||||
pending = await self._db.get_pending_payment(query.invoice_payload)
|
pending = await self._db.get_pending_payment(query.invoice_payload)
|
||||||
|
user_row = await self._db.get_user(query.from_user.id)
|
||||||
allowed = (
|
allowed = (
|
||||||
pending is not None
|
pending is not None
|
||||||
and pending.user_id == query.from_user.id
|
and pending.user_id == query.from_user.id
|
||||||
and pending.status == "pending"
|
and pending.status == "pending"
|
||||||
and query.currency == "XTR"
|
and query.currency == "XTR"
|
||||||
and query.total_amount == pending.amount
|
and query.total_amount == pending.amount
|
||||||
|
and not (user_row is not None and user_row.is_blocked)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await query.answer(
|
await query.answer(
|
||||||
|
|
@ -373,6 +376,11 @@ class MpakoBot:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
user_row = await self._db.get_user(query.from_user.id)
|
||||||
|
if user_row is not None and user_row.is_blocked:
|
||||||
|
await query.answer("Доступ заблокирован.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
if tier == "credit":
|
if tier == "credit":
|
||||||
remaining = await self._db.consume_credit(query.from_user.id)
|
remaining = await self._db.consume_credit(query.from_user.id)
|
||||||
if remaining is None:
|
if remaining is None:
|
||||||
|
|
@ -465,6 +473,17 @@ class MpakoBot:
|
||||||
if pending.status != "pending":
|
if pending.status != "pending":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
user_row = await self._db.get_user(user.id)
|
||||||
|
if user_row is not None and user_row.is_blocked:
|
||||||
|
refunded = await self._refund_stars(
|
||||||
|
user.id, payment.telegram_payment_charge_id
|
||||||
|
)
|
||||||
|
if refunded:
|
||||||
|
await self._db.mark_pending_refunded(payload)
|
||||||
|
note = " Звёзды возвращены." if refunded else ""
|
||||||
|
await message.reply_text("Доступ заблокирован." + note)
|
||||||
|
return
|
||||||
|
|
||||||
charge_id = payment.telegram_payment_charge_id
|
charge_id = payment.telegram_payment_charge_id
|
||||||
if payment.currency != "XTR" or payment.total_amount != pending.amount:
|
if payment.currency != "XTR" or payment.total_amount != pending.amount:
|
||||||
refunded = await self._refund_stars(user.id, charge_id)
|
refunded = await self._refund_stars(user.id, charge_id)
|
||||||
|
|
@ -641,8 +660,11 @@ class MpakoBot:
|
||||||
if message is None or user is None or self._queue is None:
|
if message is None or user is None or self._queue is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
is_admin = user.id in self._settings.admin_user_ids
|
|
||||||
user_row = await self._db.ensure_user(user.id, user.username)
|
user_row = await self._db.ensure_user(user.id, user.username)
|
||||||
|
is_admin = user_row.is_admin
|
||||||
|
if user_row.is_blocked:
|
||||||
|
await message.reply_text("Доступ заблокирован.")
|
||||||
|
return False
|
||||||
credits = user_row.credits
|
credits = user_row.credits
|
||||||
has_person = False
|
has_person = False
|
||||||
if (
|
if (
|
||||||
|
|
@ -1067,6 +1089,10 @@ class MpakoBot:
|
||||||
):
|
):
|
||||||
await message.reply_text("Доступ к боту ограничен.")
|
await message.reply_text("Доступ к боту ограничен.")
|
||||||
return False
|
return False
|
||||||
|
row = await self._db.get_user(user.id)
|
||||||
|
if row is not None and row.is_blocked:
|
||||||
|
await message.reply_text("Доступ заблокирован.")
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _on_error(
|
async def _on_error(
|
||||||
|
|
|
||||||
14
checklist.md
14
checklist.md
|
|
@ -6,9 +6,9 @@
|
||||||
|
|
||||||
## Текущий статус
|
## Текущий статус
|
||||||
|
|
||||||
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16) и баланс/рефералы (17).
|
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17) и управление пользователями (18).
|
||||||
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
|
||||||
- 50 тестов проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, вспомогательные функции ComfyUI.
|
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
|
||||||
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
||||||
|
|
||||||
## 0. Допущения и решения по умолчанию
|
## 0. Допущения и решения по умолчанию
|
||||||
|
|
@ -351,6 +351,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
||||||
|
|
||||||
**DoD:** новый пользователь получает 3 генерации, ежедневно +1, друг после первой оплаты приносит рефереру +3.
|
**DoD:** новый пользователь получает 3 генерации, ежедневно +1, друг после первой оплаты приносит рефереру +3.
|
||||||
|
|
||||||
|
## 18. Управление пользователями в веб
|
||||||
|
|
||||||
|
- [x] Страница `/users`: поиск, баланс, админ-флаг, блокировка.
|
||||||
|
- [x] POST-действия: изменить баланс, назначить/снять админа, заблокировать.
|
||||||
|
- [x] Блокировка проверяется ботом при запросах, оплате и callback-выборе.
|
||||||
|
- [x] Админы из env сидируются один раз; дальше источник истины — БД.
|
||||||
|
- [x] Тесты страницы и действий.
|
||||||
|
|
||||||
|
**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
|
||||||
|
|
||||||
## Порядок работ
|
## Порядок работ
|
||||||
|
|
||||||
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
||||||
|
|
|
||||||
95
db.py
95
db.py
|
|
@ -121,6 +121,8 @@ class User:
|
||||||
referrer_id: int | None
|
referrer_id: int | None
|
||||||
first_paid_at: str | None
|
first_paid_at: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
is_admin: bool
|
||||||
|
is_blocked: bool
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_user(self) -> str:
|
def display_user(self) -> str:
|
||||||
|
|
@ -136,6 +138,8 @@ class User:
|
||||||
referrer_id=row["referrer_id"],
|
referrer_id=row["referrer_id"],
|
||||||
first_paid_at=row["first_paid_at"],
|
first_paid_at=row["first_paid_at"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
|
is_admin=bool(row["is_admin"]),
|
||||||
|
is_blocked=bool(row["is_blocked"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -242,11 +246,18 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
last_daily_credit TEXT NOT NULL,
|
last_daily_credit TEXT NOT NULL,
|
||||||
referrer_id INTEGER,
|
referrer_id INTEGER,
|
||||||
first_paid_at TEXT,
|
first_paid_at TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_blocked INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_referrer
|
CREATE INDEX IF NOT EXISTS idx_users_referrer
|
||||||
ON users(referrer_id);
|
ON users(referrer_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -295,6 +306,16 @@ class Database:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"ALTER TABLE pending_payments ADD COLUMN is_premium INTEGER NOT NULL DEFAULT 0"
|
"ALTER TABLE pending_payments ADD COLUMN is_premium INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
async with conn.execute("PRAGMA table_info(users)") as cursor:
|
||||||
|
user_columns = {row["name"] async for row in cursor}
|
||||||
|
if "is_admin" not in user_columns:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
if "is_blocked" not in user_columns:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO users
|
INSERT OR IGNORE INTO users
|
||||||
|
|
@ -415,6 +436,78 @@ class Database:
|
||||||
raise RuntimeError("Не удалось прочитать пользователя")
|
raise RuntimeError("Не удалось прочитать пользователя")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def seed_admins(self, user_ids: frozenset[int] | set[int]) -> None:
|
||||||
|
if not user_ids:
|
||||||
|
return
|
||||||
|
conn = self._connection()
|
||||||
|
async with self._write_lock:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT value FROM app_settings WHERE key = 'admins_seeded'"
|
||||||
|
) as cursor:
|
||||||
|
if await cursor.fetchone() is not None:
|
||||||
|
return
|
||||||
|
today = msk_today().isoformat()
|
||||||
|
for user_id in user_ids:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO users
|
||||||
|
(user_id, username, credits, last_daily_credit, created_at)
|
||||||
|
VALUES (?, NULL, 3, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, today, utc_now()),
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET is_admin = 1 WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO app_settings (key, value)"
|
||||||
|
" VALUES ('admins_seeded', '1')"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def list_users_admin(self, limit: int = 500) -> list[User]:
|
||||||
|
conn = self._connection()
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT * FROM users ORDER BY user_id LIMIT ?", (limit,)
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [User.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def set_admin(self, user_id: int, enabled: bool) -> bool:
|
||||||
|
return await self._update_user_flag(
|
||||||
|
"is_admin", user_id, enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_blocked(self, user_id: int, enabled: bool) -> bool:
|
||||||
|
return await self._update_user_flag(
|
||||||
|
"is_blocked", user_id, enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_credits(self, user_id: int, credits: int) -> bool:
|
||||||
|
conn = self._connection()
|
||||||
|
async with self._write_lock:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"UPDATE users SET credits = ? WHERE user_id = ?",
|
||||||
|
(max(0, credits), user_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def _update_user_flag(
|
||||||
|
self, column: str, user_id: int, enabled: bool
|
||||||
|
) -> bool:
|
||||||
|
if column not in {"is_admin", "is_blocked"}:
|
||||||
|
raise ValueError("Недопустимое поле")
|
||||||
|
conn = self._connection()
|
||||||
|
async with self._write_lock:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"UPDATE users SET {column} = ? WHERE user_id = ?",
|
||||||
|
(int(enabled), user_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
async def add_credits(self, user_id: int, amount: int) -> int | None:
|
async def add_credits(self, user_id: int, amount: int) -> int | None:
|
||||||
conn = self._connection()
|
conn = self._connection()
|
||||||
async with self._write_lock:
|
async with self._write_lock:
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ fastapi>=0.115,<1
|
||||||
uvicorn>=0.30,<1
|
uvicorn>=0.30,<1
|
||||||
jinja2>=3.1,<4
|
jinja2>=3.1,<4
|
||||||
pillow>=10,<13
|
pillow>=10,<13
|
||||||
|
python-multipart>=0.0.9,<1
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,31 @@ def insert_request(
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def insert_user(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
username: str | None = "alice",
|
||||||
|
credits: int = 3,
|
||||||
|
is_admin: int = 0,
|
||||||
|
is_blocked: int = 0,
|
||||||
|
) -> None:
|
||||||
|
connection = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users
|
||||||
|
(user_id, username, credits, last_daily_credit, created_at,
|
||||||
|
is_admin, is_blocked)
|
||||||
|
VALUES (?, ?, ?, '2026-10-18', '2026-10-18T12:00:00+00:00', ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, username, credits, is_admin, is_blocked),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def test_history_page_and_user_filter(tmp_path: Path) -> None:
|
def test_history_page_and_user_filter(tmp_path: Path) -> None:
|
||||||
settings = make_settings(tmp_path)
|
settings = make_settings(tmp_path)
|
||||||
app = create_app(settings)
|
app = create_app(settings)
|
||||||
|
|
@ -112,3 +137,46 @@ def test_detail_and_media(tmp_path: Path) -> None:
|
||||||
|
|
||||||
missing = client.get(f"/media/{request_id}/input")
|
missing = client.get(f"/media/{request_id}/input")
|
||||||
assert missing.status_code == 404
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_management(tmp_path: Path) -> None:
|
||||||
|
settings = make_settings(tmp_path)
|
||||||
|
app = create_app(settings)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
insert_user(settings.db_path, user_id=1, username="alice")
|
||||||
|
insert_user(settings.db_path, user_id=2, username="bob", credits=0)
|
||||||
|
|
||||||
|
page = client.get("/users")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "@alice" in page.text
|
||||||
|
assert "@bob" in page.text
|
||||||
|
|
||||||
|
assert client.post("/users/1/credits", data={"credits": "9"}).status_code == 200
|
||||||
|
assert client.post("/users/1/admin").status_code == 200
|
||||||
|
assert client.post("/users/2/block").status_code == 200
|
||||||
|
|
||||||
|
connection = sqlite3.connect(settings.db_path)
|
||||||
|
try:
|
||||||
|
first = connection.execute(
|
||||||
|
"SELECT credits, is_admin, is_blocked FROM users WHERE user_id = 1"
|
||||||
|
).fetchone()
|
||||||
|
second = connection.execute(
|
||||||
|
"SELECT is_blocked FROM users WHERE user_id = 2"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
assert first == (9, 1, 0)
|
||||||
|
assert second == (1,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_search(tmp_path: Path) -> None:
|
||||||
|
settings = make_settings(tmp_path)
|
||||||
|
app = create_app(settings)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
insert_user(settings.db_path, user_id=1, username="alice")
|
||||||
|
insert_user(settings.db_path, user_id=2, username="bob")
|
||||||
|
|
||||||
|
page = client.get("/users?q=alice")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "@alice" in page.text
|
||||||
|
assert "@bob" not in page.text
|
||||||
|
|
|
||||||
53
web.py
53
web.py
|
|
@ -7,12 +7,12 @@ import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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, HTTPException, Request
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
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
|
||||||
|
|
@ -96,6 +96,53 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
async def healthz() -> dict[str, bool]:
|
async def healthz() -> dict[str, bool]:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/users", response_class=HTMLResponse)
|
||||||
|
async def users(request: Request, q: str = ""):
|
||||||
|
db: Database = request.app.state.db
|
||||||
|
rows = await db.list_users_admin(1000)
|
||||||
|
needle = q.strip().lower().lstrip("@")
|
||||||
|
if needle:
|
||||||
|
rows = [
|
||||||
|
user
|
||||||
|
for user in rows
|
||||||
|
if needle in str(user.user_id)
|
||||||
|
or (user.username and needle in user.username.lower())
|
||||||
|
]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="users.html",
|
||||||
|
context={"users": rows, "q": q},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/users/{user_id}/credits")
|
||||||
|
async def 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("/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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(
|
async def index(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,25 @@ a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
|
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
|
||||||
.muted { color: var(--muted); font-size: 13px; }
|
.muted { color: var(--muted); font-size: 13px; }
|
||||||
|
.nav { display: flex; gap: 16px; margin-left: 8px; }
|
||||||
|
.nav a { color: var(--muted); font-size: 14px; }
|
||||||
|
.nav a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search { display: flex; gap: 8px; }
|
||||||
|
.search input { min-width: 220px; }
|
||||||
|
.inline-form { display: inline-flex; gap: 6px; align-items: center; margin-top: 6px; }
|
||||||
|
.inline-form button { padding: 4px 8px; font-size: 12px; }
|
||||||
|
.credits-input { width: 80px; min-width: 0; padding: 4px 6px; }
|
||||||
|
.users-table { min-width: 1050px; }
|
||||||
|
.users-table td { vertical-align: top; }
|
||||||
|
|
||||||
.page { max-width: 1280px; margin: 0 auto; padding: 24px 28px 48px; }
|
.page { max-width: 1280px; margin: 0 auto; padding: 24px 28px 48px; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/">MpakoImageAIbot</a>
|
<a class="brand" href="/">MpakoImageAIbot</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/">История</a>
|
||||||
|
<a href="/users">Пользователи</a>
|
||||||
|
</nav>
|
||||||
<span class="muted">История запросов</span>
|
<span class="muted">История запросов</span>
|
||||||
</header>
|
</header>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Пользователи</h1>
|
||||||
|
<form class="search" method="get" action="/users">
|
||||||
|
<input name="q" value="{{ q }}" placeholder="ID или username">
|
||||||
|
<button type="submit">Найти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Баланс</th>
|
||||||
|
<th>Админ</th>
|
||||||
|
<th>Блокировка</th>
|
||||||
|
<th>Реферер</th>
|
||||||
|
<th>Первая оплата</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">
|
||||||
|
<a href="/?user_id={{ user.user_id }}">{{ user.user_id }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.display_user }}</td>
|
||||||
|
<td>
|
||||||
|
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/credits">
|
||||||
|
<input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}">
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %}
|
||||||
|
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/admin">
|
||||||
|
<button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %}
|
||||||
|
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/block">
|
||||||
|
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.referrer_id %}
|
||||||
|
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ user.first_paid_at|fmt_dt }}</td>
|
||||||
|
<td>{{ user.created_at|fmt_dt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="empty">Пользователей нет</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue