diff --git a/api/v2/routes/auth/session.py b/api/v2/routes/auth/session.py index 32db8265..f8fb394d 100644 --- a/api/v2/routes/auth/session.py +++ b/api/v2/routes/auth/session.py @@ -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""" + +
+ +| Назначение | {_esc(purpose) or "—"} |
| Провайдер | {_esc(provider)} |
| Дата | {_esc(created)} |
| Получатель | {_esc(user_label)} |
| Идентификатор платежа | {_esc(payment.id)} |
[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:
diff --git a/api/v2/schemas/web_public.py b/api/v2/schemas/web_public.py
index a5803867..52eb4c0d 100644
--- a/api/v2/schemas/web_public.py
+++ b/api/v2/schemas/web_public.py
@@ -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)
diff --git a/database/migrations/schema_upgrade.py b/database/migrations/schema_upgrade.py
index 6c6bc4ac..3d754b40 100644
--- a/database/migrations/schema_upgrade.py
+++ b/database/migrations/schema_upgrade.py
@@ -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),
]
diff --git a/database/models/__init__.py b/database/models/__init__.py
index d997f08f..083d2704 100644
--- a/database/models/__init__.py
+++ b/database/models/__init__.py
@@ -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",
diff --git a/database/models/identity_notif_prefs.py b/database/models/identity_notif_prefs.py
new file mode 100644
index 00000000..13d5e3e2
--- /dev/null
+++ b/database/models/identity_notif_prefs.py
@@ -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"),
+ )
diff --git a/handlers/admin/users/users_manage.py b/handlers/admin/users/users_manage.py
index 4820046e..ff1b0386 100644
--- a/handlers/admin/users/users_manage.py
+++ b/handlers/admin/users/users_manage.py
@@ -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 = (
"🔍 Поиск пользователя"
- "\n\n📌 Введите ID, Username, Email или перешлите сообщение пользователя."
+ "\n\n📌 Введите ID, Username, Email, UUID веб-аккаунта или перешлите сообщение пользователя."
"\n\n🆔 ID - числовой айди"
"\n📝 Username - юзернейм пользователя"
"\n📧 Email - почта веб-кабинета"
+ "\n🧬 UUID - идентификатор веб-аккаунта (identity_id)"
"\n\n✉️ Для поиска, вы можете просто переслать сообщение от пользователя."
)
@@ -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"🚫 Веб-аккаунт {label} не имеет биллинг-профиля.",
+ reply_markup=kb,
+ )
+ return
+ tg_id = user_id
elif "@" in raw and "." in raw.split("@", 1)[-1]:
email = raw.lower()
ident = (