scale cabinet-mono pack architecture
This commit is contained in:
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
@@ -35,6 +35,7 @@ from api.v2.schemas.web_public import (
|
||||
AccountKeyApplyAddonsResponse,
|
||||
AccountKeyChangeLocationRequest,
|
||||
AccountKeyChangeLocationResponse,
|
||||
AccountKeyConnectionResponse,
|
||||
AccountKeyDetailsResponse,
|
||||
AccountKeyLocationOptionResponse,
|
||||
AccountKeyLocationsResponse,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user