Управление пользователями
Build and push Docker image / docker (push) Successful in 2m11s
Details
Build and push Docker image / docker (push) Successful in 2m11s
Details
This commit is contained in:
parent
a9df213714
commit
99cea7cb8f
16
README.md
16
README.md
|
|
@ -13,6 +13,7 @@ Telegram-бот для генерации и редактирования изо
|
|||
- **Оплата**: 5 Telegram Stars за обычную очередь, 15 за премиум; админам бесплатно, возврат при ошибке.
|
||||
- **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая.
|
||||
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
|
||||
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
|
||||
- Глобальная очередь с одной активной задачей на пользователя.
|
||||
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
|
||||
- SQLite: запросы, статусы, seed, пути к файлам.
|
||||
|
|
@ -124,6 +125,19 @@ BLOCKED_PUBLIC_FIGURES=
|
|||
|
||||
При блокировке пользователь получает «Запрос нарушает правила. Звёзды не списаны.», а запись с `user_id`, временем, промптом и причиной сохраняется в таблицу `blocked_requests`.
|
||||
|
||||
## Управление пользователями
|
||||
|
||||
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
|
||||
|
||||
- поиск по ID или username;
|
||||
- установка точного баланса генераций;
|
||||
- назначение/снятие админа;
|
||||
- блокировка/разблокировка.
|
||||
|
||||
Заблокированный пользователь не может отправлять запросы, получать счета и оплачивать. Админ-флаг из веб-интерфейса действует сразу.
|
||||
|
||||
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
|
||||
|
||||
## Баланс и рефералы
|
||||
|
||||
- Новому пользователю начисляется 3 генерации.
|
||||
|
|
@ -149,7 +163,7 @@ PREMIUM_STARS_PRICE=15
|
|||
- Перед оплатой бот показывает количество людей в обычной и премиум-очередях.
|
||||
- Пользователь выбирает очередь кнопкой, после чего получает счёт.
|
||||
- Премиум- и обычные задачи чередуются: одна премиум, одна обычная; если одна очередь пуста, берётся из другой.
|
||||
- Админы из `ADMIN_USER_IDS` генерируют бесплатно и попадают в премиум-очередь.
|
||||
- Админы из `ADMIN_USER_IDS` генерируют бесплатно в обычной очереди.
|
||||
- Не-админу выставляется счёт через Telegram Stars (`XTR`).
|
||||
- Фильтрация промпта выполняется до выставления счёта.
|
||||
- Если поставить задачу в очередь не удалось, звёзды возвращаются.
|
||||
|
|
|
|||
28
bot.py
28
bot.py
|
|
@ -113,6 +113,7 @@ class MpakoBot:
|
|||
self._settings.comfy_ws_url,
|
||||
)
|
||||
await self._db.connect()
|
||||
await self._db.seed_admins(self._settings.admin_user_ids)
|
||||
await self._db.accrue_daily_credits()
|
||||
self._daily_task = asyncio.create_task(
|
||||
self._daily_credits_loop(), name="daily-credits"
|
||||
|
|
@ -324,12 +325,14 @@ class MpakoBot:
|
|||
if query is None:
|
||||
return
|
||||
pending = await self._db.get_pending_payment(query.invoice_payload)
|
||||
user_row = await self._db.get_user(query.from_user.id)
|
||||
allowed = (
|
||||
pending is not None
|
||||
and pending.user_id == query.from_user.id
|
||||
and pending.status == "pending"
|
||||
and query.currency == "XTR"
|
||||
and query.total_amount == pending.amount
|
||||
and not (user_row is not None and user_row.is_blocked)
|
||||
)
|
||||
try:
|
||||
await query.answer(
|
||||
|
|
@ -373,6 +376,11 @@ class MpakoBot:
|
|||
)
|
||||
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":
|
||||
remaining = await self._db.consume_credit(query.from_user.id)
|
||||
if remaining is None:
|
||||
|
|
@ -465,6 +473,17 @@ class MpakoBot:
|
|||
if pending.status != "pending":
|
||||
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
|
||||
if payment.currency != "XTR" or payment.total_amount != pending.amount:
|
||||
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:
|
||||
return False
|
||||
|
||||
is_admin = user.id in self._settings.admin_user_ids
|
||||
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
|
||||
has_person = False
|
||||
if (
|
||||
|
|
@ -1067,6 +1089,10 @@ class MpakoBot:
|
|||
):
|
||||
await message.reply_text("Доступ к боту ограничен.")
|
||||
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
|
||||
|
||||
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 и веб-история.
|
||||
- 50 тестов проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, вспомогательные функции ComfyUI.
|
||||
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
|
||||
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
|
||||
|
||||
## 0. Допущения и решения по умолчанию
|
||||
|
|
@ -351,6 +351,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
|||
|
||||
**DoD:** новый пользователь получает 3 генерации, ежедневно +1, друг после первой оплаты приносит рефереру +3.
|
||||
|
||||
## 18. Управление пользователями в веб
|
||||
|
||||
- [x] Страница `/users`: поиск, баланс, админ-флаг, блокировка.
|
||||
- [x] POST-действия: изменить баланс, назначить/снять админа, заблокировать.
|
||||
- [x] Блокировка проверяется ботом при запросах, оплате и callback-выборе.
|
||||
- [x] Админы из env сидируются один раз; дальше источник истины — БД.
|
||||
- [x] Тесты страницы и действий.
|
||||
|
||||
**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
|
||||
|
||||
## Порядок работ
|
||||
|
||||
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.
|
||||
|
|
|
|||
95
db.py
95
db.py
|
|
@ -121,6 +121,8 @@ class User:
|
|||
referrer_id: int | None
|
||||
first_paid_at: str | None
|
||||
created_at: str
|
||||
is_admin: bool
|
||||
is_blocked: bool
|
||||
|
||||
@property
|
||||
def display_user(self) -> str:
|
||||
|
|
@ -136,6 +138,8 @@ class User:
|
|||
referrer_id=row["referrer_id"],
|
||||
first_paid_at=row["first_paid_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,
|
||||
referrer_id INTEGER,
|
||||
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
|
||||
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(
|
||||
"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(
|
||||
"""
|
||||
INSERT OR IGNORE INTO users
|
||||
|
|
@ -415,6 +436,78 @@ class Database:
|
|||
raise RuntimeError("Не удалось прочитать пользователя")
|
||||
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:
|
||||
conn = self._connection()
|
||||
async with self._write_lock:
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ fastapi>=0.115,<1
|
|||
uvicorn>=0.30,<1
|
||||
jinja2>=3.1,<4
|
||||
pillow>=10,<13
|
||||
python-multipart>=0.0.9,<1
|
||||
|
|
|
|||
|
|
@ -48,6 +48,31 @@ def insert_request(
|
|||
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:
|
||||
settings = make_settings(tmp_path)
|
||||
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")
|
||||
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 datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image, ImageOps
|
||||
|
|
@ -96,6 +96,53 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||
async def healthz() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users(request: Request, q: str = ""):
|
||||
db: Database = request.app.state.db
|
||||
rows = await db.list_users_admin(1000)
|
||||
needle = q.strip().lower().lstrip("@")
|
||||
if needle:
|
||||
rows = [
|
||||
user
|
||||
for user in rows
|
||||
if needle in str(user.user_id)
|
||||
or (user.username and needle in user.username.lower())
|
||||
]
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="users.html",
|
||||
context={"users": rows, "q": q},
|
||||
)
|
||||
|
||||
@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)
|
||||
async def index(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,25 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
|
||||
.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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">MpakoImageAIbot</a>
|
||||
<nav class="nav">
|
||||
<a href="/">История</a>
|
||||
<a href="/users">Пользователи</a>
|
||||
</nav>
|
||||
<span class="muted">История запросов</span>
|
||||
</header>
|
||||
<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