scale cabinet-mono pack architecture

This commit is contained in:
Vladless
2026-05-03 10:36:03 +00:00
parent 3a7bf7430d
commit aa086a57d2
11 changed files with 616 additions and 14 deletions
+324 -4
View File
@@ -1,5 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import func, select
import re
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
@@ -19,7 +23,11 @@ from api.v2.schemas.identities import (
IdentitySessionsResponse,
SetPasswordRequest,
)
from api.v2.schemas.web_public import AccountSummaryResponse
from api.v2.schemas.web_public import (
AccountSearchHit,
AccountSearchResponse,
AccountSummaryResponse,
)
from database import (
get_balance,
get_keys,
@@ -27,7 +35,7 @@ from database import (
identities as idb,
identity_sessions as idsess,
)
from database.models import CouponUsage, Gift, GiftUsage
from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification
from database.referrals import get_referral_stats
from database.web_notifications import count_unread_for_identity
from utils.referral_codes import encode_referral_code
@@ -208,6 +216,8 @@ async def auth_summary(
email=identity.email,
tg_id=identity.tg_id,
linked_telegram=identity.tg_id is not None,
created_at=identity.created_at.isoformat() if identity.created_at else None,
password_set=bool(identity.password_set),
referral_code=encode_referral_code(int(billing_user_id)),
balance=balance,
trial_status=int(trial_status),
@@ -230,6 +240,316 @@ async def auth_summary(
)
class MyPaymentItem(BaseModel):
id: int
amount: float
currency: str
status: str
provider: str
created_at: str | None
purpose: str | None
class MyPaymentsResponse(BaseModel):
ok: bool = True
payments: list[MyPaymentItem]
@router.get("/me/payments", response_model=MyPaymentsResponse)
async def my_payments(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 50,
):
"""История платежей текущего юзера. Привязка через Identity → User → Payment."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return MyPaymentsResponse(ok=True, payments=[])
safe_limit = max(1, min(200, int(limit) if limit else 50))
rows = await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
payments = rows.scalars().all()
items: list[MyPaymentItem] = []
for p in payments:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = None
if meta:
purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if purpose is not None:
purpose = str(purpose)
items.append(
MyPaymentItem(
id=int(p.id),
amount=float(p.amount or 0),
currency=str(p.currency or "RUB"),
status=str(p.status or ""),
provider=str(p.payment_system or ""),
created_at=p.created_at.isoformat() if p.created_at else None,
purpose=purpose,
)
)
return MyPaymentsResponse(ok=True, payments=items)
def _esc(value: object) -> str:
s = "" if value is None else str(value)
return (
s.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse)
async def get_my_payment_invoice(
payment_id: int = Path(..., ge=1),
request: Request = None, # type: ignore[assignment]
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF)."""
actor = get_request_actor(request) if request is not None else None
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
payment = (
await session.execute(
select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1)
)
).scalar_one_or_none()
if payment is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {}
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else ""
amount_value = float(payment.amount or 0)
currency = str(payment.currency or "RUB").upper()
status_raw = str(payment.status or "")
status_norm = status_raw.lower()
status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН"
provider = str(payment.payment_system or "").upper() or ""
user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id)
html = f"""<!DOCTYPE html>
<html lang=\"ru\">
<head>
<meta charset=\"utf-8\" />
<title>Квитанция #{_esc(payment.id)}</title>
<style>
@page {{ size: A4; margin: 18mm; }}
body {{ font-family: 'JetBrains Mono', ui-monospace, monospace; color: #111; background: #fff; max-width: 720px; margin: 0 auto; padding: 24px; }}
h1 {{ font-size: 24px; letter-spacing: -0.02em; margin: 0 0 4px; text-transform: uppercase; }}
.sub {{ color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 32px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
td {{ padding: 11px 0; border-bottom: 1px dashed #ddd; vertical-align: top; }}
td.k {{ color: #888; width: 35%; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }}
td.v {{ font-weight: 600; }}
.amount {{ font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 8px; }}
.badge {{ display: inline-block; padding: 4px 10px; border: 1px solid #111; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }}
.footer {{ margin-top: 48px; font-size: 10px; color: #aaa; letter-spacing: 0.12em; text-transform: uppercase; text-align: center; }}
@media print {{ .no-print {{ display: none; }} }}
.print-btn {{ position: fixed; top: 16px; right: 16px; padding: 10px 16px; background: #111; color: #fff; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }}
</style>
</head>
<body>
<button class=\"print-btn no-print\" onclick=\"window.print()\">Сохранить PDF</button>
<h1>Квитанция #{_esc(payment.id)}</h1>
<div class=\"sub\">// {_esc(created)}</div>
<div class=\"amount\">{amount_value:,.2f} {_esc(currency)}</div>
<span class=\"badge\">{_esc(status_label)}</span>
<table>
<tr><td class=\"k\">Назначение</td><td class=\"v\">{_esc(purpose) or ""}</td></tr>
<tr><td class=\"k\">Провайдер</td><td class=\"v\">{_esc(provider)}</td></tr>
<tr><td class=\"k\">Дата</td><td class=\"v\">{_esc(created)}</td></tr>
<tr><td class=\"k\">Получатель</td><td class=\"v\">{_esc(user_label)}</td></tr>
<tr><td class=\"k\">Идентификатор платежа</td><td class=\"v\" style=\"font-size:11px;color:#666\">{_esc(payment.id)}</td></tr>
</table>
<div class=\"footer\">Документ сгенерирован автоматически. Не требует подписи и печати.</div>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)
class NotifChannelPref(BaseModel):
channel: str
enabled: bool
class NotifChannelPrefsResponse(BaseModel):
ok: bool = True
channels: list[NotifChannelPref]
class NotifChannelPrefsUpdateRequest(BaseModel):
channels: list[NotifChannelPref]
_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def get_my_notification_prefs(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def set_my_notification_prefs(
body: NotifChannelPrefsUpdateRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
for entry in body.channels:
channel = str(entry.channel or "").strip()
if not channel or not _NOTIF_CHANNEL_RE.match(channel):
raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}")
existing = (
await session.execute(
select(IdentityNotifPref).where(
IdentityNotifPref.identity_id == identity.id,
IdentityNotifPref.channel == channel,
)
)
).scalar_one_or_none()
if existing is None:
session.add(
IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled))
)
else:
existing.enabled = bool(entry.enabled)
await session.flush()
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.get("/me/search", response_model=AccountSearchResponse)
async def my_search(
q: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 8,
):
"""Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE."""
query_raw = (q or "").strip()
if len(query_raw) < 2:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
safe_limit = max(1, min(20, int(limit) if limit else 8))
pattern = f"%{query_raw.lower()}%"
hits: list[AccountSearchHit] = []
# Keys: alias / email / server_id
keys_rows = (
await session.execute(
select(Key)
.where(Key.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Key.alias, ""))
.like(pattern)
| func.lower(func.coalesce(Key.email, "")).like(pattern)
| func.lower(func.coalesce(Key.server_id, "")).like(pattern)
| func.lower(func.coalesce(Key.client_id, "")).like(pattern)
)
.limit(safe_limit)
)
).scalars().all()
for k in keys_rows:
label = (k.alias or k.email or k.client_id or "").strip() or ""
sublabel = (k.server_id or "").strip() or ""
hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id)))
# Payments: provider / metadata.purpose
payments_rows = (
await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Payment.payment_system, "")).like(pattern)
| func.cast(Payment.metadata_, String).ilike(pattern)
)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for p in payments_rows:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}"
hits.append(AccountSearchHit(
kind="payment",
label=purpose or amount_label,
sublabel=f"{(p.payment_system or '').upper()} · {amount_label}",
href="/dashboard",
meta=str(p.id),
))
# Notifications: title / message
notif_rows = (
await session.execute(
select(WebNotification)
.where(WebNotification.identity_id == identity.id)
.where(
func.lower(WebNotification.title).like(pattern)
| func.lower(WebNotification.message).like(pattern)
)
.order_by(WebNotification.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for n in notif_rows:
hits.append(AccountSearchHit(
kind="notification",
label=str(n.title or ""),
sublabel=(str(n.message or "")[:80]),
href="/dashboard/notifications",
meta=str(n.id),
))
return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits))
@router.post("/set-password")
async def set_password(
body: SetPasswordRequest,
+1
View File
@@ -35,6 +35,7 @@ from api.v2.schemas.web_public import (
AccountKeyApplyAddonsResponse,
AccountKeyChangeLocationRequest,
AccountKeyChangeLocationResponse,
AccountKeyConnectionResponse,
AccountKeyDetailsResponse,
AccountKeyLocationOptionResponse,
AccountKeyLocationsResponse,
+54
View File
@@ -4,6 +4,8 @@
из ``__init__.py`` запускает регистрацию декораторов.
"""
import time
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
@@ -60,6 +62,58 @@ async def user_keys_actions_config(
return _key_actions_config()
@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse)
async def user_key_connection(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания."""
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_name = str(getattr(db_key, "server_id", "") or "")
cluster_name = ""
panel_type = ""
if server_name:
srv = (
await session.execute(
select(Server).where(Server.server_name == server_name).limit(1)
)
).scalar_one_or_none()
if srv is not None:
cluster_name = str(getattr(srv, "cluster_name", "") or "")
panel_type = str(getattr(srv, "panel_type", "") or "").lower()
expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0)
is_frozen = bool(getattr(db_key, "is_frozen", False))
now_ms = int(time.time() * 1000)
online = not is_frozen and expiry_ms > now_ms
expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0
if panel_type == "remnawave":
protocol = "VLESS"
elif panel_type == "marzban":
protocol = "VLESS"
elif panel_type == "3xui":
protocol = "VLESS"
else:
protocol = panel_type.upper() or "VLESS"
return AccountKeyConnectionResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
online=online,
is_frozen=is_frozen,
expiry_time=expiry_ms,
expires_in_days=expires_in_days,
server_name=server_name,
cluster_name=cluster_name,
panel_type=panel_type,
protocol=protocol,
)
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
async def user_key_details(
client_id: str,
+37
View File
@@ -18,6 +18,8 @@ from api.v2.schemas.web_public import (
PartnerApplyRequest,
PartnerApplyResponse,
PartnerConditionsResponse,
PartnerInvitedEntry,
PartnerInvitedResponse,
PartnerPayoutEntryResponse,
PartnerPayoutHistoryResponse,
PartnerPayoutRequestCreate,
@@ -238,6 +240,41 @@ async def partner_apply(
)
@router.get("/invited/me", response_model=PartnerInvitedResponse)
async def partner_me_invited(
request: Request,
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_, tg_id = await _resolve_partner_user(session, request, identity)
invited_sql = text(
"""
SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0),
(SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id),
(SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success')
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
LIMIT :limit
"""
)
result = await session.execute(invited_sql, {"tg_id": tg_id, "limit": limit})
rows = result.fetchall()
items = [
PartnerInvitedEntry(
tg_id=int(row[0]),
joined_at=row[1].isoformat() if isinstance(row[1], datetime) else None,
balance=float(row[2] or 0),
keys_count=int(row[3] or 0),
payments_count=int(row[4] or 0),
)
for row in rows
]
return PartnerInvitedResponse(total=len(items), items=items)
@router.get("/qr", response_model=PartnerQrResponse)
async def partner_qr(
request: Request,
+32
View File
@@ -12,10 +12,14 @@ from api.v2.schemas.web_public import (
ReferralApplyRequest,
ReferralApplyResponse,
ReferralConditionsResponse,
ReferralListEntry,
ReferralListResponse,
ReferralQrResponse,
ReferralTopEntryResponse,
ReferralTopResponse,
)
from sqlalchemy import select
from database.models import Referral
from config import (
CHECK_REFERRAL_REWARD_ISSUED,
REFERRAL_BONUS_PERCENTAGES,
@@ -155,6 +159,34 @@ async def referral_top(
)
@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"])
async def referral_list(
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
rows_stmt = (
select(Referral)
.where(Referral.referrer_user_id == int(billing_uid))
.limit(limit)
)
result = await session.execute(rows_stmt)
rows = result.scalars().all()
items = [
ReferralListEntry(
referred_user_id=int(r.referred_user_id),
referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None,
display_id=encode_referral_code(int(r.referred_user_id)),
reward_issued=bool(r.reward_issued),
)
for r in rows
]
return ReferralListResponse(total=len(items), items=items)
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
async def referral_qr(
request: Request,
+30 -9
View File
@@ -198,7 +198,7 @@ async def site_config():
_UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None}
_UPDATE_CHECK_LOCK = asyncio.Lock()
_UPDATE_CHECK_TTL_SEC = 3600
_UPDATE_CHECK_TTL_SEC = 600
_SEMVER_RE = re.compile(
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
)
@@ -230,8 +230,8 @@ def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int |
async def _fetch_ghcr_tags(image: str) -> list[str]:
"""Возвращает все теги образа в GHCR."""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
"""Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
async with session.get(token_url) as token_resp:
if token_resp.status != 200:
@@ -241,12 +241,33 @@ async def _fetch_ghcr_tags(image: str) -> list[str]:
if not token:
return []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
tags_url = f"https://ghcr.io/v2/{image}/tags/list"
async with session.get(tags_url, headers=headers) as tags_resp:
if tags_resp.status != 200:
return []
payload = await tags_resp.json()
return payload.get("tags") or []
all_tags: list[str] = []
next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
guard = 0
while next_url and guard < 20:
guard += 1
async with session.get(next_url, headers=headers) as tags_resp:
if tags_resp.status != 200:
break
payload = await tags_resp.json()
page_tags = payload.get("tags") or []
if isinstance(page_tags, list):
all_tags.extend(str(t) for t in page_tags)
link_header = tags_resp.headers.get("Link") or ""
next_url = None
for part in link_header.split(","):
part = part.strip()
if not part or 'rel="next"' not in part:
continue
inner = part.split(";", 1)[0].strip()
if inner.startswith("<") and inner.endswith(">"):
inner = inner[1:-1]
if inner.startswith("/"):
next_url = f"https://ghcr.io{inner}"
else:
next_url = inner
break
return all_tags
def _is_dev_version(v: str) -> bool:
+53
View File
@@ -6,6 +6,8 @@ class AccountSummaryResponse(BaseModel):
email: str | None = None
tg_id: int | None = None
linked_telegram: bool = False
created_at: str | None = None
password_set: bool = False
referral_code: str = ""
balance: float = 0.0
trial_status: int = 0
@@ -196,6 +198,32 @@ class AccountKeyActionsConfigResponse(BaseModel):
tv_connect_enabled: bool = False
class AccountKeyConnectionResponse(BaseModel):
client_id: str
online: bool = False
is_frozen: bool = False
expiry_time: int = 0
expires_in_days: int = 0
server_name: str = ""
cluster_name: str = ""
panel_type: str = ""
protocol: str = ""
class AccountSearchHit(BaseModel):
kind: str
label: str
sublabel: str = ""
href: str = ""
meta: str = ""
class AccountSearchResponse(BaseModel):
query: str
hits: list[AccountSearchHit] = []
total: int = 0
class TariffConfigPriceResponse(BaseModel):
price_rub: int
@@ -325,6 +353,18 @@ class ReferralTopResponse(BaseModel):
top: list[ReferralTopEntryResponse] = []
class ReferralListEntry(BaseModel):
referred_user_id: int
referred_tg_id: int | None = None
display_id: str = ""
reward_issued: bool = False
class ReferralListResponse(BaseModel):
total: int = 0
items: list[ReferralListEntry] = []
class ReferralQrResponse(BaseModel):
ok: bool = True
link: str = ""
@@ -387,6 +427,19 @@ class PartnerTopResponse(BaseModel):
top: list[PartnerTopEntryResponse] = []
class PartnerInvitedEntry(BaseModel):
tg_id: int
joined_at: str | None = None
balance: float = 0.0
keys_count: int = 0
payments_count: int = 0
class PartnerInvitedResponse(BaseModel):
total: int = 0
items: list[PartnerInvitedEntry] = []
class CouponApplyRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=128)
+20
View File
@@ -1266,6 +1266,25 @@ async def _migration_v27_add_admins_permissions(conn: AsyncConnection) -> None:
)
async def _migration_v28_add_identity_notif_prefs(conn: AsyncConnection) -> None:
logger.info("[schema_upgrade] v28: таблица identity_notif_prefs (toggle каналов уведомлений)")
if not await _table_exists(conn, "identities"):
return
if not await _table_exists(conn, "identity_notif_prefs"):
await _exec_ignore(
conn,
"""
CREATE TABLE identity_notif_prefs (
identity_id VARCHAR(36) NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
channel VARCHAR(32) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (identity_id, channel)
)
""",
)
async def _migration_v24_add_identity_sessions(conn: AsyncConnection) -> None:
logger.info("[schema_upgrade] v24: таблица identity_sessions + перенос существующих токенов")
if not await _table_exists(conn, "identities"):
@@ -1346,6 +1365,7 @@ _MIGRATIONS = [
(25, "индексы на partners(partner_tg_id/joined_tg_id)", _migration_v25_add_partners_indexes),
(26, "индексы keys(expiry_time/server_id/tariff_id)", _migration_v26_add_keys_indexes),
(27, "admins.permissions (JSONB per-admin permissions)", _migration_v27_add_admins_permissions),
(28, "таблица identity_notif_prefs (toggle каналов)", _migration_v28_add_identity_notif_prefs),
]
+2
View File
@@ -4,6 +4,7 @@ from .audit import AuditEvent
from .coupons import Coupon, CouponUsage
from .gifts import Gift, GiftUsage
from .identity import Identity
from .identity_notif_prefs import IdentityNotifPref
from .identity_session import IdentitySession
from .keys import Key
from .notifications import Notification, ScheduledBroadcast
@@ -31,6 +32,7 @@ __all__ = [
"Base",
"DictLikeMixin",
"Identity",
"IdentityNotifPref",
"IdentitySession",
"User",
"ManualBan",
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
PrimaryKeyConstraint,
String,
)
from ._base import Base, DictLikeMixin
class IdentityNotifPref(DictLikeMixin, Base):
"""Пользовательские настройки каналов доставки уведомлений."""
__tablename__ = "identity_notif_prefs"
identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="CASCADE"),
nullable=False,
)
channel = Column(String(32), nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
PrimaryKeyConstraint("identity_id", "channel"),
)
+32 -1
View File
@@ -1,3 +1,5 @@
import re
import pytz
from aiogram import F, Router, types
@@ -39,6 +41,7 @@ from .users_states import UserEditorState
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
router = Router()
@@ -50,10 +53,11 @@ router = Router()
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
text = (
"<b>🔍 Поиск пользователя</b>"
"\n\n📌 Введите ID, Username, Email или перешлите сообщение пользователя."
"\n\n📌 Введите ID, Username, Email, UUID веб-аккаунта или перешлите сообщение пользователя."
"\n\n🆔 ID - числовой айди"
"\n📝 Username - юзернейм пользователя"
"\n📧 Email - почта веб-кабинета"
"\n🧬 UUID - идентификатор веб-аккаунта (identity_id)"
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
)
@@ -111,6 +115,33 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
if raw.isdigit():
tg_id = int(raw)
elif UUID_RE.match(raw):
identity_id = raw.lower()
ident = (
await session.execute(select(Identity).where(func.lower(Identity.id) == identity_id).limit(1))
).scalar_one_or_none()
if ident is None:
await message.answer(
text="🚫 Веб-аккаунт с указанным UUID не найден!",
reply_markup=kb,
)
return
if ident.tg_id is not None:
tg_id = ident.tg_id
else:
user_id = (
await session.execute(select(User.id).where(User.identity_id == ident.id).limit(1))
).scalar_one_or_none()
if user_id is None:
label = ident.email or ident.id
await message.answer(
text=f"🚫 Веб-аккаунт <code>{label}</code> не имеет биллинг-профиля.",
reply_markup=kb,
)
return
tg_id = user_id
elif "@" in raw and "." in raw.split("@", 1)[-1]:
email = raw.lower()
ident = (