284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Чтение данных пользователей для админки (Блок 4.3).
|
||
|
||
Баланс — контракт ``SUM(bot.money.amount)``; яички — ``SUM(bot.eggs.amount)``.
|
||
Подписка на рассылки — отсутствие строки в ``bot.unsubscribe``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from common.db.pool import get_pool
|
||
|
||
#: Общая часть выборки: агрегаты по сообщениям, деньгам, яичкам и очищениям.
|
||
_FROM = """
|
||
FROM bot.tg_users AS tg
|
||
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user
|
||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
||
LEFT JOIN (
|
||
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
|
||
FROM bot.messages GROUP BY id_user
|
||
) AS msg ON msg.id_user = tg.id_user
|
||
LEFT JOIN (
|
||
SELECT id_user, sum(amount) AS balance
|
||
FROM bot.money GROUP BY id_user
|
||
) AS money ON money.id_user = tg.id_user
|
||
LEFT JOIN (
|
||
SELECT id_user, sum(amount) AS eggs
|
||
FROM bot.eggs GROUP BY id_user
|
||
) AS eggs ON eggs.id_user = tg.id_user
|
||
LEFT JOIN (
|
||
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
|
||
FROM bot.purifications GROUP BY id_user
|
||
) AS pur ON pur.id_user = tg.id_user
|
||
LEFT JOIN bot.unsubscribe AS unsub ON unsub.id_user = tg.id_user
|
||
"""
|
||
|
||
_SELECT = """
|
||
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
|
||
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
|
||
du.id_title,
|
||
t.title,
|
||
coalesce(msg.cnt, 0) AS msg_count,
|
||
msg.last_dt AS last_message_at,
|
||
coalesce(money.balance, 0) AS balance,
|
||
coalesce(eggs.eggs, 0) AS eggs,
|
||
coalesce(pur.cnt, 0) AS purification_count,
|
||
pur.last_dt AS last_purification_at,
|
||
(unsub.id_user IS NULL) AS subscribed
|
||
"""
|
||
|
||
#: Колонки, по которым идёт поиск (в порядке подстановки параметров).
|
||
_SEARCH_COLUMNS = ("tg.username", "tg.first_name", "tg.last_name", "du.nick")
|
||
|
||
|
||
def _search_clause(term: str) -> tuple[str, list[Any]]:
|
||
"""WHERE по нику, username, имени и Telegram-ID."""
|
||
term = term.strip()
|
||
if not term:
|
||
return "", []
|
||
|
||
like = f"%{term}%"
|
||
clauses = [f"{column} ILIKE %s" for column in _SEARCH_COLUMNS]
|
||
params: list[Any] = [like] * len(_SEARCH_COLUMNS)
|
||
|
||
telegram_id = term.lstrip("@")
|
||
if telegram_id.isdigit():
|
||
clauses.append("tg.id_user = %s")
|
||
params.append(int(telegram_id))
|
||
|
||
return " WHERE (" + " OR ".join(clauses) + ")", params
|
||
|
||
|
||
async def count_users(search: str = "") -> int:
|
||
"""Сколько пользователей подходит под поиск."""
|
||
where, params = _search_clause(search)
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute("SELECT count(*) AS total " + _FROM + where, tuple(params))
|
||
row = await cur.fetchone()
|
||
return int(row["total"]) if row else 0
|
||
|
||
|
||
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
|
||
"""Страница списка пользователей (с поиском)."""
|
||
where, params = _search_clause(search)
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute(
|
||
_SELECT + _FROM + where + " ORDER BY du.nick NULLS LAST, tg.id_user LIMIT %s OFFSET %s",
|
||
(*params, limit, offset),
|
||
)
|
||
return await cur.fetchall()
|
||
|
||
|
||
async def get_user(user_id: int) -> dict[str, Any] | None:
|
||
"""Карточка пользователя со сводными показателями."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute(_SELECT + _FROM + " WHERE tg.id_user = %s", (user_id,))
|
||
return await cur.fetchone()
|
||
|
||
|
||
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
||
"""Последние операции по балансу с названием действия."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute(
|
||
"""
|
||
SELECT m.id_money, m.amount, m.dt_action, a.title AS action
|
||
FROM bot.money AS m
|
||
LEFT JOIN bot.actions AS a ON a.id_action = m.id_action
|
||
WHERE m.id_user = %s
|
||
ORDER BY m.dt_action DESC, m.id_money DESC
|
||
LIMIT %s
|
||
""",
|
||
(user_id, limit),
|
||
)
|
||
return await cur.fetchall()
|
||
|
||
|
||
# --- Управление пользователем (Блок 4.3) -------------------------------------
|
||
|
||
#: Действие «Корректировка админом» из V18 (ручное изменение баланса).
|
||
ADMIN_ACTION_ID = 6
|
||
|
||
#: Ограничение длины ника (как в команде /ник).
|
||
MAX_NICK_LENGTH = 32
|
||
|
||
#: Ограничение длины комментария к корректировке баланса.
|
||
MAX_COMMENT_LENGTH = 200
|
||
|
||
|
||
async def list_titles() -> list[dict[str, Any]]:
|
||
"""Доступные звания для выбора в карточке."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title")
|
||
return await cur.fetchall()
|
||
|
||
|
||
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||
"""История ручных изменений пользователя."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute(
|
||
"""
|
||
SELECT id, field, old_value, new_value, comment, editor_id, changed_at
|
||
FROM bot.user_changes
|
||
WHERE id_user = %s
|
||
ORDER BY changed_at DESC
|
||
LIMIT %s
|
||
""",
|
||
(user_id, limit),
|
||
)
|
||
return await cur.fetchall()
|
||
|
||
|
||
async def _log_change(
|
||
conn: Any,
|
||
user_id: int,
|
||
field: str,
|
||
old_value: str | None,
|
||
new_value: str | None,
|
||
editor_id: int | None,
|
||
comment: str | None = None,
|
||
) -> None:
|
||
await conn.execute(
|
||
"""
|
||
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id)
|
||
VALUES (%s, %s, %s, %s, %s, %s)
|
||
""",
|
||
(user_id, field, old_value, new_value, comment, editor_id),
|
||
)
|
||
|
||
|
||
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
||
"""Сменить ник (создаёт запись de_users, если её ещё нет)."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,))
|
||
row = await cur.fetchone()
|
||
|
||
if row is None:
|
||
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick))
|
||
old_nick = None
|
||
else:
|
||
old_nick = row["nick"]
|
||
if old_nick == nick:
|
||
return True
|
||
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id))
|
||
|
||
await _log_change(conn, user_id, "nick", old_nick, nick, editor_id)
|
||
return True
|
||
|
||
|
||
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
||
"""Сменить звание пользователя. False — если пользователя или звания нет."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute(
|
||
"""
|
||
SELECT du.id_title, coalesce(t.title, '') AS title
|
||
FROM bot.de_users AS du
|
||
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
||
WHERE du.id_user = %s
|
||
FOR UPDATE OF du
|
||
""",
|
||
(user_id,),
|
||
)
|
||
current = await cur.fetchone()
|
||
if current is None:
|
||
return False
|
||
|
||
cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,))
|
||
title = await cur.fetchone()
|
||
if title is None:
|
||
return False
|
||
|
||
if current["id_title"] == title_id:
|
||
return True
|
||
|
||
await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id))
|
||
await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id)
|
||
return True
|
||
|
||
|
||
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
||
"""Подписать или отписать пользователя от рассылок."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||
if await cur.fetchone() is None:
|
||
return False
|
||
|
||
cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||
was_subscribed = await cur.fetchone() is None
|
||
if was_subscribed == subscribed:
|
||
return True
|
||
|
||
if subscribed:
|
||
await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||
else:
|
||
await conn.execute(
|
||
"INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING",
|
||
(user_id,),
|
||
)
|
||
|
||
await _log_change(
|
||
conn,
|
||
user_id,
|
||
"subscription",
|
||
"да" if was_subscribed else "нет",
|
||
"да" if subscribed else "нет",
|
||
editor_id,
|
||
)
|
||
return True
|
||
|
||
|
||
async def adjust_balance(
|
||
user_id: int,
|
||
amount: int,
|
||
reason: str,
|
||
*,
|
||
editor_id: int | None = None,
|
||
) -> int | None:
|
||
"""Изменить баланс записью в money. Возвращает новый баланс или None."""
|
||
async with get_pool().connection() as conn:
|
||
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||
if await cur.fetchone() is None:
|
||
return None
|
||
|
||
cur = await conn.execute(
|
||
"SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,)
|
||
)
|
||
row = await cur.fetchone()
|
||
old_balance = int(row["balance"])
|
||
|
||
await conn.execute(
|
||
"INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)",
|
||
(user_id, amount, ADMIN_ACTION_ID),
|
||
)
|
||
new_balance = old_balance + amount
|
||
await _log_change(
|
||
conn,
|
||
user_id,
|
||
"balance",
|
||
str(old_balance),
|
||
str(new_balance),
|
||
editor_id,
|
||
comment=reason,
|
||
)
|
||
return new_balance
|