Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcc35d6e22 | |||
| b850e81897 | |||
| 4d9e42c3f1 | |||
| 834a0478ae | |||
| 0e968987fb | |||
| acd2cff9ca | |||
| 69dbd6a2df | |||
| 497a8ee5b5 | |||
| dd8d7f6920 | |||
| b9089e693f | |||
| b815abf2b1 | |||
| 95a32e8574 | |||
| cd04f3b622 | |||
| 6a4140e3e2 | |||
| f80b058380 | |||
| 6a61b09575 | |||
| 759bfe1bdb | |||
| 0936d4a7f6 | |||
| 680c22c017 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.27.0"
|
||||
".": "3.28.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet gift subscription API routes and schemas ([6a61b09](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a61b095755885ff8973eb9ac4422740d07e0306))
|
||||
* add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering ([dd8d7f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd8d7f69203490553d15dcdad6dda28fab02d593))
|
||||
* add CABINET_GIFT_ENABLED branding toggle ([759bfe1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/759bfe1bdb3a3d3f917334fd32d0ea2f5be5d1f0))
|
||||
* add open_in setting for custom buttons (external browser / webapp) ([497a8ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/497a8ee5b528cf80d7042a7eec62369b6a327339))
|
||||
* add source and buyer_user_id fields to GuestPurchase model ([0936d4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0936d4a7f651a1fcef8c2f86818320af3764b423))
|
||||
* implement gateway payment for gifts, persist recipient warning ([cd04f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd04f3b622444f45e2edf4a92da581f3d1f79b67))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line ([69dbd6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69dbd6a2df4cf5e0dd7156ca0f3beb53c4a061af))
|
||||
* harden gift subscription feature after multi-agent review ([6a4140e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4140e3e203beb20cc56aa9c65dfed70f0a12d7))
|
||||
* loyalty tiers current status based on spending, not assigned group ([b815abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b815abf2b11e32eb658f9a8a63ae902bc0db46f4))
|
||||
* negate GIFT_PAYMENT amounts and remove dead code ([f80b058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80b0583804f27c322a4eb27f0613163ca1f97e9))
|
||||
* normalize threshold 0→NULL in create_promo_group for consistency ([b9089e6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9089e693f823e3b8618d08329ccba559592dfa3))
|
||||
* payment gateway issues — YooKassa polling, PAL24 card 500 ([95a32e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95a32e8574320eeba9276e44551a2f1207ae1e8b))
|
||||
* support Telegram OIDC id_token in account linking endpoint ([680c22c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/680c22c0179253d24f7f89e115a283dac92f9a49))
|
||||
|
||||
## [3.27.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.26.0...v3.27.0) (2026-03-09)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.27.0" # x-release-please-version
|
||||
ARG VERSION="v3.28.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
+8
-1
@@ -248,7 +248,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
# Load per-section button styles cache and menu layout cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
@@ -257,6 +257,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
try:
|
||||
from app.utils.menu_layout_cache import load_menu_layout_cache
|
||||
|
||||
await load_menu_layout_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load menu layout cache', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
@@ -12,6 +12,7 @@ from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
@@ -36,6 +37,7 @@ from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
@@ -86,6 +88,9 @@ router.include_router(media_router)
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
|
||||
# Gift routes
|
||||
router.include_router(gift_router)
|
||||
|
||||
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
|
||||
router.include_router(admin_ticket_notifications_router)
|
||||
router.include_router(admin_tickets_router)
|
||||
@@ -113,6 +118,7 @@ router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
router.include_router(admin_menu_layout_router)
|
||||
router.include_router(admin_channels_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
|
||||
@@ -5,6 +5,7 @@ Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth provide
|
||||
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
@@ -14,6 +15,8 @@ from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.user import (
|
||||
OAUTH_PROVIDER_COLUMNS,
|
||||
clear_user_oauth_provider_id,
|
||||
@@ -24,7 +27,7 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
|
||||
from ..auth.merge_service import (
|
||||
MERGE_TOKEN_TTL_SECONDS,
|
||||
@@ -38,7 +41,11 @@ from ..auth.oauth_providers import (
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..auth.telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
from ..auth.telegram_auth import (
|
||||
validate_telegram_init_data,
|
||||
validate_telegram_login_widget,
|
||||
validate_telegram_oidc_token,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..ip_utils import get_client_ip
|
||||
from ..schemas.auth import UserResponse
|
||||
@@ -70,8 +77,6 @@ class OAuthStateData(TypedDict):
|
||||
|
||||
def _get_active_providers() -> list[str]:
|
||||
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
|
||||
from app.config import settings
|
||||
|
||||
providers: list[str] = ['telegram']
|
||||
if settings.is_cabinet_email_auth_enabled():
|
||||
providers.append('email')
|
||||
@@ -117,10 +122,12 @@ class UnlinkResponse(BaseModel):
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""Request for linking Telegram account. Supply EITHER init_data OR widget fields."""
|
||||
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
|
||||
|
||||
# Mini App: Telegram WebApp initData
|
||||
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
|
||||
# OIDC: id_token from Telegram Login popup
|
||||
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
|
||||
# Login Widget fields
|
||||
id: int | None = Field(None, description='Telegram user ID from Login Widget')
|
||||
first_name: str | None = Field(None, max_length=256, description="User's first name")
|
||||
@@ -133,11 +140,13 @@ class LinkTelegramRequest(BaseModel):
|
||||
@model_validator(mode='after')
|
||||
def check_exclusive(self) -> 'LinkTelegramRequest':
|
||||
has_init = self.init_data is not None
|
||||
has_oidc = self.id_token is not None
|
||||
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
|
||||
if has_init and has_widget:
|
||||
raise ValueError('Provide either init_data or Login Widget fields, not both')
|
||||
if not has_init and not has_widget:
|
||||
raise ValueError('Provide either init_data or Login Widget fields (id, auth_date, hash)')
|
||||
modes = sum([has_init, has_oidc, has_widget])
|
||||
if modes > 1:
|
||||
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
|
||||
if modes == 0:
|
||||
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
|
||||
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
|
||||
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
|
||||
return self
|
||||
@@ -449,10 +458,20 @@ async def unlink_provider(
|
||||
@router.post('/link/telegram', response_model=LinkCallbackResponse)
|
||||
async def link_telegram(
|
||||
request: LinkTelegramRequest,
|
||||
raw_request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> LinkCallbackResponse:
|
||||
"""Link Telegram account via WebApp initData or Login Widget."""
|
||||
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
|
||||
# Rate limit
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
# 1. Already has Telegram linked?
|
||||
if user.telegram_id:
|
||||
raise HTTPException(
|
||||
@@ -478,6 +497,53 @@ async def link_telegram(
|
||||
telegram_username = user_data.get('username')
|
||||
telegram_first_name = user_data.get('first_name')
|
||||
telegram_last_name = user_data.get('last_name')
|
||||
elif request.id_token:
|
||||
# OIDC flow: validate id_token via JWKS
|
||||
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
|
||||
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
|
||||
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
|
||||
oidc_enabled = (
|
||||
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
|
||||
) and bool(oidc_client_id)
|
||||
|
||||
if not oidc_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Telegram OIDC is not configured',
|
||||
)
|
||||
|
||||
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
|
||||
if not claims:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
# Replay detection
|
||||
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
|
||||
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
|
||||
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(claims.get('id', claims.get('sub', 0)))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid user ID in OIDC claims',
|
||||
) from exc
|
||||
if not telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Missing user ID in OIDC claims',
|
||||
)
|
||||
|
||||
telegram_username = claims.get('preferred_username')
|
||||
telegram_first_name = claims.get('name', claims.get('given_name', ''))
|
||||
telegram_last_name = claims.get('family_name')
|
||||
elif request.id is not None and request.hash is not None and request.auth_date is not None:
|
||||
# Login Widget flow: validate widget hash
|
||||
widget_data = {
|
||||
@@ -506,7 +572,7 @@ async def link_telegram(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide either init_data (Mini App) or Login Widget fields (id, auth_date, hash)',
|
||||
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
|
||||
)
|
||||
|
||||
# 3. Check if telegram_id is linked to ANOTHER user
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
|
||||
|
||||
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
|
||||
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
|
||||
On save, splits the payload back into two SystemSetting keys.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
get_cached_button_styles,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
from app.utils.menu_layout_cache import (
|
||||
BUILTIN_SECTIONS,
|
||||
DEFAULT_MENU_LAYOUT,
|
||||
MENU_LAYOUT_KEY,
|
||||
VALID_CUSTOM_BUTTON_STYLES,
|
||||
get_cached_menu_layout,
|
||||
load_menu_layout_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
|
||||
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MAX_ROWS = 20
|
||||
MAX_BUTTONS_PER_ROW = 3
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^https?://')
|
||||
|
||||
|
||||
# ---- Schemas -----------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonConfig(BaseModel):
|
||||
"""Configuration for a single button (built-in or custom URL)."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
type: Literal['builtin', 'custom']
|
||||
style: str = Field(default='primary', max_length=20)
|
||||
icon_custom_emoji_id: str = Field(default='', max_length=100)
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
|
||||
url: str | None = Field(default=None, max_length=2048)
|
||||
open_in: Literal['external', 'webapp'] = 'external'
|
||||
|
||||
|
||||
class RowConfig(BaseModel):
|
||||
"""Configuration for a single row of buttons."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
max_per_row: int = Field(default=2, ge=1, le=3)
|
||||
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
|
||||
|
||||
|
||||
class MenuConfigResponse(BaseModel):
|
||||
"""Full merged menu configuration returned to the frontend."""
|
||||
|
||||
rows: list[RowConfig]
|
||||
|
||||
|
||||
class MenuConfigUpdateRequest(BaseModel):
|
||||
"""Full menu configuration submitted by the frontend."""
|
||||
|
||||
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
|
||||
|
||||
|
||||
# ---- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Insert or update a SystemSetting without committing."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
|
||||
def _build_merged_response(
|
||||
layout: dict[str, object],
|
||||
button_styles: dict[str, dict],
|
||||
) -> MenuConfigResponse:
|
||||
"""Merge layout rows with button_styles into a unified response.
|
||||
|
||||
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
|
||||
Custom URL buttons get all config from layout's ``custom_buttons``.
|
||||
"""
|
||||
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
|
||||
|
||||
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
|
||||
row_keys = sorted(
|
||||
(k for k in layout if k.startswith('row_')),
|
||||
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
|
||||
)
|
||||
|
||||
rows: list[RowConfig] = []
|
||||
for row_key in row_keys:
|
||||
row_data = layout[row_key]
|
||||
if not isinstance(row_data, dict):
|
||||
continue
|
||||
|
||||
raw_buttons: list[str] = row_data.get('buttons', [])
|
||||
max_per_row: int = row_data.get('max_per_row', 2)
|
||||
row_id: str = row_data.get('id', row_key)
|
||||
|
||||
merged_buttons: list[ButtonConfig] = []
|
||||
for btn_id in raw_buttons:
|
||||
if btn_id in BUILTIN_SECTIONS:
|
||||
# Built-in: pull style data from button_styles cache
|
||||
style_cfg = button_styles.get(btn_id, {})
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='builtin',
|
||||
style=style_cfg.get('style', 'primary'),
|
||||
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
|
||||
enabled=style_cfg.get('enabled', True),
|
||||
labels=style_cfg.get('labels', {}),
|
||||
),
|
||||
)
|
||||
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
|
||||
# Custom URL button: pull config from layout's custom_buttons
|
||||
cb = custom_buttons[btn_id]
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='custom',
|
||||
style=cb.get('style', 'primary'),
|
||||
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
|
||||
enabled=cb.get('enabled', True),
|
||||
labels=cb.get('labels', {}),
|
||||
url=cb.get('url'),
|
||||
open_in=cb.get('open_in', 'external'),
|
||||
),
|
||||
)
|
||||
|
||||
rows.append(
|
||||
RowConfig(
|
||||
id=row_id,
|
||||
max_per_row=max_per_row,
|
||||
buttons=merged_buttons,
|
||||
),
|
||||
)
|
||||
|
||||
return MenuConfigResponse(rows=rows)
|
||||
|
||||
|
||||
def _split_update(
|
||||
rows: list[RowConfig],
|
||||
) -> tuple[dict[str, object], dict[str, dict]]:
|
||||
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
|
||||
|
||||
Returns:
|
||||
(layout_data, button_styles_updates)
|
||||
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
|
||||
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
|
||||
for built-in sections only
|
||||
"""
|
||||
layout_data: dict[str, object] = {}
|
||||
custom_buttons: dict[str, dict] = {}
|
||||
button_styles_updates: dict[str, dict] = {}
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
row_key = f'row_{idx}'
|
||||
button_ids: list[str] = []
|
||||
|
||||
for btn in row.buttons:
|
||||
button_ids.append(btn.id)
|
||||
|
||||
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
|
||||
button_styles_updates[btn.id] = {
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
}
|
||||
elif btn.type == 'custom' and btn.id.startswith('custom_'):
|
||||
custom_buttons[btn.id] = {
|
||||
'id': btn.id,
|
||||
'url': btn.url or '',
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
'open_in': btn.open_in,
|
||||
}
|
||||
|
||||
layout_data[row_key] = {
|
||||
'id': row.id or row_key,
|
||||
'buttons': button_ids,
|
||||
'max_per_row': row.max_per_row,
|
||||
}
|
||||
|
||||
layout_data['custom_buttons'] = custom_buttons
|
||||
return layout_data, button_styles_updates
|
||||
|
||||
|
||||
def _validate_update_payload(rows: list[RowConfig]) -> None:
|
||||
"""Validate the full update payload. Raises HTTPException on failure."""
|
||||
if len(rows) > MAX_ROWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
|
||||
)
|
||||
|
||||
# Check for duplicate button IDs across all rows
|
||||
seen_ids: set[str] = set()
|
||||
for row in rows:
|
||||
for btn in row.buttons:
|
||||
if btn.id in seen_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
|
||||
)
|
||||
seen_ids.add(btn.id)
|
||||
|
||||
for row in rows:
|
||||
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
|
||||
)
|
||||
|
||||
for btn in row.buttons:
|
||||
# Validate button type consistency
|
||||
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown built-in section: "{btn.id}".',
|
||||
)
|
||||
|
||||
if btn.type == 'custom' and not btn.id.startswith('custom_'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button id must start with "custom_": "{btn.id}".',
|
||||
)
|
||||
|
||||
# Validate URL for custom buttons
|
||||
if btn.type == 'custom':
|
||||
if not btn.url or not URL_PATTERN.match(btn.url):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
|
||||
)
|
||||
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
|
||||
)
|
||||
|
||||
# Validate style
|
||||
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
|
||||
if btn.style not in all_allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(sorted(all_allowed))}.',
|
||||
)
|
||||
|
||||
# Validate labels
|
||||
for locale_key, label_val in btn.labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}.',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
if len(label_val.strip()) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
|
||||
f'exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=MenuConfigResponse)
|
||||
async def get_menu_layout(
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Return merged menu layout config (rows + button styles). Admin only."""
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.put('', response_model=MenuConfigResponse)
|
||||
async def update_menu_layout(
|
||||
payload: MenuConfigUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
|
||||
_validate_update_payload(payload.rows)
|
||||
|
||||
layout_data, button_styles_updates = _split_update(payload.rows)
|
||||
|
||||
# Save layout to CABINET_MENU_LAYOUT (without committing)
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
|
||||
|
||||
# Merge button styles updates with existing styles (don't overwrite sections not in request)
|
||||
if button_styles_updates:
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current_styles: dict[str, dict] = {}
|
||||
if raw:
|
||||
try:
|
||||
current_styles = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current_styles = {}
|
||||
|
||||
for section, updates in button_styles_updates.items():
|
||||
current_styles[section] = updates
|
||||
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated menu layout',
|
||||
telegram_id=admin.telegram_id,
|
||||
rows_count=len(payload.rows),
|
||||
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
|
||||
)
|
||||
|
||||
# Return merged response from fresh caches
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=MenuConfigResponse)
|
||||
async def reset_menu_layout(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset menu layout AND button styles to defaults. Admin only."""
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
@@ -390,7 +390,7 @@ async def create_topup(
|
||||
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -571,7 +571,6 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
if option not in {'card', 'sbp'}:
|
||||
option = 'sbp'
|
||||
provider_method = 'card' if option == 'card' else 'sbp'
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
@@ -580,7 +579,6 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=provider_method,
|
||||
)
|
||||
|
||||
if result:
|
||||
|
||||
@@ -39,6 +39,7 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
|
||||
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
|
||||
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
|
||||
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
|
||||
@@ -276,6 +277,18 @@ class LiteModeEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class GiftEnabledResponse(BaseModel):
|
||||
"""Gift feature enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class GiftEnabledUpdate(BaseModel):
|
||||
"""Request to update gift feature setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -983,3 +996,30 @@ async def update_lite_mode_enabled(
|
||||
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Gift Feature Routes ============
|
||||
|
||||
|
||||
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def get_gift_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift feature enabled setting. Public endpoint."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
enabled = value.lower() == 'true'
|
||||
return GiftEnabledResponse(enabled=enabled)
|
||||
return GiftEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def update_gift_enabled(
|
||||
payload: GiftEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update gift feature enabled setting. Admin only."""
|
||||
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
|
||||
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
return GiftEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Gift subscription routes for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import get_purchase_by_token
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, PaymentMethod, Tariff, TransactionType, User
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
create_purchase,
|
||||
fulfill_purchase,
|
||||
)
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.utils.cache import RateLimitCache
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.gift import (
|
||||
GiftConfigPaymentMethod,
|
||||
GiftConfigResponse,
|
||||
GiftConfigSubOption,
|
||||
GiftConfigTariff,
|
||||
GiftConfigTariffPeriod,
|
||||
GiftPurchaseRequest,
|
||||
GiftPurchaseResponse,
|
||||
GiftPurchaseStatusResponse,
|
||||
PendingGiftResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
|
||||
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
async def _is_gift_enabled(db: AsyncSession) -> bool:
|
||||
"""Check if the gift feature is enabled via system settings."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
return value.lower() == 'true'
|
||||
return False
|
||||
|
||||
|
||||
@router.get('/config', response_model=GiftConfigResponse)
|
||||
async def get_gift_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift subscription configuration: tariffs, payment methods, balance."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
return GiftConfigResponse(
|
||||
is_enabled=False,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
for tariff in tariffs_db:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
periods: list[GiftConfigTariffPeriod] = []
|
||||
for days in period_days_list:
|
||||
price = tariff.get_price_for_period(days)
|
||||
if price is None:
|
||||
continue
|
||||
periods.append(
|
||||
GiftConfigTariffPeriod(
|
||||
days=days,
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
)
|
||||
)
|
||||
if not periods:
|
||||
continue
|
||||
tariffs.append(
|
||||
GiftConfigTariff(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
periods=periods,
|
||||
)
|
||||
)
|
||||
|
||||
# Load payment methods available for this user
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user)
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
for method_data in enabled_methods:
|
||||
sub_options = None
|
||||
raw_options = method_data.get('options')
|
||||
if raw_options:
|
||||
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
|
||||
payment_methods.append(
|
||||
GiftConfigPaymentMethod(
|
||||
method_id=method_data['id'],
|
||||
display_name=method_data['name'],
|
||||
min_amount_kopeks=method_data.get('min_amount_kopeks'),
|
||||
max_amount_kopeks=method_data.get('max_amount_kopeks'),
|
||||
sub_options=sub_options,
|
||||
)
|
||||
)
|
||||
|
||||
return GiftConfigResponse(
|
||||
is_enabled=True,
|
||||
tariffs=tariffs,
|
||||
payment_methods=payment_methods,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/purchase', response_model=GiftPurchaseResponse)
|
||||
async def create_gift_purchase(
|
||||
body: GiftPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a gift subscription purchase from the cabinet."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Gift feature is not enabled',
|
||||
)
|
||||
|
||||
# Rate limit: 5 gift purchases per 60 seconds per user
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# Check if user has purchase restrictions
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Purchases are restricted for this account',
|
||||
)
|
||||
|
||||
# Validate recipient format
|
||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid email format',
|
||||
)
|
||||
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid Telegram username format',
|
||||
)
|
||||
|
||||
# Prevent self-gift
|
||||
if body.recipient_type == 'telegram':
|
||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
||||
if user.username and user.username.lower() == normalized_recipient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
elif body.recipient_type == 'email':
|
||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
price_kopeks = tariff.get_price_for_period(body.period_days)
|
||||
if price_kopeks is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
buyer_contact_type = 'email'
|
||||
buyer_contact_value = user.email
|
||||
elif user.username:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'@{user.username}'
|
||||
else:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'id:{user.telegram_id or user.id}'
|
||||
|
||||
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
||||
# Placed after validation gates to prevent zero-cost enumeration.
|
||||
# The resolved ID is passed to fulfill_purchase to avoid a duplicate API call.
|
||||
recipient_warning: str | None = None
|
||||
pre_resolved_telegram_id: int | None = None
|
||||
if body.recipient_type == 'telegram':
|
||||
tg_username = body.recipient_value.lstrip('@')
|
||||
normalized_username = tg_username.lower()
|
||||
|
||||
# 1) Check local DB — user may already be registered in the bot
|
||||
db_result = await db.execute(
|
||||
select(User.telegram_id).where(
|
||||
func.lower(User.username) == normalized_username,
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
)
|
||||
db_telegram_id = db_result.scalar_one_or_none()
|
||||
|
||||
if db_telegram_id is not None:
|
||||
pre_resolved_telegram_id = db_telegram_id
|
||||
else:
|
||||
# 2) Fall back to Bot API (works for public usernames the bot has seen)
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
|
||||
pre_resolved_telegram_id = chat.id
|
||||
except Exception:
|
||||
recipient_warning = 'telegram_unresolvable'
|
||||
logger.warning(
|
||||
'Telegram username not resolvable for gift',
|
||||
username=tg_username,
|
||||
buyer_id=user.id,
|
||||
)
|
||||
|
||||
# Gateway mode: create payment via external provider
|
||||
if body.payment_mode == 'gateway':
|
||||
if not body.payment_method:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='payment_method is required for gateway mode',
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=True,
|
||||
gift_recipient_type=body.recipient_type,
|
||||
gift_recipient_value=body.recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning so it survives the gateway redirect
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Build return URL for after payment
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
return_url = f'{cabinet_base}/gift/result?token={purchase.token}'
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider is unavailable, please try again later',
|
||||
)
|
||||
|
||||
payment_url = payment_result.get('payment_url')
|
||||
if not payment_url:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Gift payment created but no payment_url returned',
|
||||
purchase_token=purchase.token[:5],
|
||||
provider=payment_result.get('provider'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='created',
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Create purchase record
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method='balance',
|
||||
is_gift=True,
|
||||
gift_recipient_type=body.recipient_type,
|
||||
gift_recipient_value=body.recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning on purchase record
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Subtract balance
|
||||
balance_ok = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Create transaction record
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Mark purchase as paid
|
||||
purchase.status = GuestPurchaseStatus.PAID.value
|
||||
purchase.paid_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=price_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d) -> {body.recipient_value}',
|
||||
)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
# Fulfill the purchase (find/create recipient user, create subscription, notify)
|
||||
try:
|
||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='ok',
|
||||
purchase_token=purchase_token,
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending', response_model=list[PendingGiftResponse])
|
||||
async def get_pending_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get pending gift purchases that the current user can activate."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
pending: list[PendingGiftResponse] = []
|
||||
for p in purchases:
|
||||
# Determine sender display name
|
||||
sender_display = None
|
||||
if p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
pending.append(
|
||||
PendingGiftResponse(
|
||||
token=p.token,
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
gift_message=p.gift_message,
|
||||
sender_display=sender_display,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return pending
|
||||
|
||||
|
||||
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
|
||||
async def get_gift_purchase_status(
|
||||
token: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a cabinet gift purchase."""
|
||||
purchase = await get_purchase_by_token(db, token)
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
# Uniform 404 prevents token existence oracle
|
||||
if purchase.buyer_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
tariff_name = purchase.tariff.name if purchase.tariff else None
|
||||
|
||||
recipient_contact_value = None
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = purchase.gift_recipient_value
|
||||
|
||||
return GiftPurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
is_gift=True,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
gift_message=purchase.gift_message,
|
||||
tariff_name=tariff_name,
|
||||
period_days=purchase.period_days,
|
||||
warning=purchase.recipient_warning,
|
||||
)
|
||||
+16
-11
@@ -204,15 +204,11 @@ async def get_loyalty_tiers(
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
|
||||
total_spent_rubles = total_spent_kopeks / 100
|
||||
|
||||
# Get user's current promo group
|
||||
await db.refresh(user, ['promo_group', 'user_promo_groups'])
|
||||
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
current_tier_name = current_promo_group.name if current_promo_group else None
|
||||
|
||||
# Get all auto-assign promo groups (sorted by threshold ascending)
|
||||
auto_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
tiers: list[LoyaltyTierInfo] = []
|
||||
current_tier_name: str | None = None
|
||||
next_tier_name: str | None = None
|
||||
next_tier_threshold: float | None = None
|
||||
|
||||
@@ -220,7 +216,15 @@ async def get_loyalty_tiers(
|
||||
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
|
||||
threshold_rubles = threshold_kopeks / 100
|
||||
is_achieved = total_spent_kopeks >= threshold_kopeks
|
||||
is_current = current_promo_group and current_promo_group.id == group.id
|
||||
|
||||
# Track highest achieved tier as "current" (by spending, not by assignment)
|
||||
if is_achieved:
|
||||
current_tier_name = group.name
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
@@ -241,15 +245,16 @@ async def get_loyalty_tiers(
|
||||
traffic_discount_percent=group.traffic_discount_percent or 0,
|
||||
device_discount_percent=group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
is_current=is_current,
|
||||
is_current=False,
|
||||
is_achieved=is_achieved,
|
||||
)
|
||||
)
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
# Mark only the highest achieved tier as "current"
|
||||
for tier in reversed(tiers):
|
||||
if tier.is_achieved:
|
||||
tier.is_current = True
|
||||
break
|
||||
|
||||
# Calculate progress to next tier
|
||||
progress_percent = 0.0
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Schemas for cabinet gift subscription feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class GiftConfigSubOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class GiftConfigTariffPeriod(BaseModel):
|
||||
days: int
|
||||
price_kopeks: int
|
||||
price_label: str
|
||||
original_price_kopeks: int | None = None
|
||||
discount_percent: int | None = None
|
||||
|
||||
|
||||
class GiftConfigTariff(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
periods: list[GiftConfigTariffPeriod]
|
||||
|
||||
|
||||
class GiftConfigPaymentMethod(BaseModel):
|
||||
method_id: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
icon_url: str | None = None
|
||||
min_amount_kopeks: int | None = None
|
||||
max_amount_kopeks: int | None = None
|
||||
sub_options: list[GiftConfigSubOption] | None = None
|
||||
|
||||
|
||||
class GiftConfigResponse(BaseModel):
|
||||
is_enabled: bool
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
balance_kopeks: int = 0
|
||||
currency_symbol: str = '\u20bd'
|
||||
|
||||
|
||||
class GiftPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(gt=0)
|
||||
period_days: int = Field(gt=0, le=3650)
|
||||
recipient_type: str = Field(pattern=r'^(email|telegram)$')
|
||||
recipient_value: str = Field(min_length=1, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
|
||||
payment_method: str | None = Field(default=None, max_length=50)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_payment(self) -> GiftPurchaseRequest:
|
||||
if self.payment_mode == 'gateway' and not self.payment_method:
|
||||
raise ValueError('payment_method is required for gateway mode')
|
||||
return self
|
||||
|
||||
|
||||
class GiftPurchaseResponse(BaseModel):
|
||||
status: str
|
||||
purchase_token: str
|
||||
payment_url: str | None = None
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class GiftPurchaseStatusResponse(BaseModel):
|
||||
status: str
|
||||
is_gift: bool = True
|
||||
recipient_contact_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
tariff_name: str | None = None
|
||||
period_days: int | None = None
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class PendingGiftResponse(BaseModel):
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
gift_message: str | None = None
|
||||
sender_display: str | None = None
|
||||
created_at: datetime | None = None
|
||||
@@ -97,9 +97,9 @@ async def create_promo_group(
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None
|
||||
)
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
value = max(0, auto_assign_total_spent_kopeks)
|
||||
auto_assign_total_spent_kopeks = value if value > 0 else None
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
@@ -168,7 +168,8 @@ async def update_promo_group(
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
value = max(0, auto_assign_total_spent_kopeks)
|
||||
group.auto_assign_total_spent_kopeks = value if value > 0 else None
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
|
||||
@@ -41,10 +41,12 @@ async def create_transaction(
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> Transaction:
|
||||
# SUBSCRIPTION_PAYMENT — always store as negative (debit from user balance)
|
||||
# SUBSCRIPTION_PAYMENT / GIFT_PAYMENT — always store as negative (debit from user balance)
|
||||
# Keep original for downstream consumers (events, contests)
|
||||
stored_amount = (
|
||||
-amount_kopeks if type == TransactionType.SUBSCRIPTION_PAYMENT and amount_kopeks > 0 else amount_kopeks
|
||||
-amount_kopeks
|
||||
if type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT) and amount_kopeks > 0
|
||||
else amount_kopeks
|
||||
)
|
||||
|
||||
transaction = Transaction(
|
||||
|
||||
+10
-1
@@ -133,6 +133,7 @@ class TransactionType(Enum):
|
||||
REFUND = 'refund'
|
||||
REFERRAL_REWARD = 'referral_reward'
|
||||
POLL_REWARD = 'poll_reward'
|
||||
GIFT_PAYMENT = 'gift_payment'
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
@@ -3077,6 +3078,10 @@ class GuestPurchase(Base):
|
||||
Index('ix_guest_purchases_status', 'status'),
|
||||
Index('ix_guest_purchases_contact', 'contact_type', 'contact_value'),
|
||||
Index('ix_guest_purchases_landing_status_paid', 'landing_id', 'status', 'paid_at'),
|
||||
Index('ix_guest_purchases_source', 'source'),
|
||||
Index('ix_guest_purchases_user_gift_status', 'user_id', 'is_gift', 'status'),
|
||||
Index('ix_guest_purchases_status_paid_at', 'status', 'paid_at'),
|
||||
Index('ix_guest_purchases_buyer_user_id', 'buyer_user_id'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -3085,6 +3090,8 @@ class GuestPurchase(Base):
|
||||
contact_type = Column(String(20), nullable=False) # 'email' or 'telegram'
|
||||
contact_value = Column(String(255), nullable=False)
|
||||
is_gift = Column(Boolean, nullable=False, default=False)
|
||||
source = Column(String(20), nullable=False, default='landing', server_default='landing') # 'landing' or 'cabinet'
|
||||
buyer_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
gift_recipient_type = Column(String(20), nullable=True)
|
||||
gift_recipient_value = Column(String(255), nullable=True)
|
||||
gift_message = Column(Text, nullable=True)
|
||||
@@ -3103,10 +3110,12 @@ class GuestPurchase(Base):
|
||||
delivered_at = Column(AwareDateTime(), nullable=True)
|
||||
cabinet_password = Column(Text, nullable=True)
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
user = relationship('User', lazy='selectin')
|
||||
user = relationship('User', foreign_keys=[user_id], lazy='selectin')
|
||||
buyer = relationship('User', foreign_keys=[buyer_user_id], lazy='selectin')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
token_prefix = self.token[:5] if self.token else '?'
|
||||
|
||||
+142
-69
@@ -332,6 +332,32 @@ def get_language_selection_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _get_balance_text(cached_styles: dict, language: str, texts, balance_kopeks: int) -> str:
|
||||
"""Build balance button text with formatting."""
|
||||
bal_cfg = cached_styles.get('balance', {})
|
||||
safe_balance = balance_kopeks or 0
|
||||
|
||||
# Custom label overrides the whole text including balance amount
|
||||
custom_bal = bal_cfg.get('labels', {}).get(language, '')
|
||||
if custom_bal:
|
||||
return custom_bal
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
return texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
return texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
|
||||
balance=texts.format_price(safe_balance),
|
||||
)
|
||||
|
||||
|
||||
def _is_support_enabled() -> bool:
|
||||
"""Check if support menu is enabled."""
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
return settings.SUPPORT_MENU_ENABLED
|
||||
|
||||
|
||||
def _build_cabinet_main_menu_keyboard(
|
||||
language: str,
|
||||
texts,
|
||||
@@ -342,10 +368,12 @@ def _build_cabinet_main_menu_keyboard(
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build the main-menu keyboard for Cabinet mode.
|
||||
|
||||
Each button opens the corresponding section of the cabinet frontend
|
||||
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
|
||||
Row layout and button arrangement are driven by the cached menu layout
|
||||
(``get_cached_menu_layout``). Each row specifies which buttons it contains
|
||||
and how many fit per keyboard row (``max_per_row``).
|
||||
"""
|
||||
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
from app.utils.menu_layout_cache import get_cached_menu_layout
|
||||
from app.utils.miniapp_buttons import (
|
||||
CALLBACK_TO_CABINET_STYLE,
|
||||
_resolve_style,
|
||||
@@ -354,6 +382,8 @@ def _build_cabinet_main_menu_keyboard(
|
||||
|
||||
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
|
||||
cached_styles = get_cached_button_styles()
|
||||
layout = get_cached_menu_layout()
|
||||
custom_buttons_cfg: dict[str, dict] = layout.get('custom_buttons', {})
|
||||
|
||||
def _cabinet_button(
|
||||
text: str,
|
||||
@@ -385,84 +415,127 @@ def _build_cabinet_main_menu_keyboard(
|
||||
)
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
|
||||
|
||||
# -- Primary action row: Cabinet home --
|
||||
home_cfg = cached_styles.get('home', {})
|
||||
if home_cfg.get('enabled', True):
|
||||
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
|
||||
]
|
||||
else:
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
# -- Collect row definitions sorted by row_N key --
|
||||
row_keys = sorted(
|
||||
(k for k in layout if k.startswith('row_')),
|
||||
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
|
||||
)
|
||||
|
||||
# -- Section buttons as paired rows --
|
||||
paired: list[InlineKeyboardButton] = []
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
# Subscription (green — main action)
|
||||
sub_cfg = cached_styles.get('subscription', {})
|
||||
if sub_cfg.get('enabled', True):
|
||||
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
|
||||
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
|
||||
for row_key in row_keys:
|
||||
row_def = layout[row_key]
|
||||
btn_ids: list[str] = row_def.get('buttons', [])
|
||||
max_per_row: int = row_def.get('max_per_row', 1)
|
||||
row_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
# Balance
|
||||
bal_cfg = cached_styles.get('balance', {})
|
||||
if bal_cfg.get('enabled', True):
|
||||
safe_balance = balance_kopeks or 0
|
||||
# Custom label overrides the whole text including balance amount
|
||||
custom_bal = bal_cfg.get('labels', {}).get(language, '')
|
||||
if custom_bal:
|
||||
balance_text = custom_bal
|
||||
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
else:
|
||||
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
|
||||
balance=texts.format_price(safe_balance),
|
||||
)
|
||||
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
for btn_id in btn_ids:
|
||||
# --- Custom URL buttons ---
|
||||
if btn_id.startswith('custom_'):
|
||||
custom_cfg = custom_buttons_cfg.get(btn_id)
|
||||
if not custom_cfg or not custom_cfg.get('url') or not custom_cfg.get('enabled', True):
|
||||
continue
|
||||
custom_text = (
|
||||
custom_cfg.get('labels', {}).get(language, '')
|
||||
or custom_cfg.get('labels', {}).get('ru', '')
|
||||
or 'Link'
|
||||
)
|
||||
resolved_style = _resolve_style(custom_cfg.get('style'))
|
||||
resolved_emoji = custom_cfg.get('icon_custom_emoji_id') or None
|
||||
open_in = custom_cfg.get('open_in', 'external')
|
||||
link_kwarg = (
|
||||
{'web_app': types.WebAppInfo(url=custom_cfg['url'])}
|
||||
if open_in == 'webapp'
|
||||
else {'url': custom_cfg['url']}
|
||||
)
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=custom_text,
|
||||
**link_kwarg,
|
||||
style=resolved_style,
|
||||
icon_custom_emoji_id=resolved_emoji,
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
# Referrals (if enabled)
|
||||
ref_cfg = cached_styles.get('referral', {})
|
||||
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
|
||||
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
|
||||
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
|
||||
# --- Built-in buttons ---
|
||||
section_cfg = cached_styles.get(btn_id, {})
|
||||
|
||||
# Support
|
||||
support_enabled = False
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
match btn_id:
|
||||
case 'home':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
home_text = section_cfg.get('labels', {}).get(language, '') or texts.t(
|
||||
'MENU_PROFILE', '👤 Личный кабинет'
|
||||
)
|
||||
row_buttons.append(_cabinet_button(home_text, '/', 'menu_profile_unavailable'))
|
||||
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
case 'subscription':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
sub_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
|
||||
row_buttons.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
|
||||
|
||||
sup_cfg = cached_styles.get('support', {})
|
||||
if support_enabled and sup_cfg.get('enabled', True):
|
||||
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
|
||||
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
|
||||
case 'balance':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
balance_text = _get_balance_text(cached_styles, language, texts, balance_kopeks)
|
||||
row_buttons.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
|
||||
# Info
|
||||
info_cfg = cached_styles.get('info', {})
|
||||
if info_cfg.get('enabled', True):
|
||||
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', 'ℹ️ Инфо')
|
||||
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
|
||||
case 'referral':
|
||||
if not settings.is_referral_program_enabled():
|
||||
continue
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
ref_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
|
||||
row_buttons.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
|
||||
|
||||
# Language selection (stays as callback — not a cabinet section)
|
||||
if settings.is_language_selection_enabled():
|
||||
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
|
||||
case 'support':
|
||||
if not _is_support_enabled():
|
||||
continue
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
sup_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
|
||||
row_buttons.append(_cabinet_button(sup_text, '/support', 'menu_support'))
|
||||
|
||||
# Lay out in pairs
|
||||
for i in range(0, len(paired), 2):
|
||||
keyboard_rows.append(paired[i : i + 2])
|
||||
case 'info':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
info_text = section_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', 'ℹ️ Инфо')
|
||||
row_buttons.append(_cabinet_button(info_text, '/info', 'menu_info'))
|
||||
|
||||
# Admin / Moderator
|
||||
admin_cfg = cached_styles.get('admin', {})
|
||||
if is_admin:
|
||||
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
|
||||
if admin_cfg.get('enabled', True):
|
||||
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
|
||||
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
|
||||
keyboard_rows.append(admin_buttons)
|
||||
elif is_moderator:
|
||||
case 'language':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
if not settings.is_language_selection_enabled():
|
||||
continue
|
||||
lang_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_LANGUAGE
|
||||
resolved_lang_emoji = section_cfg.get('icon_custom_emoji_id') or None
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=lang_text,
|
||||
callback_data='menu_language',
|
||||
icon_custom_emoji_id=resolved_lang_emoji,
|
||||
)
|
||||
)
|
||||
|
||||
case 'admin':
|
||||
if not is_admin:
|
||||
continue
|
||||
admin_row = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
|
||||
if section_cfg.get('enabled', True):
|
||||
admin_web_text = section_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
|
||||
admin_row.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
|
||||
keyboard_rows.append(admin_row)
|
||||
continue # bypass max_per_row chunking
|
||||
|
||||
# Split collected buttons into keyboard rows respecting max_per_row
|
||||
if row_buttons:
|
||||
for i in range(0, len(row_buttons), max_per_row):
|
||||
keyboard_rows.append(row_buttons[i : i + max_per_row])
|
||||
|
||||
# -- Moderator panel (only when not admin — admin row handled above) --
|
||||
if is_moderator and not is_admin:
|
||||
keyboard_rows.append([InlineKeyboardButton(text='🧑⚖️ Модерация', callback_data='moderator_panel')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -115,7 +115,7 @@ async def validate_and_calculate(
|
||||
|
||||
async def create_purchase(
|
||||
db: AsyncSession,
|
||||
landing: LandingPage,
|
||||
landing: LandingPage | None,
|
||||
tariff: Tariff,
|
||||
period_days: int,
|
||||
amount_kopeks: int,
|
||||
@@ -126,13 +126,15 @@ async def create_purchase(
|
||||
gift_recipient_type: str | None = None,
|
||||
gift_recipient_value: str | None = None,
|
||||
gift_message: str | None = None,
|
||||
source: str = 'landing',
|
||||
buyer_user_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> GuestPurchase:
|
||||
"""Create a guest purchase record."""
|
||||
purchase = await create_guest_purchase(
|
||||
db,
|
||||
commit=commit,
|
||||
landing_id=landing.id,
|
||||
landing_id=landing.id if landing else None,
|
||||
tariff_id=tariff.id,
|
||||
period_days=period_days,
|
||||
amount_kopeks=amount_kopeks,
|
||||
@@ -143,6 +145,8 @@ async def create_purchase(
|
||||
gift_recipient_type=gift_recipient_type,
|
||||
gift_recipient_value=gift_recipient_value,
|
||||
gift_message=gift_message,
|
||||
source=source,
|
||||
buyer_user_id=buyer_user_id,
|
||||
status=GuestPurchaseStatus.PENDING.value,
|
||||
)
|
||||
|
||||
@@ -150,23 +154,32 @@ async def create_purchase(
|
||||
'Guest purchase created',
|
||||
purchase_id=purchase.id,
|
||||
token_prefix=purchase.token[:5],
|
||||
landing_slug=landing.slug,
|
||||
landing_slug=landing.slug if landing else None,
|
||||
tariff_id=tariff.id,
|
||||
period_days=period_days,
|
||||
amount_kopeks=amount_kopeks,
|
||||
is_gift=is_gift,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return purchase
|
||||
|
||||
|
||||
async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase | None:
|
||||
async def fulfill_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
pre_resolved_telegram_id: int | None = None,
|
||||
) -> GuestPurchase | None:
|
||||
"""After payment: find/create user, create subscription, send notification.
|
||||
|
||||
Uses SELECT ... FOR UPDATE to prevent concurrent fulfillment of the same purchase.
|
||||
The PENDING_ACTIVATION path commits early and returns (terminal for this call).
|
||||
The DELIVERED path commits after subscription creation.
|
||||
Returns the updated purchase or None if not found.
|
||||
|
||||
Args:
|
||||
pre_resolved_telegram_id: If caller already resolved the recipient's telegram_id
|
||||
via Bot API, pass it here to avoid a duplicate API call.
|
||||
"""
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
purchase = result.scalars().first()
|
||||
@@ -188,7 +201,13 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
|
||||
recipient_type, recipient_value = _get_recipient_contact(purchase)
|
||||
|
||||
# Find or create user for the recipient (no commit — stays within our transaction)
|
||||
user, is_new_account = await _find_or_create_user(db, recipient_type, recipient_value, purchase=purchase)
|
||||
user, is_new_account = await _find_or_create_user(
|
||||
db,
|
||||
recipient_type,
|
||||
recipient_value,
|
||||
purchase=purchase,
|
||||
pre_resolved_telegram_id=pre_resolved_telegram_id,
|
||||
)
|
||||
|
||||
# Load tariff early — needed for both PENDING_ACTIVATION and DELIVERED paths
|
||||
tariff = await get_tariff_by_id(db, purchase.tariff_id)
|
||||
@@ -359,6 +378,7 @@ async def _find_or_create_user(
|
||||
contact_type: Literal['email', 'telegram'],
|
||||
contact_value: str,
|
||||
purchase: GuestPurchase | None = None,
|
||||
pre_resolved_telegram_id: int | None = None,
|
||||
) -> tuple[User, bool]:
|
||||
"""Find user by email/telegram username or create a new one.
|
||||
|
||||
@@ -367,6 +387,10 @@ async def _find_or_create_user(
|
||||
|
||||
Returns (user, is_new_account) where is_new_account means a new password was generated.
|
||||
|
||||
Args:
|
||||
pre_resolved_telegram_id: If caller already resolved the telegram_id via Bot API,
|
||||
pass it here to skip the redundant API call.
|
||||
|
||||
NOTE: Does NOT commit — caller is responsible for committing the transaction.
|
||||
This preserves FOR UPDATE locks held by the caller.
|
||||
"""
|
||||
@@ -438,22 +462,23 @@ async def _find_or_create_user(
|
||||
normalized = username.lower()
|
||||
|
||||
# Try to resolve telegram_id via Bot API (works if user has interacted with the bot)
|
||||
resolved_telegram_id: int | None = None
|
||||
try:
|
||||
from aiogram import Bot
|
||||
resolved_telegram_id: int | None = pre_resolved_telegram_id
|
||||
if resolved_telegram_id is None:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
chat = await asyncio.wait_for(
|
||||
bot.get_chat(chat_id=f'@{username}'),
|
||||
timeout=5.0,
|
||||
)
|
||||
resolved_telegram_id = chat.id
|
||||
# Use the canonical username from Telegram if available
|
||||
if chat.username:
|
||||
username = chat.username
|
||||
normalized = username.lower()
|
||||
except Exception as exc:
|
||||
logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
|
||||
async with Bot(token=settings.BOT_TOKEN) as bot:
|
||||
chat = await asyncio.wait_for(
|
||||
bot.get_chat(chat_id=f'@{username}'),
|
||||
timeout=5.0,
|
||||
)
|
||||
resolved_telegram_id = chat.id
|
||||
# Use the canonical username from Telegram if available
|
||||
if chat.username:
|
||||
username = chat.username
|
||||
normalized = username.lower()
|
||||
except Exception as exc:
|
||||
logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
|
||||
|
||||
# Search by telegram_id first (most reliable), then by username (case-insensitive)
|
||||
user = None
|
||||
@@ -709,8 +734,8 @@ async def send_guest_notification(
|
||||
notification_type=notification_type.value,
|
||||
)
|
||||
|
||||
# Send separate credentials email for new/upgraded accounts (non-gift self-purchases)
|
||||
if purchase.cabinet_password and not purchase.is_gift:
|
||||
# Send separate credentials email for new accounts (self-purchases and gifts)
|
||||
if purchase.cabinet_password:
|
||||
cred_template = None
|
||||
try:
|
||||
from app.cabinet.services.email_template_overrides import get_rendered_override
|
||||
@@ -724,8 +749,8 @@ async def send_guest_notification(
|
||||
'subject': cred_subject,
|
||||
'body_html': cred_body,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug('Failed to check credentials template override', e=e)
|
||||
if not cred_template:
|
||||
cred_template = templates.get_template(NotificationType.GUEST_CABINET_CREDENTIALS, language, context)
|
||||
if cred_template:
|
||||
@@ -869,3 +894,52 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
raise GuestPurchaseError('Activation failed, please try again', status_code=500)
|
||||
|
||||
return purchase
|
||||
|
||||
|
||||
async def retry_stuck_paid_purchases(
|
||||
db: AsyncSession,
|
||||
stale_minutes: int = 5,
|
||||
limit: int = 10,
|
||||
max_age_hours: int = 24,
|
||||
) -> int:
|
||||
"""Retry fulfillment for purchases stuck in PAID status.
|
||||
|
||||
Finds purchases that have been in PAID status for longer than stale_minutes
|
||||
(but not older than max_age_hours) and attempts to fulfill them in isolated
|
||||
sessions. Returns the number of successfully retried purchases.
|
||||
|
||||
Purchases older than max_age_hours are left for manual investigation.
|
||||
"""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
|
||||
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
||||
|
||||
# Collect tokens only — each retry gets its own session.
|
||||
# NULL paid_at is included via or_() as a safety net for data anomalies.
|
||||
result = await db.execute(
|
||||
select(GuestPurchase.token)
|
||||
.where(
|
||||
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
|
||||
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
|
||||
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
|
||||
)
|
||||
.order_by(GuestPurchase.paid_at.asc().nulls_first())
|
||||
.limit(limit)
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
if not tokens:
|
||||
return 0
|
||||
|
||||
retried = 0
|
||||
for token in tokens:
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_db:
|
||||
await fulfill_purchase(retry_db, token)
|
||||
retried += 1
|
||||
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
|
||||
except Exception:
|
||||
logger.exception('Failed to retry stuck purchase', token_prefix=token[:5])
|
||||
|
||||
return retried
|
||||
|
||||
@@ -226,6 +226,7 @@ class MonitoringService:
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._retry_stuck_guest_purchases(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
@@ -1679,6 +1680,16 @@ class MonitoringService:
|
||||
'Ошибка отправки уведомления о неудачном автоплатеже пользователю', telegram_id=user.telegram_id, e=e
|
||||
)
|
||||
|
||||
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
|
||||
try:
|
||||
from app.services.guest_purchase_service import retry_stuck_paid_purchases
|
||||
|
||||
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
|
||||
if retried:
|
||||
logger.info('Retried stuck guest purchases', retried=retried)
|
||||
except Exception:
|
||||
logger.error('Error retrying stuck guest purchases', exc_info=True)
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -23,6 +23,7 @@ DEFAULT_BUTTON_STYLES: dict[str, dict] = {
|
||||
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'language': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
}
|
||||
|
||||
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Lightweight in-process cache for cabinet menu row layout configuration.
|
||||
|
||||
Stores per-row button arrangement (which buttons per row, max_per_row)
|
||||
and custom URL buttons. Loaded from SystemSetting key ``CABINET_MENU_LAYOUT``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MENU_LAYOUT_KEY = 'CABINET_MENU_LAYOUT'
|
||||
|
||||
BUILTIN_SECTIONS: tuple[str, ...] = (
|
||||
'home',
|
||||
'subscription',
|
||||
'balance',
|
||||
'referral',
|
||||
'support',
|
||||
'info',
|
||||
'admin',
|
||||
'language',
|
||||
)
|
||||
|
||||
VALID_MAX_PER_ROW = frozenset({1, 2, 3})
|
||||
|
||||
# Valid Telegram Bot API style values for custom buttons.
|
||||
VALID_CUSTOM_BUTTON_STYLES = frozenset({'primary', 'success', 'danger', 'default'})
|
||||
|
||||
DEFAULT_MENU_LAYOUT: dict[str, object] = {
|
||||
'row_1': {'id': 'row_1', 'buttons': ['home'], 'max_per_row': 1},
|
||||
'row_2': {'id': 'row_2', 'buttons': ['subscription', 'balance'], 'max_per_row': 2},
|
||||
'row_3': {'id': 'row_3', 'buttons': ['referral', 'support'], 'max_per_row': 2},
|
||||
'row_4': {'id': 'row_4', 'buttons': ['info', 'language'], 'max_per_row': 2},
|
||||
'row_5': {'id': 'row_5', 'buttons': ['admin'], 'max_per_row': 1},
|
||||
'custom_buttons': {},
|
||||
}
|
||||
|
||||
# ---- Module-level cache ------------------------------------------------------
|
||||
|
||||
_cached_layout: dict[str, object] | None = None
|
||||
|
||||
|
||||
def _deep_copy_layout(source: dict[str, object]) -> dict[str, object]:
|
||||
"""Return a deep copy of layout dict via JSON round-trip."""
|
||||
return json.loads(json.dumps(source))
|
||||
|
||||
|
||||
def get_cached_menu_layout() -> dict[str, object]:
|
||||
"""Return the current layout config (DB overrides + defaults).
|
||||
|
||||
If the cache has not been loaded yet, returns defaults.
|
||||
"""
|
||||
if _cached_layout is not None:
|
||||
return _deep_copy_layout(_cached_layout)
|
||||
return _deep_copy_layout(DEFAULT_MENU_LAYOUT)
|
||||
|
||||
|
||||
def _validate_row(row_id: str, data: dict) -> dict | None:
|
||||
"""Validate and sanitize a single row entry. Returns cleaned dict or None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
buttons = data.get('buttons')
|
||||
if not isinstance(buttons, list) or not buttons:
|
||||
return None
|
||||
|
||||
# Allow known built-in section names AND custom_* button IDs in rows
|
||||
clean_buttons = [b for b in buttons if isinstance(b, str) and (b in BUILTIN_SECTIONS or b.startswith('custom_'))]
|
||||
if not clean_buttons:
|
||||
return None
|
||||
|
||||
max_per_row = data.get('max_per_row')
|
||||
if not isinstance(max_per_row, int) or max_per_row not in VALID_MAX_PER_ROW:
|
||||
max_per_row = 1
|
||||
|
||||
return {'id': row_id, 'buttons': clean_buttons, 'max_per_row': max_per_row}
|
||||
|
||||
|
||||
def _validate_custom_button(btn_id: str, data: dict) -> dict | None:
|
||||
"""Validate and sanitize a single custom URL button. Returns cleaned dict or None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if not btn_id.startswith('custom_'):
|
||||
return None
|
||||
|
||||
url = data.get('url')
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return None
|
||||
|
||||
style = data.get('style', 'primary')
|
||||
if style not in VALID_CUSTOM_BUTTON_STYLES:
|
||||
style = 'primary'
|
||||
|
||||
labels = data.get('labels')
|
||||
if not isinstance(labels, dict):
|
||||
labels = {}
|
||||
clean_labels = {k: v for k, v in labels.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
icon_custom_emoji_id = data.get('icon_custom_emoji_id', '')
|
||||
if not isinstance(icon_custom_emoji_id, str):
|
||||
icon_custom_emoji_id = ''
|
||||
|
||||
enabled = data.get('enabled', True)
|
||||
if not isinstance(enabled, bool):
|
||||
enabled = True
|
||||
|
||||
open_in = data.get('open_in', 'external')
|
||||
if open_in not in ('external', 'webapp'):
|
||||
open_in = 'external'
|
||||
if open_in == 'webapp' and not url.strip().startswith('https://'):
|
||||
open_in = 'external'
|
||||
|
||||
return {
|
||||
'id': btn_id,
|
||||
'url': url.strip(),
|
||||
'style': style,
|
||||
'labels': clean_labels,
|
||||
'icon_custom_emoji_id': icon_custom_emoji_id,
|
||||
'enabled': enabled,
|
||||
'open_in': open_in,
|
||||
}
|
||||
|
||||
|
||||
def _validate_layout(data: dict) -> dict[str, object]:
|
||||
"""Validate and sanitize full layout data from DB.
|
||||
|
||||
Returns a clean layout dict; invalid entries are silently dropped.
|
||||
"""
|
||||
result: dict[str, object] = {}
|
||||
|
||||
for key, value in data.items():
|
||||
if key == 'custom_buttons':
|
||||
if isinstance(value, dict):
|
||||
clean_customs: dict[str, dict] = {}
|
||||
for btn_id, btn_data in value.items():
|
||||
validated = _validate_custom_button(str(btn_id), btn_data)
|
||||
if validated is not None:
|
||||
clean_customs[str(btn_id)] = validated
|
||||
result['custom_buttons'] = clean_customs
|
||||
elif key.startswith('row_'):
|
||||
validated_row = _validate_row(key, value)
|
||||
if validated_row is not None:
|
||||
result[key] = validated_row
|
||||
|
||||
# Ensure custom_buttons key always exists
|
||||
if 'custom_buttons' not in result:
|
||||
result['custom_buttons'] = {}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def load_menu_layout_cache() -> dict[str, object]:
|
||||
"""Load menu layout from DB and refresh the module cache.
|
||||
|
||||
Called at bot startup and after admin updates via the cabinet API.
|
||||
"""
|
||||
global _cached_layout
|
||||
|
||||
merged = _deep_copy_layout(DEFAULT_MENU_LAYOUT)
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
db_data: dict = json.loads(setting.value)
|
||||
if isinstance(db_data, dict):
|
||||
validated = _validate_layout(db_data)
|
||||
if validated and any(k.startswith('row_') for k in validated):
|
||||
# Replace rows and custom_buttons from DB only if at least one row exists
|
||||
merged = validated
|
||||
# Ensure custom_buttons always present
|
||||
if 'custom_buttons' not in merged:
|
||||
merged['custom_buttons'] = {}
|
||||
except Exception:
|
||||
logger.exception('Failed to load menu layout from DB, using defaults')
|
||||
|
||||
_cached_layout = merged
|
||||
logger.info('Menu layout cache loaded', rows=len([k for k in merged if k.startswith('row_')]))
|
||||
return merged
|
||||
@@ -1155,8 +1155,6 @@ async def create_payment_link(
|
||||
option = (payload.payment_option or '').strip().lower()
|
||||
if option not in {'card', 'sbp'}:
|
||||
option = 'sbp'
|
||||
provider_method = 'card' if option == 'card' else 'sbp'
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
@@ -1164,7 +1162,6 @@ async def create_payment_link(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=provider_method,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment')
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Add source and buyer_user_id columns to guest_purchases
|
||||
|
||||
Supports cabinet gift purchases by tracking purchase origin
|
||||
and linking to the authenticated buyer.
|
||||
|
||||
Revision ID: 0032
|
||||
Revises: 0031
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0032'
|
||||
down_revision: Union[str, None] = '0031'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return column in [c['name'] for c in inspector.get_columns(table)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column('guest_purchases', 'source'):
|
||||
op.add_column(
|
||||
'guest_purchases',
|
||||
sa.Column('source', sa.String(20), nullable=False, server_default='landing'),
|
||||
)
|
||||
op.create_index('ix_guest_purchases_source', 'guest_purchases', ['source'])
|
||||
|
||||
if not _has_column('guest_purchases', 'buyer_user_id'):
|
||||
op.add_column(
|
||||
'guest_purchases',
|
||||
sa.Column('buyer_user_id', sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'fk_guest_purchases_buyer_user_id',
|
||||
'guest_purchases',
|
||||
'users',
|
||||
['buyer_user_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column('guest_purchases', 'buyer_user_id'):
|
||||
op.drop_constraint('fk_guest_purchases_buyer_user_id', 'guest_purchases', type_='foreignkey')
|
||||
op.drop_column('guest_purchases', 'buyer_user_id')
|
||||
|
||||
if _has_column('guest_purchases', 'source'):
|
||||
op.drop_index('ix_guest_purchases_source', table_name='guest_purchases')
|
||||
op.drop_column('guest_purchases', 'source')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add indexes for gift pending queries and retry on guest_purchases
|
||||
|
||||
Adds three indexes:
|
||||
- (user_id, is_gift, status) for dashboard pending gifts query
|
||||
- (status, paid_at) for retry_stuck_paid_purchases query
|
||||
- (buyer_user_id) for FK lookup performance
|
||||
|
||||
Revision ID: 0033
|
||||
Revises: 0032
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0033'
|
||||
down_revision: Union[str, None] = '0032'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
INDEXES = [
|
||||
('ix_guest_purchases_user_gift_status', ['user_id', 'is_gift', 'status']),
|
||||
('ix_guest_purchases_status_paid_at', ['status', 'paid_at']),
|
||||
('ix_guest_purchases_buyer_user_id', ['buyer_user_id']),
|
||||
]
|
||||
|
||||
|
||||
def _has_index(table: str, index_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return index_name in [idx['name'] for idx in inspector.get_indexes(table)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for index_name, columns in INDEXES:
|
||||
if not _has_index('guest_purchases', index_name):
|
||||
op.create_index(index_name, 'guest_purchases', columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for index_name, _ in reversed(INDEXES):
|
||||
if _has_index('guest_purchases', index_name):
|
||||
op.drop_index(index_name, table_name='guest_purchases')
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Add recipient_warning column to guest_purchases.
|
||||
|
||||
Revision ID: 0034
|
||||
Revises: 0033
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0034'
|
||||
down_revision = '0033'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('guest_purchases', sa.Column('recipient_warning', sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('guest_purchases', 'recipient_warning')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.27.0"
|
||||
version = "3.28.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user