+2
-1
@@ -33,7 +33,8 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data && chown app:app logs data
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
|
||||
chown -R app:app logs data uploads
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
+2
-2
@@ -133,11 +133,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
chat_type_filter = ChatTypeFilterMiddleware()
|
||||
dp.message.middleware(chat_type_filter)
|
||||
dp.callback_query.middleware(chat_type_filter)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
|
||||
@@ -13,6 +13,10 @@ 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_news import router as admin_news_router
|
||||
from .admin_news_categories import router as admin_news_categories_router
|
||||
from .admin_news_media import router as admin_news_media_router
|
||||
from .admin_news_tags import router as admin_news_tags_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
|
||||
@@ -42,6 +46,7 @@ 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
|
||||
from .news import router as news_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
@@ -85,6 +90,7 @@ router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -126,6 +132,12 @@ router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
# Categories/tags/media routers MUST be before the main news router
|
||||
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
|
||||
router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Admin routes for managing news articles in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
create_news_article,
|
||||
delete_news_article,
|
||||
get_all_news,
|
||||
get_all_news_count,
|
||||
get_news_article_by_id,
|
||||
unfeature_all_news,
|
||||
update_news_article,
|
||||
)
|
||||
from app.database.crud.news_categories import get_category_by_id
|
||||
from app.database.crud.news_tags import get_tag_by_id
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsCreateRequest,
|
||||
NewsListResponse,
|
||||
NewsToggleResponse,
|
||||
NewsUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
|
||||
|
||||
|
||||
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to full detail dict.
|
||||
|
||||
Expects the ``author`` relationship to be eagerly loaded.
|
||||
"""
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
|
||||
return {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'content': article.content,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'category_id': article.category_id,
|
||||
'tag_id': article.tag_id,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
'author_name': author_name,
|
||||
'created_at': article.created_at,
|
||||
'updated_at': article.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_all_news(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get all news articles (admin view, includes unpublished)."""
|
||||
try:
|
||||
articles = await get_all_news(db, limit=limit, offset=offset)
|
||||
total = await get_all_news_count(db)
|
||||
|
||||
items = [NewsArticleListItem.model_validate(a) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list all news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news articles',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def get_article_detail(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single news article by ID (admin view)."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_article(
|
||||
request: NewsCreateRequest,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Create a new news article."""
|
||||
try:
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
category_name = request.category
|
||||
category_color = request.category_color
|
||||
if request.category_id is not None:
|
||||
cat = await get_category_by_id(db, request.category_id)
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={request.category_id} not found',
|
||||
)
|
||||
category_name = cat.name
|
||||
category_color = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
tag_name = request.tag
|
||||
if request.tag_id is not None:
|
||||
tag_obj = await get_tag_by_id(db, request.tag_id)
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={request.tag_id} not found',
|
||||
)
|
||||
tag_name = tag_obj.name
|
||||
|
||||
if request.is_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await create_news_article(
|
||||
db,
|
||||
title=request.title,
|
||||
slug=request.slug,
|
||||
content=request.content,
|
||||
excerpt=request.excerpt,
|
||||
category=category_name,
|
||||
category_color=category_color,
|
||||
tag=tag_name,
|
||||
category_id=request.category_id,
|
||||
tag_id=request.tag_id,
|
||||
featured_image_url=request.featured_image_url,
|
||||
is_published=request.is_published,
|
||||
is_featured=request.is_featured,
|
||||
read_time_minutes=request.read_time_minutes,
|
||||
created_by=admin.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create news article')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create article',
|
||||
)
|
||||
|
||||
# Reload with author relationship
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after creation',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.put('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def update_article(
|
||||
article_id: int,
|
||||
request: NewsUpdateRequest,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Update an existing news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
if 'category_id' in update_data and update_data['category_id'] is not None:
|
||||
cat = await get_category_by_id(db, update_data['category_id'])
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={update_data["category_id"]} not found',
|
||||
)
|
||||
update_data['category'] = cat.name
|
||||
update_data['category_color'] = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
if 'tag_id' in update_data and update_data['tag_id'] is not None:
|
||||
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={update_data["tag_id"]} not found',
|
||||
)
|
||||
update_data['tag'] = tag_obj.name
|
||||
|
||||
if update_data.get('is_featured'):
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update article',
|
||||
)
|
||||
|
||||
# Reload with author relationship (update used bulk UPDATE, author not populated)
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after update',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_article(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_news_article(db, article)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete article',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
|
||||
async def toggle_publish(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the published status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
new_published = not article.is_published
|
||||
|
||||
update_kwargs: dict[str, Any] = {'is_published': new_published}
|
||||
# Auto-set published_at on first publish
|
||||
if new_published and article.published_at is None:
|
||||
update_kwargs['published_at'] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
article = await update_news_article(db, article, **update_kwargs)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle publish', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle publish status',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
|
||||
async def toggle_featured(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the featured status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
new_featured = not article.is_featured
|
||||
# Only one article can be featured at a time — unfeature all others first
|
||||
if new_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, is_featured=new_featured)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle featured', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle featured status',
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news categories."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_categories import (
|
||||
create_category,
|
||||
delete_category,
|
||||
get_all_categories,
|
||||
get_category_by_id,
|
||||
update_category,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsCategoryResponse])
|
||||
async def list_categories(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsCategoryResponse]:
|
||||
"""Get all news categories."""
|
||||
categories = await get_all_categories(db)
|
||||
return [NewsCategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_category(
|
||||
request: NewsCategoryCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Create a new news category."""
|
||||
try:
|
||||
category = await create_category(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put('/{category_id}', response_model=NewsCategoryResponse)
|
||||
async def update_existing_category(
|
||||
category_id: int,
|
||||
request: NewsCategoryUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Update an existing news category."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
try:
|
||||
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category name already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_category(
|
||||
category_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news category. Articles using it will have category_id set to NULL."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
await delete_category(db, category)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Admin routes for managing news article media (images/videos)."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.news_media_service import (
|
||||
SavedMedia,
|
||||
delete_media_file,
|
||||
detect_file_type,
|
||||
ensure_upload_dirs,
|
||||
save_image,
|
||||
save_video,
|
||||
)
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.news_media import NewsMediaUploadResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
|
||||
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
|
||||
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
|
||||
|
||||
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
|
||||
|
||||
|
||||
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
|
||||
|
||||
|
||||
def _build_media_url(request: Request, relative_path: str) -> str:
|
||||
"""Build a full URL for a media file, respecting reverse proxy headers."""
|
||||
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
|
||||
if proto not in _ALLOWED_SCHEMES:
|
||||
proto = 'https'
|
||||
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
|
||||
host = host.split(',')[0].strip()
|
||||
return f'{proto}://{host}/uploads/{relative_path}'
|
||||
|
||||
|
||||
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
|
||||
"""Convert SavedMedia to API response with full URLs."""
|
||||
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
|
||||
|
||||
return NewsMediaUploadResponse(
|
||||
url=_build_media_url(request, saved.relative_path),
|
||||
thumbnail_url=thumbnail_url,
|
||||
media_type=saved.media_type,
|
||||
filename=saved.filename,
|
||||
size_bytes=saved.size_bytes,
|
||||
width=saved.width,
|
||||
height=saved.height,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
) -> NewsMediaUploadResponse:
|
||||
"""Upload an image or video for a news article."""
|
||||
# Read with a hard budget to prevent memory exhaustion from huge uploads.
|
||||
# Read slightly over the max allowed size so we can detect oversized files.
|
||||
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
|
||||
data = await file.read(absolute_max_bytes)
|
||||
await file.close()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Empty file',
|
||||
)
|
||||
|
||||
if len(data) >= absolute_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
|
||||
)
|
||||
|
||||
# Detect type from magic bytes
|
||||
try:
|
||||
media_type, _ext = detect_file_type(data)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
|
||||
) from None
|
||||
|
||||
# Enforce per-type size limits
|
||||
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
|
||||
if len(data) > max_size_mb * _BYTES_PER_MB:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
await asyncio.to_thread(ensure_upload_dirs, upload_path)
|
||||
|
||||
try:
|
||||
if media_type == 'image':
|
||||
saved = await save_image(
|
||||
data,
|
||||
upload_path,
|
||||
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
|
||||
quality=settings.MEDIA_JPEG_QUALITY,
|
||||
)
|
||||
else:
|
||||
saved = await save_video(data, upload_path)
|
||||
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
|
||||
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail='Failed to process uploaded file',
|
||||
) from None
|
||||
|
||||
logger.info(
|
||||
'Media uploaded',
|
||||
filename=saved.filename,
|
||||
media_type=saved.media_type,
|
||||
size_bytes=saved.size_bytes,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return _build_response(request, saved)
|
||||
|
||||
|
||||
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(
|
||||
filename: str,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
) -> None:
|
||||
"""Delete a previously uploaded media file."""
|
||||
if not _SAFE_FILENAME_RE.match(filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid filename',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
|
||||
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='File not found',
|
||||
)
|
||||
|
||||
logger.info('Media deleted', filename=filename, admin_id=admin.id)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news tags."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_tags import (
|
||||
create_tag,
|
||||
delete_tag,
|
||||
get_all_tags,
|
||||
get_tag_by_id,
|
||||
update_tag,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsTagResponse])
|
||||
async def list_tags(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsTagResponse]:
|
||||
"""Get all news tags."""
|
||||
tags = await get_all_tags(db)
|
||||
return [NewsTagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_tag(
|
||||
request: NewsTagCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Create a new news tag."""
|
||||
try:
|
||||
tag = await create_tag(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.put('/{tag_id}', response_model=NewsTagResponse)
|
||||
async def update_existing_tag(
|
||||
tag_id: int,
|
||||
request: NewsTagUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Update an existing news tag."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
try:
|
||||
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag name already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_tag(
|
||||
tag_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
await delete_tag(db, tag)
|
||||
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, literal, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -42,12 +42,15 @@ from app.database.models import (
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.services.permission_service import PermissionService
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
AdminUserGiftItem,
|
||||
AdminUserGiftsResponse,
|
||||
AssignReferrerRequest,
|
||||
AssignReferrerResponse,
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
@@ -1696,6 +1699,13 @@ async def update_user_referral_commission(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
# Prevent admin from modifying their own commission
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin cannot modify their own referral commission',
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@@ -1706,6 +1716,14 @@ async def update_user_referral_commission(
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=admin.id,
|
||||
action='update_referral_commission',
|
||||
resource_type='user',
|
||||
resource_id=str(user_id),
|
||||
details={'old_commission': old_commission, 'new_commission': request.commission_percent},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -1724,6 +1742,103 @@ async def update_user_referral_commission(
|
||||
)
|
||||
|
||||
|
||||
# === Assign Referrer ===
|
||||
|
||||
|
||||
@router.post('/{user_id}/assign-referrer', response_model=AssignReferrerResponse)
|
||||
async def assign_user_referrer(
|
||||
user_id: int,
|
||||
request: AssignReferrerRequest,
|
||||
admin: User = Depends(require_permission('users:referral')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Manually assign a referrer to a user (e.g. cabinet-registered users without telegram_id).
|
||||
|
||||
Bonuses are NOT triggered immediately — they will apply on the user's next topup.
|
||||
"""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user_id == request.referrer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='User cannot be their own referrer',
|
||||
)
|
||||
|
||||
# Prevent admin self-enrichment
|
||||
if request.referrer_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin cannot assign themselves as referrer',
|
||||
)
|
||||
|
||||
referrer = await get_user_by_id(db, request.referrer_id)
|
||||
if not referrer:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referrer user not found',
|
||||
)
|
||||
|
||||
# Prevent circular referral chains of any depth via recursive CTE
|
||||
if await _would_create_referral_cycle(db, user_id, request.referrer_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Circular referral: assigning this referrer would create a cycle in the referral chain',
|
||||
)
|
||||
|
||||
old_referrer_id = user.referred_by_id
|
||||
user.referred_by_id = request.referrer_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=admin.id,
|
||||
action='assign_referrer',
|
||||
resource_type='user',
|
||||
resource_id=str(user_id),
|
||||
details={'old_referrer_id': old_referrer_id, 'new_referrer_id': request.referrer_id},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin assigned referrer to user',
|
||||
admin_id=admin.id,
|
||||
user_id=user_id,
|
||||
old_referrer_id=old_referrer_id,
|
||||
new_referrer_id=request.referrer_id,
|
||||
)
|
||||
|
||||
return AssignReferrerResponse(
|
||||
success=True,
|
||||
old_referrer_id=old_referrer_id,
|
||||
new_referrer_id=request.referrer_id,
|
||||
message='Referrer assigned successfully. Bonuses will apply on next user topup.',
|
||||
)
|
||||
|
||||
|
||||
async def _would_create_referral_cycle(db: AsyncSession, user_id: int, referrer_id: int) -> bool:
|
||||
"""Walk the referrer's ancestor chain; if user_id appears, a cycle would form."""
|
||||
max_depth = 50
|
||||
anchor = (
|
||||
select(User.id, User.referred_by_id, literal(0).label('depth'))
|
||||
.where(User.id == referrer_id)
|
||||
.cte(name='ancestors', recursive=True)
|
||||
)
|
||||
rpart = (
|
||||
select(User.id, User.referred_by_id, (anchor.c.depth + 1).label('depth'))
|
||||
.join(anchor, User.id == anchor.c.referred_by_id)
|
||||
.where(anchor.c.depth < max_depth)
|
||||
)
|
||||
ancestors_cte = anchor.union_all(rpart)
|
||||
result = await db.execute(
|
||||
select(literal(1)).where(ancestors_cte.c.id == user_id).select_from(ancestors_cte).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
# === Devices ===
|
||||
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
# Use description with telegram_id for tax receipts
|
||||
description = settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
)
|
||||
if option == 'sbp':
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
@@ -423,7 +423,7 @@ async def create_topup(
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
@@ -484,7 +484,7 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
@@ -513,7 +513,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_return_url,
|
||||
success_url=cabinet_success_url,
|
||||
@@ -540,7 +542,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -570,7 +574,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -610,7 +616,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
@@ -637,7 +645,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
@@ -665,7 +675,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -695,7 +707,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
@@ -722,7 +736,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Public news routes for cabinet - user-facing news/blog section."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
get_news_article_by_slug,
|
||||
get_news_categories,
|
||||
get_published_news,
|
||||
get_published_news_count,
|
||||
increment_views,
|
||||
)
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
|
||||
_SLUG_MAX_LENGTH: int = 500
|
||||
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
|
||||
|
||||
# --- View counter deduplication ---
|
||||
# In-memory TTL cache to prevent a single user from inflating view counts.
|
||||
# Key: (user_id, article_id), Value: timestamp of last counted view.
|
||||
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
|
||||
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
|
||||
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
|
||||
_view_dedup_cache: dict[tuple[int, int], float] = {}
|
||||
|
||||
|
||||
def _should_count_view(user_id: int, article_id: int) -> bool:
|
||||
"""Return True if this view should be counted (not a duplicate within TTL)."""
|
||||
now = time.monotonic()
|
||||
key = (user_id, article_id)
|
||||
last_seen = _view_dedup_cache.get(key)
|
||||
|
||||
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
|
||||
return False
|
||||
|
||||
# Evict stale entries if cache grows too large
|
||||
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
|
||||
cutoff = now - _VIEW_DEDUP_SECONDS
|
||||
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
|
||||
for k in stale_keys:
|
||||
del _view_dedup_cache[k]
|
||||
|
||||
_view_dedup_cache[key] = now
|
||||
return True
|
||||
|
||||
|
||||
router = APIRouter(prefix='/news', tags=['Cabinet News'])
|
||||
|
||||
|
||||
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to response dict.
|
||||
|
||||
``author_name`` is only resolved when ``include_content=True`` (single-article
|
||||
detail view) because the author relationship is not eagerly loaded for list
|
||||
queries -- accessing it there would trigger a lazy-load or raise
|
||||
``MissingGreenlet`` in async context.
|
||||
"""
|
||||
data: dict[str, Any] = {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
}
|
||||
|
||||
if include_content:
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
data['content'] = article.content
|
||||
data['author_name'] = author_name
|
||||
data['created_at'] = article.created_at
|
||||
data['updated_at'] = article.updated_at
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
|
||||
@router.get('/categories', response_model=list[str])
|
||||
async def list_categories(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[str]:
|
||||
"""Get list of distinct news categories."""
|
||||
try:
|
||||
return await get_news_categories(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get news categories')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load categories',
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_published_news(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
category: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get paginated list of published news articles.
|
||||
|
||||
SQLAlchemy AsyncSession does not support concurrent operations, so
|
||||
queries run sequentially.
|
||||
"""
|
||||
try:
|
||||
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
|
||||
total = await get_published_news_count(db, category=category)
|
||||
categories = await get_news_categories(db)
|
||||
|
||||
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total, categories=categories)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list published news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=NewsArticleResponse)
|
||||
async def get_article_by_slug(
|
||||
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single published news article by slug. Increments view count."""
|
||||
article = await get_news_article_by_slug(db, slug)
|
||||
|
||||
if not article or not article.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
# Increment views with per-user deduplication (5-min TTL).
|
||||
# Prevents view count inflation from repeated requests by the same user.
|
||||
if _should_count_view(user.id, article.id):
|
||||
try:
|
||||
new_count = await increment_views(db, article.id)
|
||||
# Patch the ORM instance so the response reflects the new count
|
||||
# without an extra db.refresh() round-trip
|
||||
article.views_count = new_count
|
||||
except Exception:
|
||||
logger.warning('Failed to increment views', article_id=article.id)
|
||||
|
||||
return NewsArticleResponse(**_article_to_response(article, include_content=True))
|
||||
@@ -1888,6 +1888,26 @@ async def purchase_tariff(
|
||||
else:
|
||||
period_days = request.period_days
|
||||
|
||||
# Validate period_days against tariff's configured periods (prevent arbitrary periods)
|
||||
if tariff.period_prices:
|
||||
available_periods = [int(p) for p in tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = []
|
||||
|
||||
# Allow custom days only if tariff explicitly supports them
|
||||
custom_days_allowed = (
|
||||
hasattr(tariff, 'can_purchase_custom_days')
|
||||
and tariff.can_purchase_custom_days()
|
||||
and hasattr(tariff, 'get_price_for_custom_days')
|
||||
and tariff.get_price_for_custom_days(period_days) is not None
|
||||
)
|
||||
|
||||
if period_days not in available_periods and not custom_days_allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Selected period is not available for this tariff',
|
||||
)
|
||||
|
||||
# Determine traffic limit (custom traffic support)
|
||||
traffic_limit_gb = tariff.traffic_limit_gb
|
||||
custom_traffic_gb = None
|
||||
@@ -1921,6 +1941,13 @@ async def purchase_tariff(
|
||||
promo_offer_discount_value = result.promo_offer_discount
|
||||
price_before_promo_offer = price_kopeks + promo_offer_discount_value
|
||||
|
||||
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
|
||||
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid tariff period or pricing configuration',
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
@@ -4394,17 +4421,81 @@ async def toggle_subscription_pause(
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
# Restore connected_squads from tariff if cleared by deactivation sync
|
||||
try:
|
||||
if not user.subscription.connected_squads:
|
||||
squads = tariff.allowed_squads or []
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
if squads:
|
||||
user.subscription.connected_squads = squads
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
except Exception as sq_err:
|
||||
logger.warning('Failed to restore connected_squads', error=sq_err)
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
# POST /api/users may ignore activeInternalSquads —
|
||||
# follow up with PATCH to ensure internal squads are assigned
|
||||
await db.refresh(user)
|
||||
if getattr(user, 'remnawave_uuid', None) and user.subscription.connected_squads:
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=False,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as squad_err:
|
||||
logger.warning('Failed to sync squads after user creation', error=squad_err)
|
||||
except Exception as e:
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
|
||||
# Send admin notification about daily subscription resume
|
||||
if resume_transaction is not None:
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=user.subscription,
|
||||
transaction=resume_transaction,
|
||||
period_days=1,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as notif_err:
|
||||
logger.error('Failed to send admin notification for daily resume', error=notif_err)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Schemas for news articles in cabinet.
|
||||
|
||||
Security notes:
|
||||
- featured_image_url is validated to only accept http/https schemes.
|
||||
- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.).
|
||||
- Slug is sanitized to only allow [a-zA-Z0-9_-].
|
||||
- Content is server-side sanitized to strip <script>, event handlers, and
|
||||
dangerous URI schemes as a defense-in-depth measure. The frontend also
|
||||
sanitizes via DOMPurify, but server-side sanitization protects against
|
||||
alternative consumers (mobile apps, RSS, email digests) and compromised
|
||||
frontends.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# Pre-compiled regex for hex color validation (reused across validators)
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
# Pre-compiled regex for collapsing repeated hyphens in slugs
|
||||
_MULTI_HYPHEN_RE: re.Pattern[str] = re.compile(r'-+')
|
||||
|
||||
# Maximum slug length (matches DB column constraint)
|
||||
_MAX_SLUG_LENGTH: int = 500
|
||||
|
||||
# Allowed URL schemes for user-supplied URLs (featured_image_url)
|
||||
_SAFE_URL_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
|
||||
|
||||
# Cyrillic-to-Latin transliteration map for slug generation
|
||||
_TRANSLIT_MAP: dict[str, str] = {
|
||||
'а': 'a',
|
||||
'б': 'b',
|
||||
'в': 'v',
|
||||
'г': 'g',
|
||||
'д': 'd',
|
||||
'е': 'e',
|
||||
'ё': 'yo',
|
||||
'ж': 'zh',
|
||||
'з': 'z',
|
||||
'и': 'i',
|
||||
'й': 'y',
|
||||
'к': 'k',
|
||||
'л': 'l',
|
||||
'м': 'm',
|
||||
'н': 'n',
|
||||
'о': 'o',
|
||||
'п': 'p',
|
||||
'р': 'r',
|
||||
'с': 's',
|
||||
'т': 't',
|
||||
'у': 'u',
|
||||
'ф': 'f',
|
||||
'х': 'kh',
|
||||
'ц': 'ts',
|
||||
'ч': 'ch',
|
||||
'ш': 'sh',
|
||||
'щ': 'shch',
|
||||
'ъ': '',
|
||||
'ы': 'y',
|
||||
'ь': '',
|
||||
'э': 'e',
|
||||
'ю': 'yu',
|
||||
'я': 'ya',
|
||||
}
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a title, transliterating Cyrillic."""
|
||||
slug = title.lower()
|
||||
result: list[str] = []
|
||||
for ch in slug:
|
||||
if ch in _TRANSLIT_MAP:
|
||||
result.append(_TRANSLIT_MAP[ch])
|
||||
elif ch.isascii() and (ch.isalnum() or ch in '-_'):
|
||||
result.append(ch)
|
||||
elif ch == ' ':
|
||||
result.append('-')
|
||||
slug = ''.join(result)
|
||||
slug = _MULTI_HYPHEN_RE.sub('-', slug).strip('-')
|
||||
return slug[:_MAX_SLUG_LENGTH] or 'untitled'
|
||||
|
||||
|
||||
def _validate_hex_color(v: str) -> str:
|
||||
"""Validate a hex color string. Raises ValueError on invalid input."""
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'category_color must be a valid hex color (e.g. #00e5a0)'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_safe_url(v: str) -> str:
|
||||
"""Validate that a URL uses http or https scheme only.
|
||||
|
||||
Prevents javascript:, data:, vbscript:, and other dangerous URI schemes
|
||||
from being stored in the database and later rendered in <img> or <a> tags.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(v)
|
||||
except Exception:
|
||||
msg = 'Invalid URL format'
|
||||
raise ValueError(msg)
|
||||
|
||||
if parsed.scheme not in _SAFE_URL_SCHEMES:
|
||||
msg = f'URL scheme must be http or https, got: {parsed.scheme!r}'
|
||||
raise ValueError(msg)
|
||||
|
||||
if not parsed.netloc:
|
||||
msg = 'URL must have a valid host'
|
||||
raise ValueError(msg)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
# --- Server-side HTML content sanitization ---
|
||||
# Pre-compiled patterns for stripping the most dangerous HTML constructs.
|
||||
# This is a defense-in-depth measure: the frontend also sanitizes via DOMPurify.
|
||||
# Uses regex rather than a full HTML parser to avoid adding a new dependency.
|
||||
# Strips: <script>, <style>, <object>, <embed>, <applet>, <base>, <form>,
|
||||
# <link>, <meta> tags and all on* event handler attributes.
|
||||
_DANGEROUS_TAGS_RE: re.Pattern[str] = re.compile(
|
||||
r'<\s*/?\s*(script|style|object|embed|applet|base|form|link(?:\s)|meta)\b[^>]*>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Match on* event handler attributes, e.g. onclick="...", onerror='...'
|
||||
_EVENT_HANDLER_RE: re.Pattern[str] = re.compile(
|
||||
r'\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Match javascript:, vbscript:, data: in href/src attributes
|
||||
_DANGEROUS_URI_RE: re.Pattern[str] = re.compile(
|
||||
r'((?:href|src)\s*=\s*["\'])\s*(javascript|vbscript|data)\s*:',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_html_content(html: str) -> str:
|
||||
"""Strip dangerous HTML constructs from article content.
|
||||
|
||||
This is NOT a replacement for DOMPurify on the frontend. It is a
|
||||
defense-in-depth layer that removes the most obvious XSS vectors
|
||||
at the storage boundary. A full HTML sanitizer (nh3, bleach) would
|
||||
be stronger, but this avoids adding a new dependency.
|
||||
"""
|
||||
if not html:
|
||||
return html
|
||||
|
||||
# 1. Remove dangerous tags and their content
|
||||
result = _DANGEROUS_TAGS_RE.sub('', html)
|
||||
|
||||
# Also strip <script>...</script> content (tag + body)
|
||||
result = re.sub(r'<script\b[^>]*>[\s\S]*?</script>', '', result, flags=re.IGNORECASE)
|
||||
result = re.sub(r'<style\b[^>]*>[\s\S]*?</style>', '', result, flags=re.IGNORECASE)
|
||||
|
||||
# 2. Remove event handler attributes
|
||||
result = _EVENT_HANDLER_RE.sub('', result)
|
||||
|
||||
# 3. Neutralize dangerous URI schemes in href/src
|
||||
result = _DANGEROUS_URI_RE.sub(r'\1about:', result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class NewsArticleResponse(BaseModel):
|
||||
"""Full news article response (detail view)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
content: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
author_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsArticleListItem(BaseModel):
|
||||
"""Compact news article for list views."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsListResponse(BaseModel):
|
||||
"""Paginated list of news articles."""
|
||||
|
||||
items: list[NewsArticleListItem]
|
||||
total: int
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NewsCreateRequest(BaseModel):
|
||||
"""Request to create a news article."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str = Field(default='', max_length=500_000)
|
||||
excerpt: str | None = Field(None, max_length=1000)
|
||||
category: str = Field(..., min_length=1, max_length=100)
|
||||
category_color: str = Field(default='#00e5a0', max_length=20)
|
||||
tag: str | None = Field(None, max_length=50)
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool = False
|
||||
is_featured: bool = False
|
||||
read_time_minutes: int = Field(default=1, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str) -> str:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
return _sanitize_html_content(v)
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str) -> str:
|
||||
return _validate_hex_color(v)
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg]
|
||||
"""Generate slug from title when not explicitly provided."""
|
||||
if isinstance(data, dict) and not data.get('slug'):
|
||||
title = data.get('title', '')
|
||||
data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled'
|
||||
return data
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters."""
|
||||
if v is not None:
|
||||
# Strip anything that isn't alphanumeric, hyphen, or underscore
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v)
|
||||
sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-')
|
||||
return sanitized or 'untitled'
|
||||
return v
|
||||
|
||||
|
||||
class NewsUpdateRequest(BaseModel):
|
||||
"""Request to update a news article."""
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str | None = Field(None, max_length=500_000)
|
||||
excerpt: str | None = None
|
||||
category: str | None = Field(None, min_length=1, max_length=100)
|
||||
category_color: str | None = Field(None, max_length=20)
|
||||
tag: str | None = None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool | None = None
|
||||
is_featured: bool | None = None
|
||||
read_time_minutes: int | None = Field(None, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str | None) -> str | None:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
if v is not None:
|
||||
return _sanitize_html_content(v)
|
||||
return v
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
return _validate_hex_color(v)
|
||||
return v
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters."""
|
||||
if v is not None:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v)
|
||||
sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-')
|
||||
return sanitized or 'untitled'
|
||||
return v
|
||||
|
||||
|
||||
class NewsToggleResponse(BaseModel):
|
||||
"""Response after toggling publish/featured status."""
|
||||
|
||||
id: int
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news categories."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsCategoryCreate(BaseModel):
|
||||
"""Request to create a news category."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str = Field(default='#00e5a0', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryUpdate(BaseModel):
|
||||
"""Request to update a news category."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryResponse(BaseModel):
|
||||
"""News category response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Schemas for news media upload responses."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NewsMediaUploadResponse(BaseModel):
|
||||
"""Response returned after a successful media upload."""
|
||||
|
||||
url: str
|
||||
thumbnail_url: str | None = None
|
||||
media_type: Literal['image', 'video']
|
||||
filename: str
|
||||
size_bytes: int
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news tags."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsTagCreate(BaseModel):
|
||||
"""Request to create a news tag."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
color: str = Field(default='#94a3b8', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagUpdate(BaseModel):
|
||||
"""Request to update a news tag."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=50)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagResponse(BaseModel):
|
||||
"""News tag response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -387,6 +387,21 @@ class UpdateReferralCommissionResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class AssignReferrerRequest(BaseModel):
|
||||
"""Request to manually assign a referrer to a user."""
|
||||
|
||||
referrer_id: int = Field(..., gt=0, description='ID of the referrer user')
|
||||
|
||||
|
||||
class AssignReferrerResponse(BaseModel):
|
||||
"""Response after referrer assignment."""
|
||||
|
||||
success: bool
|
||||
old_referrer_id: int | None = None
|
||||
new_referrer_id: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
|
||||
+18
-2
@@ -367,6 +367,7 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
YOOKASSA_TEST_MODE: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -462,6 +463,7 @@ class Settings(BaseSettings):
|
||||
PLATEGA_FAILED_URL: str | None = None
|
||||
PLATEGA_CURRENCY: str = 'RUB'
|
||||
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
|
||||
PLATEGA_INLINE_METHODS: bool = True
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = '/platega-webhook'
|
||||
@@ -582,6 +584,13 @@ class Settings(BaseSettings):
|
||||
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||
MINIAPP_CUSTOM_URL: str = ''
|
||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
||||
|
||||
# Media upload settings (news article images/videos)
|
||||
MEDIA_UPLOAD_DIR: str = './uploads'
|
||||
MEDIA_MAX_IMAGE_SIZE_MB: int = 10
|
||||
MEDIA_MAX_VIDEO_SIZE_MB: int = 50
|
||||
MEDIA_IMAGE_MAX_DIMENSION: int = 2048
|
||||
MEDIA_JPEG_QUALITY: int = 85
|
||||
MINIAPP_PURCHASE_URL: str = ''
|
||||
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
|
||||
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
|
||||
@@ -2207,13 +2216,17 @@ class Settings(BaseSettings):
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 60, 90, 180, 360]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str:
|
||||
def get_balance_payment_description(
|
||||
self, amount_kopeks: int, telegram_user_id: int | None = None, user_db_id: int | None = None
|
||||
) -> str:
|
||||
# Базовое описание
|
||||
description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}'
|
||||
|
||||
# Если передан user_id, добавляем его
|
||||
# Добавляем идентификатор пользователя (TG ID приоритет, fallback на DB ID)
|
||||
if telegram_user_id is not None:
|
||||
description += f' (ID {telegram_user_id})'
|
||||
elif user_db_id is not None:
|
||||
description += f' (U{user_db_id})'
|
||||
|
||||
# Формируем финальную строку по шаблону
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description)
|
||||
@@ -2626,6 +2639,9 @@ class Settings(BaseSettings):
|
||||
raw_path = 'miniapp'
|
||||
return Path(raw_path)
|
||||
|
||||
def get_media_upload_path(self) -> Path:
|
||||
return Path(self.MEDIA_UPLOAD_DIR)
|
||||
|
||||
# Cabinet methods
|
||||
def is_cabinet_enabled(self) -> bool:
|
||||
return bool(self.CABINET_ENABLED)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""CRUD operations for news articles."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, func, nullslast, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import NewsArticle
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields that can be set via update_news_article
|
||||
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'title',
|
||||
'slug',
|
||||
'content',
|
||||
'excerpt',
|
||||
'category',
|
||||
'category_color',
|
||||
'tag',
|
||||
'category_id',
|
||||
'tag_id',
|
||||
'featured_image_url',
|
||||
'is_published',
|
||||
'is_featured',
|
||||
'published_at',
|
||||
'read_time_minutes',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields that can be explicitly set to None
|
||||
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'excerpt',
|
||||
'tag',
|
||||
'category_id',
|
||||
'tag_id',
|
||||
'featured_image_url',
|
||||
'published_at',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_news_article(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
slug: str,
|
||||
content: str = '',
|
||||
excerpt: str | None = None,
|
||||
category: str = '',
|
||||
category_color: str = '#00e5a0',
|
||||
tag: str | None = None,
|
||||
category_id: int | None = None,
|
||||
tag_id: int | None = None,
|
||||
featured_image_url: str | None = None,
|
||||
is_published: bool = False,
|
||||
is_featured: bool = False,
|
||||
published_at: datetime | None = None,
|
||||
read_time_minutes: int = 1,
|
||||
created_by: int | None = None,
|
||||
) -> NewsArticle:
|
||||
"""Create a new news article.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug is not unique (caller must handle).
|
||||
"""
|
||||
# Auto-set published_at when publishing without explicit date
|
||||
if is_published and published_at is None:
|
||||
published_at = datetime.now(UTC)
|
||||
|
||||
article = NewsArticle(
|
||||
title=title,
|
||||
slug=slug,
|
||||
content=content,
|
||||
excerpt=excerpt,
|
||||
category=category,
|
||||
category_color=category_color,
|
||||
tag=tag,
|
||||
category_id=category_id,
|
||||
tag_id=tag_id,
|
||||
featured_image_url=featured_image_url,
|
||||
is_published=is_published,
|
||||
is_featured=is_featured,
|
||||
published_at=published_at,
|
||||
read_time_minutes=read_time_minutes,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
db.add(article)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(article)
|
||||
|
||||
logger.info(
|
||||
'Created news article',
|
||||
article_id=article.id,
|
||||
slug=article.slug,
|
||||
is_published=article.is_published,
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None:
|
||||
"""Get a news article by ID with author, category, and tag relationships."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle)
|
||||
.options(
|
||||
selectinload(NewsArticle.author),
|
||||
selectinload(NewsArticle.category_obj),
|
||||
selectinload(NewsArticle.tag_obj),
|
||||
)
|
||||
.where(NewsArticle.id == article_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None:
|
||||
"""Get a news article by slug with author, category, and tag relationships."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle)
|
||||
.options(
|
||||
selectinload(NewsArticle.author),
|
||||
selectinload(NewsArticle.category_obj),
|
||||
selectinload(NewsArticle.tag_obj),
|
||||
)
|
||||
.where(NewsArticle.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_published_news(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> list[NewsArticle]:
|
||||
"""Get published news articles, ordered by published_at descending.
|
||||
|
||||
Does NOT load the author relationship -- list views do not need it.
|
||||
"""
|
||||
stmt = select(NewsArticle).where(NewsArticle.is_published.is_(True))
|
||||
if category:
|
||||
stmt = stmt.where(NewsArticle.category == category)
|
||||
|
||||
# NULLs last so articles without published_at don't float to the top in DESC
|
||||
stmt = stmt.order_by(nullslast(NewsArticle.published_at.desc())).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_published_news_count(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category: str | None = None,
|
||||
) -> int:
|
||||
"""Get count of published news articles, optionally filtered by category."""
|
||||
stmt = select(func.count(NewsArticle.id)).where(NewsArticle.is_published.is_(True))
|
||||
if category:
|
||||
stmt = stmt.where(NewsArticle.category == category)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def get_all_news(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[NewsArticle]:
|
||||
"""Get all news articles (admin), ordered by created_at descending."""
|
||||
stmt = select(NewsArticle).order_by(NewsArticle.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_all_news_count(db: AsyncSession) -> int:
|
||||
"""Get total count of all news articles."""
|
||||
result = await db.execute(select(func.count(NewsArticle.id)))
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def get_news_categories(db: AsyncSession) -> list[str]:
|
||||
"""Get distinct categories from published articles."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle.category)
|
||||
.where(NewsArticle.is_published.is_(True))
|
||||
.where(NewsArticle.category != '')
|
||||
.distinct()
|
||||
.order_by(NewsArticle.category)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def unfeature_all_news(db: AsyncSession) -> None:
|
||||
"""Remove featured flag from all articles (so only one can be featured).
|
||||
|
||||
Does NOT commit. The caller must commit the session to persist this change.
|
||||
This is intentional — the caller should commit both this operation and the
|
||||
subsequent feature operation atomically.
|
||||
"""
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False))
|
||||
|
||||
|
||||
async def update_news_article(
|
||||
db: AsyncSession,
|
||||
article: NewsArticle,
|
||||
**kwargs: Any,
|
||||
) -> NewsArticle:
|
||||
"""Update a news article. Only whitelisted fields are applied.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug conflicts with another article (caller must handle).
|
||||
"""
|
||||
update_data: dict[str, Any] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ALLOWED_UPDATE_FIELDS:
|
||||
continue
|
||||
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
# Auto-set published_at when transitioning to published
|
||||
if update_data.get('is_published') and not article.is_published and not update_data.get('published_at'):
|
||||
if article.published_at is None:
|
||||
update_data['published_at'] = datetime.now(UTC)
|
||||
|
||||
if not update_data:
|
||||
return article
|
||||
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(article)
|
||||
|
||||
logger.info(
|
||||
'Updated news article',
|
||||
article_id=article.id,
|
||||
slug=article.slug,
|
||||
updated_fields=list(update_data.keys()),
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
async def delete_news_article(db: AsyncSession, article: NewsArticle) -> None:
|
||||
"""Delete a news article."""
|
||||
# Capture fields before commit expires the ORM instance attributes
|
||||
article_id = article.id
|
||||
article_slug = article.slug
|
||||
|
||||
await db.execute(delete(NewsArticle).where(NewsArticle.id == article_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info('Deleted news article', article_id=article_id, slug=article_slug)
|
||||
|
||||
|
||||
async def increment_views(db: AsyncSession, article_id: int) -> int:
|
||||
"""Atomically increment the views counter and return the new count.
|
||||
|
||||
Uses UPDATE … RETURNING so the caller can patch the ORM instance directly
|
||||
without issuing a second SELECT (db.refresh).
|
||||
"""
|
||||
result = await db.execute(
|
||||
update(NewsArticle)
|
||||
.where(NewsArticle.id == article_id)
|
||||
.values(views_count=NewsArticle.views_count + 1)
|
||||
.returning(NewsArticle.views_count)
|
||||
)
|
||||
await db.commit()
|
||||
row = result.fetchone()
|
||||
return row[0] if row else 0
|
||||
@@ -0,0 +1,87 @@
|
||||
"""CRUD operations for news categories."""
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import NewsArticle, NewsCategory
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_all_categories(db: AsyncSession) -> list[NewsCategory]:
|
||||
"""Get all news categories ordered by name."""
|
||||
result = await db.execute(select(NewsCategory).order_by(NewsCategory.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_category_by_id(db: AsyncSession, category_id: int) -> NewsCategory | None:
|
||||
"""Get a single news category by primary key."""
|
||||
result = await db.execute(select(NewsCategory).where(NewsCategory.id == category_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_category(db: AsyncSession, *, name: str, color: str = '#00e5a0') -> NewsCategory:
|
||||
"""Create a new news category.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if a category with the same name already exists (caller must handle).
|
||||
"""
|
||||
category = NewsCategory(name=name.strip(), color=color)
|
||||
db.add(category)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(category)
|
||||
logger.info('Created news category', category_id=category.id, name=category.name)
|
||||
return category
|
||||
|
||||
|
||||
async def update_category(
|
||||
db: AsyncSession,
|
||||
category: NewsCategory,
|
||||
**kwargs: str | None,
|
||||
) -> NewsCategory:
|
||||
"""Update an existing news category.
|
||||
|
||||
Supported kwargs: name, color.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if the new name conflicts with an existing category.
|
||||
"""
|
||||
update_data: dict[str, str] = {}
|
||||
if 'name' in kwargs and kwargs['name'] is not None:
|
||||
update_data['name'] = kwargs['name'].strip()
|
||||
if 'color' in kwargs and kwargs['color'] is not None:
|
||||
update_data['color'] = kwargs['color']
|
||||
|
||||
if not update_data:
|
||||
return category
|
||||
|
||||
await db.execute(update(NewsCategory).where(NewsCategory.id == category.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(category)
|
||||
logger.info('Updated news category', category_id=category.id, updated_fields=list(update_data.keys()))
|
||||
return category
|
||||
|
||||
|
||||
async def delete_category(db: AsyncSession, category: NewsCategory) -> None:
|
||||
"""Delete a news category and clear category fields from all linked articles."""
|
||||
cat_id, cat_name = category.id, category.name
|
||||
# Clear legacy string fields on articles that reference this category
|
||||
await db.execute(
|
||||
update(NewsArticle)
|
||||
.where(NewsArticle.category_id == cat_id)
|
||||
.values(category='', category_color='#00e5a0', category_id=None)
|
||||
)
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
logger.info('Deleted news category', category_id=cat_id, name=cat_name)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""CRUD operations for news tags."""
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import NewsArticle, NewsTag
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_all_tags(db: AsyncSession) -> list[NewsTag]:
|
||||
"""Get all news tags ordered by name."""
|
||||
result = await db.execute(select(NewsTag).order_by(NewsTag.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_tag_by_id(db: AsyncSession, tag_id: int) -> NewsTag | None:
|
||||
"""Get a single news tag by primary key."""
|
||||
result = await db.execute(select(NewsTag).where(NewsTag.id == tag_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_tag(db: AsyncSession, *, name: str, color: str = '#94a3b8') -> NewsTag:
|
||||
"""Create a new news tag.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if a tag with the same name already exists (caller must handle).
|
||||
"""
|
||||
tag = NewsTag(name=name.strip(), color=color)
|
||||
db.add(tag)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(tag)
|
||||
logger.info('Created news tag', tag_id=tag.id, name=tag.name)
|
||||
return tag
|
||||
|
||||
|
||||
async def update_tag(
|
||||
db: AsyncSession,
|
||||
tag: NewsTag,
|
||||
**kwargs: str | None,
|
||||
) -> NewsTag:
|
||||
"""Update an existing news tag.
|
||||
|
||||
Supported kwargs: name, color.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if the new name conflicts with an existing tag.
|
||||
"""
|
||||
update_data: dict[str, str] = {}
|
||||
if 'name' in kwargs and kwargs['name'] is not None:
|
||||
update_data['name'] = kwargs['name'].strip()
|
||||
if 'color' in kwargs and kwargs['color'] is not None:
|
||||
update_data['color'] = kwargs['color']
|
||||
|
||||
if not update_data:
|
||||
return tag
|
||||
|
||||
await db.execute(update(NewsTag).where(NewsTag.id == tag.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(tag)
|
||||
logger.info('Updated news tag', tag_id=tag.id, updated_fields=list(update_data.keys()))
|
||||
return tag
|
||||
|
||||
|
||||
async def delete_tag(db: AsyncSession, tag: NewsTag) -> None:
|
||||
"""Delete a news tag and clear tag fields from all linked articles."""
|
||||
tag_id, tag_name = tag.id, tag.name
|
||||
# Clear legacy string field on articles that reference this tag
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.tag_id == tag_id).values(tag=None, tag_id=None))
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
logger.info('Deleted news tag', tag_id=tag_id, name=tag_name)
|
||||
@@ -34,6 +34,8 @@ async def record_notification(
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: int | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
|
||||
if already_exists:
|
||||
@@ -45,7 +47,8 @@ async def record_notification(
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
|
||||
@@ -58,6 +61,8 @@ async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
@@ -65,4 +70,5 @@ async def clear_notification_by_type(
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
@@ -826,18 +826,19 @@ async def update_subscription_autopay(
|
||||
return subscription
|
||||
|
||||
|
||||
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
async def deactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
@@ -861,8 +862,9 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
|
||||
@@ -139,6 +139,7 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User
|
||||
.where(
|
||||
User.telegram_id.is_(None),
|
||||
User.auth_type == 'telegram',
|
||||
User.status != UserStatus.DELETED.value,
|
||||
func.lower(User.username) == normalized,
|
||||
)
|
||||
.with_for_update()
|
||||
@@ -459,6 +460,14 @@ async def add_user_balance(
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
if amount_kopeks < 0:
|
||||
logger.error(
|
||||
'add_user_balance вызван с отрицательной суммой — используйте subtract_user_balance',
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -221,10 +222,10 @@ def replace_placeholders(text: str, user) -> str:
|
||||
first_name = first_name.strip() if first_name else None
|
||||
username = username.strip() if username else None
|
||||
|
||||
user_name = first_name or username or 'друг'
|
||||
display_first_name = first_name or 'друг'
|
||||
display_username = f'@{username}' if username else (first_name or 'друг')
|
||||
clean_username = username or first_name or 'друг'
|
||||
user_name = html.escape(first_name or username or 'друг')
|
||||
display_first_name = html.escape(first_name or 'друг')
|
||||
display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг')
|
||||
clean_username = html.escape(username or first_name or 'друг')
|
||||
|
||||
replacements = {
|
||||
'{user_name}': user_name,
|
||||
|
||||
+74
-1
@@ -28,6 +28,7 @@ from sqlalchemy import (
|
||||
Time,
|
||||
TypeDecorator,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
@@ -2364,7 +2365,7 @@ class SubscriptionServer(Base):
|
||||
__tablename__ = 'subscription_servers'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False)
|
||||
|
||||
connected_at = Column(AwareDateTime(), default=func.now())
|
||||
@@ -3299,3 +3300,75 @@ class GuestPurchase(Base):
|
||||
def __repr__(self) -> str:
|
||||
token_prefix = self.token[:5] if self.token else '?'
|
||||
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
|
||||
|
||||
|
||||
class NewsArticle(Base):
|
||||
"""News article for the cabinet news section."""
|
||||
|
||||
__tablename__ = 'news_articles'
|
||||
__table_args__ = (
|
||||
# Covers the main public list query: WHERE is_published = true ORDER BY published_at DESC
|
||||
Index('ix_news_articles_published_at_published', 'is_published', 'published_at'),
|
||||
# Covers the category-filtered public list: WHERE is_published = true AND category = ?
|
||||
Index('ix_news_articles_published_category', 'is_published', 'category'),
|
||||
# Covers the admin list query: ORDER BY created_at DESC
|
||||
Index('ix_news_articles_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
slug = Column(String(500), unique=True, nullable=False, index=True)
|
||||
content = Column(Text, nullable=False, default='', server_default='')
|
||||
excerpt = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=False, default='', server_default='')
|
||||
category_color = Column(String(20), nullable=False, default='#00e5a0', server_default='#00e5a0')
|
||||
tag = Column(String(50), nullable=True)
|
||||
featured_image_url = Column(Text, nullable=True)
|
||||
is_published = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
is_featured = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
published_at = Column(AwareDateTime(), nullable=True)
|
||||
read_time_minutes = Column(Integer, nullable=False, default=1, server_default='1')
|
||||
views_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
category_id = Column(Integer, ForeignKey('news_categories.id', ondelete='SET NULL'), nullable=True)
|
||||
tag_id = Column(Integer, ForeignKey('news_tags.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
author = relationship('User', backref='created_news_articles', foreign_keys=[created_by])
|
||||
category_obj = relationship('NewsCategory', foreign_keys=[category_id], lazy='noload')
|
||||
tag_obj = relationship('NewsTag', foreign_keys=[tag_id], lazy='noload')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsArticle id={self.id} slug='{self.slug}' published={self.is_published}>"
|
||||
|
||||
|
||||
class NewsCategory(Base):
|
||||
"""Managed news category with a display color."""
|
||||
|
||||
__tablename__ = 'news_categories'
|
||||
__table_args__ = (Index('ix_news_categories_name_lower', text('lower(name)'), unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), nullable=False, server_default='#00e5a0')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsCategory id={self.id} name='{self.name}'>"
|
||||
|
||||
|
||||
class NewsTag(Base):
|
||||
"""Managed news tag with a display color."""
|
||||
|
||||
__tablename__ = 'news_tags'
|
||||
__table_args__ = (Index('ix_news_tags_name_lower', text('lower(name)'), unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
color = Column(String(20), nullable=False, server_default='#94a3b8')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsTag id={self.id} name='{self.name}'>"
|
||||
|
||||
Vendored
+2
-2
@@ -125,8 +125,8 @@ class CryptoBotService:
|
||||
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||
token = self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||
return True
|
||||
logger.error('CryptoBot API token не настроен, отклоняем webhook')
|
||||
return False
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
Vendored
+2
-2
@@ -155,8 +155,8 @@ class HeleketService:
|
||||
|
||||
def verify_webhook_signature(self, payload: dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning('Heleket сервис не настроен, подпись пропускается')
|
||||
return True
|
||||
logger.error('Heleket сервис не настроен, отклоняем webhook')
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error('Heleket webhook payload не dict', payload=payload)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Обработчики админ-панели для управления черным списком
|
||||
"""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.filters import StateFilter
|
||||
@@ -147,7 +149,7 @@ async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, sta
|
||||
|
||||
# Показываем первые 20 записей
|
||||
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
|
||||
text += f'{i}. <code>{tg_id}</code> {username or ""} — {reason}\n'
|
||||
text += f'{i}. <code>{tg_id}</code> {html.escape(username or "")} — {html.escape(reason or "")}\n'
|
||||
|
||||
if len(blacklist_users) > 20:
|
||||
text += f'\n... и еще {len(blacklist_users) - 20} записей'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
и выполнять очистку БД и панели Remnawave.
|
||||
"""
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -437,7 +438,7 @@ async def show_blocked_list(
|
||||
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
|
||||
telegram_id = user_data.get('telegram_id', '?')
|
||||
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
|
||||
name=name,
|
||||
name=html.escape(name),
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1906,7 +1906,7 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
amount_kopeks = 10 * 100
|
||||
description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),)
|
||||
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id)
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
|
||||
import structlog
|
||||
@@ -67,8 +68,8 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = '❓ Неизвестный тип бонуса'
|
||||
|
||||
return (
|
||||
f'<b>{campaign.name}</b>\n'
|
||||
f'Стартовый параметр: <code>{campaign.start_parameter}</code>\n'
|
||||
f'<b>{html.escape(campaign.name)}</b>\n'
|
||||
f'Стартовый параметр: <code>{html.escape(campaign.start_parameter)}</code>\n'
|
||||
f'Статус: {status}\n'
|
||||
f'{bonus_info}\n'
|
||||
)
|
||||
@@ -244,7 +245,7 @@ async def show_campaigns_list(
|
||||
total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs)
|
||||
status = '🟢' if campaign.is_active else '⚪'
|
||||
line = (
|
||||
f'{status} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n'
|
||||
f'{status} <b>{html.escape(campaign.name)}</b> — <code>{html.escape(campaign.start_parameter)}</code>\n'
|
||||
f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}'
|
||||
)
|
||||
if campaign.is_subscription_bonus:
|
||||
@@ -383,7 +384,7 @@ async def start_edit_campaign_name(
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
'✏️ <b>Изменение названия кампании</b>\n\n'
|
||||
f'Текущее название: <b>{campaign.name}</b>\n'
|
||||
f'Текущее название: <b>{html.escape(campaign.name)}</b>\n'
|
||||
'Введите новое название (3-100 символов):'
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
@@ -1183,8 +1184,8 @@ async def confirm_delete_campaign(
|
||||
|
||||
text = (
|
||||
'🗑️ <b>Удаление кампании</b>\n\n'
|
||||
f'Название: <b>{campaign.name}</b>\n'
|
||||
f'Параметр: <code>{campaign.start_parameter}</code>\n\n'
|
||||
f'Название: <b>{html.escape(campaign.name)}</b>\n'
|
||||
f'Параметр: <code>{html.escape(campaign.start_parameter)}</code>\n\n'
|
||||
'Вы уверены, что хотите удалить кампанию?'
|
||||
)
|
||||
|
||||
@@ -1591,7 +1592,7 @@ async def select_campaign_tariff(
|
||||
await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name)
|
||||
await state.set_state(AdminStates.creating_campaign_tariff_days)
|
||||
await callback.message.edit_text(
|
||||
f'🎁 Выбран тариф: <b>{tariff.name}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
|
||||
f'🎁 Выбран тариф: <b>{html.escape(tariff.name)}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]]
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -70,7 +71,7 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
|
||||
f'Дневная сводка: <b>{summary_times}</b>',
|
||||
]
|
||||
if contest.prize_text:
|
||||
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text))
|
||||
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=html.escape(contest.prize_text)))
|
||||
if contest.last_daily_summary_date:
|
||||
parts.append(
|
||||
texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format(
|
||||
@@ -188,7 +189,7 @@ async def list_contests(
|
||||
lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.'))
|
||||
else:
|
||||
for contest in contests:
|
||||
lines.append(f'• <b>{contest.title}</b> (#{contest.id})')
|
||||
lines.append(f'• <b>{html.escape(contest.title)}</b> (#{contest.id})')
|
||||
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
lines.append(_format_contest_summary(contest, texts, contest_tz))
|
||||
lines.append('')
|
||||
@@ -250,21 +251,21 @@ async def show_contest_details(
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
|
||||
lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
f'🏆 <b>{html.escape(contest.title)}</b>',
|
||||
_format_contest_summary(contest, texts, tz),
|
||||
texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: <b>{count}</b>').format(count=total_events),
|
||||
]
|
||||
|
||||
if contest.description:
|
||||
lines.append('')
|
||||
lines.append(contest.description)
|
||||
lines.append(html.escape(contest.description))
|
||||
|
||||
if leaderboard:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -444,7 +445,7 @@ async def show_leaderboard(
|
||||
]
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -690,7 +691,7 @@ async def show_detailed_stats(
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
'📈 <b>Статистика конкурса</b>',
|
||||
f'🏆 {contest.title}',
|
||||
f'🏆 {html.escape(contest.title)}',
|
||||
'',
|
||||
f'👥 Участников (рефереров): <b>{stats["total_participants"]}</b>',
|
||||
f'📨 Приглашено рефералов: <b>{stats["total_invited"]}</b>',
|
||||
@@ -751,7 +752,7 @@ async def show_detailed_stats_page(
|
||||
for p in page_participants:
|
||||
lines.extend(
|
||||
[
|
||||
f'• <b>{p["full_name"]}</b>',
|
||||
f'• <b>{html.escape(p["full_name"] or "")}</b>',
|
||||
f' 📨 Приглашено: {p["total_referrals"]}',
|
||||
f' 💰 Оплатили: {p["paid_referrals"]}',
|
||||
f' ❌ Не оплатили: {p["unpaid_referrals"]}',
|
||||
@@ -828,7 +829,7 @@ async def sync_contest(
|
||||
lines = [
|
||||
'✅ <b>Синхронизация завершена!</b>',
|
||||
'',
|
||||
f'📊 <b>Конкурс:</b> {contest.title}',
|
||||
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
|
||||
f'📅 <b>Период:</b> {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
|
||||
'🔍 <b>Фильтр транзакций:</b>',
|
||||
f' <code>{start_str}</code>',
|
||||
@@ -870,7 +871,7 @@ async def sync_contest(
|
||||
# Обновляем основное сообщение с новой статистикой
|
||||
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
general_lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
f'🏆 <b>{html.escape(contest.title)}</b>',
|
||||
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
|
||||
'',
|
||||
f'👥 Участников (рефереров): <b>{detailed_stats["total_participants"]}</b>',
|
||||
@@ -927,7 +928,7 @@ async def debug_contest_transactions(
|
||||
lines = [
|
||||
'🔍 <b>Отладка транзакций конкурса</b>',
|
||||
'',
|
||||
f'📊 <b>Конкурс:</b> {contest.title}',
|
||||
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
|
||||
'📅 <b>Период фильтрации:</b>',
|
||||
f' Начало: <code>{debug_data.get("contest_start")}</code>',
|
||||
f' Конец: <code>{debug_data.get("contest_end")}</code>',
|
||||
@@ -1002,10 +1003,10 @@ async def show_virtual_participants(
|
||||
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
|
||||
if vps:
|
||||
for vp in vps:
|
||||
lines.append(f'• {vp.display_name} — {vp.referral_count} реф.')
|
||||
lines.append(f'• {html.escape(vp.display_name)} — {vp.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
@@ -1156,10 +1157,10 @@ async def delete_virtual_participant_handler(
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
|
||||
if vps:
|
||||
for v in vps:
|
||||
lines.append(f'• {v.display_name} — {v.referral_count} реф.')
|
||||
lines.append(f'• {html.escape(v.display_name)} — {v.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.filters import Command
|
||||
@@ -135,6 +137,8 @@ async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(
|
||||
@@ -283,7 +287,7 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe
|
||||
f'📊 <b>Статистика:</b>\n'
|
||||
f'• Очищено правил: {stats["total_active"]}\n'
|
||||
f'• Язык: {db_user.language}\n'
|
||||
f'• Выполнил: {db_user.full_name}\n\n'
|
||||
f'• Выполнил: {html.escape(db_user.full_name or "")}\n\n'
|
||||
f'Теперь используются стандартные правила по умолчанию.'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -133,7 +135,7 @@ async def process_maintenance_reason(message: types.Message, db_user: User, db:
|
||||
if success:
|
||||
response_text = 'Режим техработ включен'
|
||||
if reason:
|
||||
response_text += f'\nПричина: {reason}'
|
||||
response_text += f'\nПричина: {html.escape(reason)}'
|
||||
else:
|
||||
response_text = 'Ошибка включения режима техработ'
|
||||
|
||||
|
||||
@@ -643,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
👤 Админ: {html.escape(broadcast.admin_name or '')}
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
@@ -1477,7 +1477,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
f'• Не доставлено: {failed_count}\n'
|
||||
f'• Всего пользователей: {total_users_count}\n'
|
||||
f'• Успешность: {success_rate}%{media_info}\n\n'
|
||||
f'<b>Администратор:</b> {admin_name}'
|
||||
f'<b>Администратор:</b> {html.escape(admin_name)}'
|
||||
)
|
||||
|
||||
back_keyboard = types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -741,7 +742,7 @@ async def traffic_check_callback(callback: CallbackQuery):
|
||||
if violations:
|
||||
text += '\n⚠️ <b>Превышения дельты:</b>\n'
|
||||
for v in violations[:10]:
|
||||
name = v.full_name or v.user_uuid[:8]
|
||||
name = html.escape(v.full_name or '') or v.user_uuid[:8]
|
||||
text += f'• {name}: +{v.used_traffic_gb:.1f} ГБ\n'
|
||||
if len(violations) > 10:
|
||||
text += f'... и ещё {len(violations) - 10}\n'
|
||||
|
||||
@@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
lines = [f'🗳️ <b>{html.escape(poll.title)}</b>']
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
lines.append(html.escape(poll.description))
|
||||
|
||||
lines.append(_format_reward_text(poll, language))
|
||||
lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions)))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import structlog
|
||||
@@ -293,7 +294,7 @@ def _build_edit_menu_content(
|
||||
header = texts.t(
|
||||
'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE',
|
||||
'✏️ Настройки промогруппы «{name}»',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
@@ -468,7 +469,7 @@ async def show_promo_groups_menu(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
lines.append(f'{icon} <b>{group.name}</b>{default_suffix} — {members_label}')
|
||||
lines.append(f'{icon} <b>{html.escape(group.name)}</b>{default_suffix} — {members_label}')
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
@@ -524,7 +525,7 @@ async def show_promo_group_details(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUP_DETAILS_TITLE',
|
||||
'💳 <b>Промогруппа:</b> {name}',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
@@ -802,7 +803,7 @@ async def process_create_group_auto_assign(
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=html.escape(group.name)),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
@@ -875,7 +876,7 @@ async def prompt_edit_promo_group_field(
|
||||
prompt = texts.t(
|
||||
'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT',
|
||||
'Введите новое название промогруппы (текущее: {name}):',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
elif field == 'priority':
|
||||
await state.set_state(AdminStates.editing_promo_group_priority)
|
||||
prompt = texts.t(
|
||||
@@ -951,7 +952,7 @@ async def process_edit_group_name(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -993,7 +994,7 @@ async def process_edit_group_priority(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1028,7 +1029,7 @@ async def process_edit_group_traffic(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1063,7 +1064,7 @@ async def process_edit_group_servers(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1098,7 +1099,7 @@ async def process_edit_group_devices(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1138,7 +1139,7 @@ async def process_edit_group_period_discounts(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1182,7 +1183,7 @@ async def process_edit_group_auto_assign(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1212,19 +1213,20 @@ async def show_promo_group_members(
|
||||
title = texts.t(
|
||||
'ADMIN_PROMO_GROUP_MEMBERS_TITLE',
|
||||
'👥 Участники группы {name}',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
if not members:
|
||||
body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.')
|
||||
else:
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f'@{user.username}' if user.username else '—'
|
||||
username = f'@{html.escape(user.username)}' if user.username else '—'
|
||||
safe_name = html.escape(user.full_name or '')
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_name}</a>'
|
||||
tg_display = str(user.telegram_id)
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_link = f'<b>{safe_name}</b>'
|
||||
tg_display = user.email or f'#{user.id}'
|
||||
lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})')
|
||||
body = '\n'.join(lines)
|
||||
@@ -1273,7 +1275,7 @@ async def request_delete_promo_group(
|
||||
confirm_text = texts.t(
|
||||
'ADMIN_PROMO_GROUP_DELETE_CONFIRM',
|
||||
'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
@@ -1308,7 +1310,7 @@ async def delete_promo_group_confirmed(
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=html.escape(group.name)),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]]
|
||||
),
|
||||
|
||||
@@ -677,7 +677,7 @@ def _describe_offer(
|
||||
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
|
||||
icon = config.get('icon', '📨')
|
||||
|
||||
lines = [f'{icon} <b>{template.name}</b>', '']
|
||||
lines = [f'{icon} <b>{html.escape(template.name)}</b>', '']
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label))
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -94,7 +95,7 @@ async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db:
|
||||
text += f'📅 Дней: {promo.subscription_days}\n'
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f'🏷️ Промогруппа: {promo.promo_group.name}\n'
|
||||
text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)}\n'
|
||||
elif promo.type == PromoCodeType.DISCOUNT.value:
|
||||
discount_hours = promo.subscription_days
|
||||
if discount_hours > 0:
|
||||
@@ -170,7 +171,7 @@ async def show_promocode_management(callback: types.CallbackQuery, db_user: User
|
||||
text += f'📅 <b>Дней:</b> {promo.subscription_days}\n'
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f'🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n'
|
||||
text += f'🏷️ <b>Промогруппа:</b> {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n'
|
||||
elif promo.promo_group_id:
|
||||
text += f'🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n'
|
||||
elif promo.type == PromoCodeType.DISCOUNT.value:
|
||||
@@ -472,7 +473,9 @@ async def process_promocode_code(message: types.Message, db_user: User, state: F
|
||||
text = f'🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n'
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f'• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
|
||||
text += (
|
||||
f'• {html.escape(promo_group.name)} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
@@ -509,7 +512,7 @@ async def process_promo_group_selection(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🏷️ <b>Промокод для промогруппы</b>\n\n'
|
||||
f'Промогруппа: {promo_group.name}\n'
|
||||
f'Промогруппа: {html.escape(promo_group.name)}\n'
|
||||
f'Приоритет: {promo_group.priority}\n\n'
|
||||
f'📊 Введите количество использований промокода (или 0 для безлимита):'
|
||||
)
|
||||
@@ -1039,9 +1042,9 @@ async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db:
|
||||
use_date = format_datetime(use.used_at)
|
||||
|
||||
if hasattr(use, 'user_username') and use.user_username:
|
||||
user_display = f'@{use.user_username}'
|
||||
user_display = f'@{html.escape(use.user_username)}'
|
||||
elif hasattr(use, 'user_full_name') and use.user_full_name:
|
||||
user_display = use.user_full_name
|
||||
user_display = html.escape(use.user_full_name)
|
||||
elif hasattr(use, 'user_telegram_id'):
|
||||
user_display = f'ID{use.user_telegram_id}'
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -218,9 +219,9 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS
|
||||
id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A'
|
||||
|
||||
if username:
|
||||
display_text = f'@{username} (ID{id_display})'
|
||||
display_text = f'@{html.escape(username)} (ID{id_display})'
|
||||
elif display_name and display_name != f'ID{id_display}':
|
||||
display_text = f'{display_name} (ID{id_display})'
|
||||
display_text = f'{html.escape(display_name)} (ID{id_display})'
|
||||
else:
|
||||
display_text = f'ID{id_display}'
|
||||
|
||||
@@ -312,7 +313,7 @@ async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_use
|
||||
|
||||
for req in requests[:10]:
|
||||
user = await get_user_by_id(db, req.user_id)
|
||||
user_name = user.full_name if user else 'Неизвестно'
|
||||
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
|
||||
user_tg_id = user.telegram_id if user else 'N/A'
|
||||
|
||||
risk_emoji = (
|
||||
@@ -359,7 +360,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, request.user_id)
|
||||
user_name = user.full_name if user else 'Неизвестно'
|
||||
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
|
||||
user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A'
|
||||
|
||||
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
|
||||
@@ -381,7 +382,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
|
||||
📊 Статус: {status_text}
|
||||
|
||||
💳 <b>Реквизиты:</b>
|
||||
<code>{request.payment_details}</code>
|
||||
<code>{html.escape(request.payment_details or '')}</code>
|
||||
|
||||
📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
@@ -639,7 +640,7 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Тестовое начисление создано!</b>\n\n'
|
||||
f'👤 Пользователь: {target_user.full_name or "Без имени"}\n'
|
||||
f'👤 Пользователь: {html.escape(target_user.full_name) if target_user.full_name else "Без имени"}\n'
|
||||
f'🆔 ID: <code>{target_telegram_id}</code>\n'
|
||||
f'💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n'
|
||||
f'💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\n\n'
|
||||
@@ -736,14 +737,17 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
user_name = f'@{html.escape(lost.username)}'
|
||||
elif lost.full_name:
|
||||
user_name = html.escape(lost.full_name)
|
||||
else:
|
||||
user_name = f'ID{lost.telegram_id}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
referrer_info = f' → {html.escape(lost.expected_referrer_name)}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
@@ -751,7 +755,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
|
||||
time_str = lost.click_time.strftime('%H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
@@ -872,16 +876,22 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
# Показываем первые 10 деталей
|
||||
for i, detail in enumerate(fix_report.details[:10], 1):
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{detail.username}'
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
|
||||
if detail.error:
|
||||
text += f'{i}. {user_name} — ❌ {detail.error}\n'
|
||||
text += f'{i}. {user_name} — ❌ {html.escape(str(detail.error))}\n'
|
||||
else:
|
||||
text += f'{i}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
referrer_display = (
|
||||
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
|
||||
)
|
||||
text += f' • Реферер: {referrer_display}\n'
|
||||
if detail.had_first_topup:
|
||||
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
@@ -967,13 +977,19 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
|
||||
for detail in fix_report.details:
|
||||
if not detail.error and success_count < 10:
|
||||
success_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{user_name}'
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
|
||||
text += f'{success_count}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
referrer_display = (
|
||||
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
|
||||
)
|
||||
text += f' • Реферер: {referrer_display}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
|
||||
if detail.bonus_to_referrer_kopeks > 0:
|
||||
@@ -989,8 +1005,13 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
|
||||
for detail in fix_report.details:
|
||||
if detail.error and error_count < 5:
|
||||
error_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
text += f'• {user_name}: {detail.error}\n'
|
||||
if detail.username:
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
text += f'• {user_name}: {html.escape(str(detail.error))}\n'
|
||||
if fix_report.errors > 5:
|
||||
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
|
||||
|
||||
@@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db
|
||||
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
|
||||
"""
|
||||
for i, mb in enumerate(report.missing_bonuses[:15], 1):
|
||||
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
|
||||
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
|
||||
referral_name = html.escape(
|
||||
mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
|
||||
)
|
||||
referrer_name = html.escape(
|
||||
mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
|
||||
)
|
||||
text += f'\n{i}. <b>{referral_name}</b>'
|
||||
text += f'\n └ Пригласил: {referrer_name}'
|
||||
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽'
|
||||
@@ -1191,9 +1216,9 @@ async def sync_referrals_with_contest(
|
||||
total_created += stats.get('created', 0)
|
||||
total_updated += stats.get('updated', 0)
|
||||
total_skipped += stats.get('skipped', 0)
|
||||
contest_results.append(f'• {contest.title}: +{stats.get("created", 0)} новых')
|
||||
contest_results.append(f'• {html.escape(contest.title)}: +{stats.get("created", 0)} новых')
|
||||
else:
|
||||
contest_results.append(f'• {contest.title}: ошибка')
|
||||
contest_results.append(f'• {html.escape(contest.title)}: ошибка')
|
||||
|
||||
text = f"""
|
||||
🏆 <b>Синхронизация с конкурсами завершена!</b>
|
||||
@@ -1275,7 +1300,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
if file_ext not in ['.log', '.txt']:
|
||||
await message.answer(
|
||||
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
f'❌ Неверный формат файла: {html.escape(file_ext)}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
@@ -1299,7 +1324,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
# Информируем о начале загрузки
|
||||
status_message = await message.answer(
|
||||
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
f'📥 Загружаю файл {html.escape(file_name)} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
)
|
||||
|
||||
temp_file_path = None
|
||||
@@ -1316,7 +1341,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size)
|
||||
|
||||
# Обновляем статус
|
||||
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
|
||||
await status_message.edit_text(
|
||||
f'🔍 Анализирую файл {html.escape(file_name)}...\n\nЭто может занять некоторое время.'
|
||||
)
|
||||
|
||||
# Анализируем файл
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
@@ -1325,7 +1352,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
🔍 <b>Анализ лог-файла: {file_name}</b>
|
||||
🔍 <b>Анализ лог-файла: {html.escape(file_name)}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
@@ -1348,14 +1375,17 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
user_name = f'@{html.escape(lost.username)}'
|
||||
elif lost.full_name:
|
||||
user_name = html.escape(lost.full_name)
|
||||
else:
|
||||
user_name = f'ID{lost.telegram_id}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
referrer_info = f' → {html.escape(lost.expected_referrer_name)}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
@@ -1363,7 +1393,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
@@ -1408,8 +1438,8 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
f'❌ <b>Ошибка при анализе файла</b>\n\n'
|
||||
f'Файл: {file_name}\n'
|
||||
f'Ошибка: {e!s}\n\n'
|
||||
f'Файл: {html.escape(file_name)}\n'
|
||||
f'Ошибка: {html.escape(str(e))}\n\n'
|
||||
f'Проверьте, что файл является текстовым логом бота.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -1428,7 +1458,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
)
|
||||
except:
|
||||
await message.answer(
|
||||
f'❌ Ошибка при анализе файла: {e!s}',
|
||||
f'❌ Ошибка при анализе файла: {html.escape(str(e))}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Управление тарифами в админ-панели."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
@@ -317,7 +319,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
price_block = f'<b>Цены:</b>\n{prices_display}'
|
||||
tariff_type = '📅 Периодный'
|
||||
|
||||
return f"""📦 <b>Тариф: {tariff.name}</b>
|
||||
return f"""📦 <b>Тариф: {html.escape(tariff.name)}</b>
|
||||
|
||||
{status} | {tariff_type}
|
||||
🎚️ Уровень: {tariff.tier_level}
|
||||
@@ -343,7 +345,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
|
||||
📊 Подписок на тарифе: {subs_count}
|
||||
|
||||
{f'📝 {tariff.description}' if tariff.description else ''}"""
|
||||
{f'📝 {html.escape(tariff.description)}' if tariff.description else ''}"""
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -591,7 +593,7 @@ async def start_edit_daily_price(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'💰 <b>Редактирование суточной цены</b>\n\n'
|
||||
f'Тариф: {tariff.name}\n'
|
||||
f'Тариф: {html.escape(tariff.name)}\n'
|
||||
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
|
||||
'Введите новую цену за день в рублях.\n'
|
||||
'Пример: <code>50</code> или <code>99.90</code>',
|
||||
@@ -1011,7 +1013,7 @@ async def start_edit_tariff_name(
|
||||
await state.update_data(tariff_id=tariff_id, language=db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{tariff.name}</b>\n\nВведите новое название:',
|
||||
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{html.escape(tariff.name)}</b>\n\nВведите новое название:',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]]
|
||||
),
|
||||
@@ -1801,7 +1803,7 @@ async def start_edit_tariff_traffic_topup(
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: {status}\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -1887,7 +1889,7 @@ async def toggle_tariff_traffic_topup(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: {status}\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -1931,7 +1933,7 @@ async def start_edit_traffic_topup_packages(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📦 <b>Настройка пакетов докупки трафика</b>\n\n'
|
||||
f'Тариф: <b>{tariff.name}</b>\n\n'
|
||||
f'Тариф: <b>{html.escape(tariff.name)}</b>\n\n'
|
||||
f'<b>Текущие пакеты:</b>\n{packages_display}\n\n'
|
||||
'Введите пакеты в формате:\n'
|
||||
f'<code>{current_packages}</code>\n\n'
|
||||
@@ -2010,7 +2012,7 @@ async def process_edit_traffic_topup_packages(
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Пакеты обновлены!</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: ✅ Включено\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -2051,7 +2053,7 @@ async def start_edit_max_topup_traffic(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📊 <b>Максимальный лимит трафика</b>\n\n'
|
||||
f'Тариф: <b>{tariff.name}</b>\n'
|
||||
f'Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'Текущий лимит: <b>{current_display}</b>\n\n'
|
||||
f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n'
|
||||
f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n'
|
||||
@@ -2127,7 +2129,7 @@ async def process_edit_max_topup_traffic(
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Лимит обновлен!</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: ✅ Включено\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -2163,7 +2165,7 @@ async def confirm_delete_tariff(
|
||||
warning = f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{tariff.name}</b>?{warning}',
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{warning}',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
@@ -2278,7 +2280,7 @@ async def start_edit_tariff_squads(
|
||||
selected_count = len(current_squads)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {selected_count} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2341,7 +2343,7 @@ async def toggle_tariff_squad(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(current_squads)} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2406,7 +2408,7 @@ async def clear_tariff_squads(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: 0 из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2470,7 +2472,7 @@ async def select_all_tariff_squads(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(squads)} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2540,7 +2542,7 @@ async def start_edit_tariff_promo_groups(
|
||||
selected_count = len(current_groups)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {selected_count}\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2608,7 +2610,7 @@ async def toggle_tariff_promo_group(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(current_groups)}\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2665,7 +2667,7 @@ async def clear_tariff_promo_groups(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: 0\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2731,7 +2733,7 @@ async def start_edit_traffic_reset_mode(
|
||||
current_mode = getattr(tariff, 'traffic_reset_mode', None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n'
|
||||
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
|
||||
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
|
||||
@@ -2775,7 +2777,7 @@ async def set_traffic_reset_mode(
|
||||
|
||||
# Обновляем клавиатуру
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Текущий режим: {mode_display}\n\n'
|
||||
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
|
||||
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
|
||||
|
||||
@@ -232,8 +232,10 @@ async def view_admin_ticket(
|
||||
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else 'Unknown'
|
||||
telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else '—'
|
||||
user_name = html.escape(ticket.user.full_name) if ticket.user else 'Unknown'
|
||||
telegram_id_display = (
|
||||
html.escape(str(ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}')) if ticket.user else '—'
|
||||
)
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID'
|
||||
|
||||
@@ -245,7 +247,7 @@ async def view_admin_ticket(
|
||||
header += f'📱 Username: @{safe_username}\n'
|
||||
else:
|
||||
header += '📱 Username: отсутствует\n'
|
||||
header += f'📝 Заголовок: {ticket.title}\n'
|
||||
header += f'📝 Заголовок: {html.escape(ticket.title)}\n'
|
||||
header += f'📊 Статус: {ticket.status_emoji} {status_text}\n'
|
||||
header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||||
|
||||
@@ -261,7 +263,7 @@ async def view_admin_ticket(
|
||||
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
|
||||
for msg in ticket.messages:
|
||||
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
|
||||
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n'
|
||||
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{html.escape(msg.message_text)}\n\n'
|
||||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||||
block += '📎 Вложение: фото\n\n'
|
||||
message_blocks.append(block)
|
||||
@@ -801,10 +803,10 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'),
|
||||
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
|
||||
}.get(updated.status, updated.status)
|
||||
user_name = updated.user.full_name if updated.user else 'Unknown'
|
||||
user_name = html.escape(updated.user.full_name) if updated.user else 'Unknown'
|
||||
ticket_text = f'🎫 Тикет #{updated.id}\n\n'
|
||||
ticket_text += f'👤 Пользователь: {user_name}\n'
|
||||
ticket_text += f'📝 Заголовок: {updated.title}\n'
|
||||
ticket_text += f'📝 Заголовок: {html.escape(updated.title)}\n'
|
||||
ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n'
|
||||
ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
@@ -823,7 +825,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
ticket_text += f'🔗 Чат по ID: <a href="{chat_link}">{chat_link}</a>\n'
|
||||
elif updated.user:
|
||||
# Email-only user
|
||||
user_id_display = updated.user.email or f'#{updated.user.id}'
|
||||
user_id_display = html.escape(str(updated.user.email or f'#{updated.user.id}'))
|
||||
ticket_text += f'🆔 ID: <code>{user_id_display}</code>\n'
|
||||
ticket_text += '📧 Тип: Email-пользователь\n'
|
||||
ticket_text += '\n'
|
||||
@@ -837,7 +839,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
for msg in updated.messages:
|
||||
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
|
||||
ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n'
|
||||
ticket_text += f'{msg.message_text}\n\n'
|
||||
ticket_text += f'{html.escape(msg.message_text)}\n\n'
|
||||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||||
ticket_text += '📎 Вложение: фото\n\n'
|
||||
|
||||
|
||||
+61
-109
@@ -47,6 +47,7 @@ from app.services.user_service import UserService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.utils.formatting import user_html_link
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
@@ -822,12 +823,8 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
subscription = profile['subscription']
|
||||
|
||||
text = '📱 <b>Подписка и настройки пользователя</b>\n\n'
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_id_display = user.telegram_id
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
user_link = user_html_link(user)
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n\n'
|
||||
|
||||
keyboard = []
|
||||
@@ -849,7 +846,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
text += f'<b>Тариф:</b> 📦 {tariff.name}\n'
|
||||
text += f'<b>Тариф:</b> 📦 {html.escape(tariff.name)}\n'
|
||||
else:
|
||||
text += f'<b>Тариф:</b> ID {subscription.tariff_id} (удалён)\n'
|
||||
|
||||
@@ -961,12 +958,8 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = '💳 <b>Транзакции пользователя</b>\n\n'
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_id_display = user.telegram_id
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
user_link = user_html_link(user)
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n'
|
||||
text += f'💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n'
|
||||
|
||||
@@ -976,7 +969,7 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d
|
||||
for transaction in transactions:
|
||||
type_emoji = '📈' if transaction.amount_kopeks > 0 else '📉'
|
||||
text += f'{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n'
|
||||
text += f'📋 {transaction.description}\n'
|
||||
text += f'📋 {html.escape(transaction.description or "")}\n'
|
||||
text += f'📅 {format_datetime(transaction.created_at)}\n\n'
|
||||
else:
|
||||
text += '📭 <b>Транзакции отсутствуют</b>'
|
||||
@@ -1057,7 +1050,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC
|
||||
|
||||
if not search_results['users']:
|
||||
await message.answer(
|
||||
f"🔍 По запросу '<b>{query}</b>' ничего не найдено",
|
||||
f"🔍 По запросу '<b>{html.escape(query)}</b>' ничего не найдено",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
|
||||
),
|
||||
@@ -1065,7 +1058,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
text = f"🔍 <b>Результаты поиска:</b> '{query}'\n\n"
|
||||
text = f"🔍 <b>Результаты поиска:</b> '{html.escape(query)}'\n\n"
|
||||
text += 'Выберите пользователя:'
|
||||
|
||||
keyboard = []
|
||||
@@ -1175,7 +1168,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
sections = [
|
||||
texts.ADMIN_USER_MANAGEMENT_PROFILE.format(
|
||||
name=user.full_name,
|
||||
name=html.escape(user.full_name),
|
||||
telegram_id=user.telegram_id,
|
||||
username=username_display,
|
||||
status=status_text,
|
||||
@@ -1223,11 +1216,11 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
texts.t(
|
||||
'ADMIN_USER_PROMO_GROUPS_PRIMARY',
|
||||
'⭐ Основная: {name} (Priority: {priority})',
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0))
|
||||
).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0))
|
||||
)
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
|
||||
name=primary_group.name,
|
||||
name=html.escape(primary_group.name),
|
||||
server_discount=primary_group.server_discount_percent,
|
||||
traffic_discount=primary_group.traffic_discount_percent,
|
||||
device_discount=primary_group.device_discount_percent,
|
||||
@@ -1249,7 +1242,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
)
|
||||
for group in additional_groups:
|
||||
sections.append(f' • {group.name} (Priority: {getattr(group, "priority", 0)})')
|
||||
sections.append(f' • {html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})')
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
@@ -1264,7 +1257,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
restriction_lines.append(' • 🚫 Продление/покупка запрещена')
|
||||
restriction_reason = getattr(user, 'restriction_reason', None)
|
||||
if restriction_reason:
|
||||
restriction_lines.append(f' 📝 Причина: {restriction_reason}')
|
||||
restriction_lines.append(f' 📝 Причина: {html.escape(restriction_reason)}')
|
||||
sections.append('\n'.join(restriction_lines))
|
||||
|
||||
text = '\n\n'.join(sections)
|
||||
@@ -1321,7 +1314,7 @@ async def _build_user_referrals_view(
|
||||
'ADMIN_USER_REFERRALS_SUMMARY',
|
||||
'👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}',
|
||||
).format(
|
||||
name=user.full_name,
|
||||
name=html.escape(user.full_name),
|
||||
telegram_id=user.telegram_id,
|
||||
count=len(referrals),
|
||||
)
|
||||
@@ -1356,11 +1349,12 @@ async def _build_user_referrals_view(
|
||||
items = []
|
||||
for referral in referrals[:limit]:
|
||||
username_part = f', @{referral.username}' if referral.username else ''
|
||||
safe_name = html.escape(referral.full_name)
|
||||
if referral.telegram_id:
|
||||
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
|
||||
referral_link = f'<a href="tg://user?id={referral.telegram_id}">{safe_name}</a>'
|
||||
referral_id_display = referral.telegram_id
|
||||
else:
|
||||
referral_link = f'<b>{referral.full_name}</b>'
|
||||
referral_link = f'<b>{safe_name}</b>'
|
||||
referral_id_display = referral.email or f'#{referral.id}'
|
||||
items.append(
|
||||
texts.t(
|
||||
@@ -1737,7 +1731,7 @@ async def start_edit_user_referrals(
|
||||
'Или нажмите кнопку ниже, чтобы отменить.'
|
||||
),
|
||||
).format(
|
||||
name=user.full_name,
|
||||
name=html.escape(user.full_name),
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
@@ -1972,7 +1966,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user:
|
||||
current_line = texts.t(
|
||||
'ADMIN_USER_PROMO_GROUPS_PRIMARY',
|
||||
'⭐ Основная: {name} (Priority: {priority})',
|
||||
).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0))
|
||||
).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0))
|
||||
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
|
||||
servers=primary_group.server_discount_percent,
|
||||
@@ -1997,7 +1991,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user:
|
||||
+ '\n'
|
||||
)
|
||||
for group in additional_groups:
|
||||
additional_line += f' • {group.name} (Priority: {getattr(group, "priority", 0)})\n'
|
||||
additional_line += f' • {html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})\n'
|
||||
discount_line += additional_line
|
||||
else:
|
||||
current_line = texts.t(
|
||||
@@ -2388,7 +2382,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
text_lines = [
|
||||
'⚠️ <b>Ограничения пользователя</b>',
|
||||
f'👤 {user.full_name}',
|
||||
f'👤 {html.escape(user.full_name)}',
|
||||
'',
|
||||
'✅ — разрешено, 🚫 — запрещено',
|
||||
'',
|
||||
@@ -2398,7 +2392,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
if restriction_reason:
|
||||
text_lines.append('')
|
||||
text_lines.append(f'📝 <b>Причина:</b> {restriction_reason}')
|
||||
text_lines.append(f'📝 <b>Причина:</b> {html.escape(restriction_reason)}')
|
||||
|
||||
keyboard = get_user_restrictions_keyboard(
|
||||
user_id=user_id,
|
||||
@@ -2479,7 +2473,7 @@ async def ask_restriction_reason(callback: types.CallbackQuery, db_user: User, d
|
||||
'выполнить запрещённое действие.\n\n'
|
||||
)
|
||||
if current_reason:
|
||||
text += f'Текущая причина: <i>{current_reason}</i>\n\n'
|
||||
text += f'Текущая причина: <i>{html.escape(current_reason)}</i>\n\n'
|
||||
text += 'Отправьте новую причину или /cancel для отмены:'
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -2525,12 +2519,12 @@ async def save_restriction_reason(message: types.Message, db_user: User, db: Asy
|
||||
'✅ <b>Причина ограничения сохранена</b>',
|
||||
'',
|
||||
'⚠️ <b>Ограничения пользователя</b>',
|
||||
f'👤 {user.full_name}',
|
||||
f'👤 {html.escape(user.full_name)}',
|
||||
'',
|
||||
f'{"🚫" if restriction_topup else "✅"} Пополнение баланса',
|
||||
f'{"🚫" if restriction_subscription else "✅"} Продление/покупка подписки',
|
||||
'',
|
||||
f'📝 <b>Причина:</b> {reason}',
|
||||
f'📝 <b>Причина:</b> {html.escape(reason)}',
|
||||
]
|
||||
|
||||
keyboard = get_user_restrictions_keyboard(
|
||||
@@ -2596,12 +2590,8 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
text += '\n'
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_id_display = user.telegram_id
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
user_link = user_html_link(user)
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
has_active = user.subscription and user.subscription.is_active
|
||||
sub_badge = ' 🛡️' if has_active else ''
|
||||
text += f'👤 {user_link}{sub_badge}\n'
|
||||
@@ -2691,12 +2681,8 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
|
||||
|
||||
text = '📊 <b>Статистика пользователя</b>\n\n'
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_id_display = user.telegram_id
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
user_link = user_html_link(user)
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link} (ID: <code>{user_id_display}</code>)\n\n'
|
||||
|
||||
text += '<b>Основная информация:</b>\n'
|
||||
@@ -2721,13 +2707,13 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
if user.referred_by_id:
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if referrer:
|
||||
text += f'• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n'
|
||||
text += f'• Пришел по реферальной ссылке от <b>{html.escape(referrer.full_name)}</b>\n'
|
||||
else:
|
||||
text += '• Пришел по реферальной ссылке (реферер не найден)\n'
|
||||
if campaign_registration and campaign_registration.campaign:
|
||||
text += f'• Дополнительно зарегистрирован через кампанию <b>{campaign_registration.campaign.name}</b>\n'
|
||||
text += f'• Дополнительно зарегистрирован через кампанию <b>{html.escape(campaign_registration.campaign.name)}</b>\n'
|
||||
elif campaign_registration and campaign_registration.campaign:
|
||||
text += f'• Регистрация через рекламную кампанию <b>{campaign_registration.campaign.name}</b>\n'
|
||||
text += f'• Регистрация через рекламную кампанию <b>{html.escape(campaign_registration.campaign.name)}</b>\n'
|
||||
if campaign_registration.created_at:
|
||||
text += f'• Дата регистрации по кампании: {campaign_registration.created_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
else:
|
||||
@@ -2737,7 +2723,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
if campaign_registration and campaign_registration.campaign and campaign_stats:
|
||||
text += '<b>Рекламная кампания:</b>\n'
|
||||
text += f'• Название: <b>{campaign_registration.campaign.name}</b>'
|
||||
text += f'• Название: <b>{html.escape(campaign_registration.campaign.name)}</b>'
|
||||
if campaign_registration.campaign.start_parameter:
|
||||
text += f' (параметр: <code>{campaign_registration.campaign.start_parameter}</code>)'
|
||||
text += '\n'
|
||||
@@ -2771,7 +2757,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
if referral_stats['referrals_detail']:
|
||||
text += '\n<b>Детали по рефералам:</b>\n'
|
||||
for detail in referral_stats['referrals_detail'][:5]:
|
||||
referral_name = detail['referral_name']
|
||||
referral_name = html.escape(detail['referral_name'])
|
||||
earned = settings.format_price(detail['total_earned_kopeks'])
|
||||
status = '🟢' if detail['is_active'] else '🔴'
|
||||
text += f'• {status} {referral_name}: {earned}\n'
|
||||
@@ -4226,7 +4212,7 @@ async def change_subscription_type(callback: types.CallbackQuery, db_user: User,
|
||||
current_type = '🎁 Триал' if subscription.is_trial else '💎 Платная'
|
||||
|
||||
text = '🔄 <b>Смена типа подписки</b>\n\n'
|
||||
text += f'👤 {profile["user"].full_name}\n'
|
||||
text += f'👤 {html.escape(profile["user"].full_name)}\n'
|
||||
text += f'📱 Текущий тип: {current_type}\n\n'
|
||||
text += 'Выберите новый тип подписки:'
|
||||
|
||||
@@ -4307,12 +4293,8 @@ async def admin_buy_subscription(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
|
||||
text = '💳 <b>Покупка подписки для пользователя</b>\n\n'
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
text += f'💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n'
|
||||
traffic_text = 'Безлимит' if (subscription.traffic_limit_gb or 0) <= 0 else f'{subscription.traffic_limit_gb} ГБ'
|
||||
@@ -4400,12 +4382,8 @@ async def admin_buy_subscription_confirm(callback: types.CallbackQuery, db_user:
|
||||
return
|
||||
|
||||
text = '💳 <b>Подтверждение покупки подписки</b>\n\n'
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
text += f'📅 Период подписки: {period_days} дней\n'
|
||||
text += f'💰 Стоимость: {settings.format_price(price_kopeks)}\n'
|
||||
@@ -4646,12 +4624,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
else:
|
||||
message = '❌ Ошибка: у пользователя нет существующей подписки'
|
||||
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
await callback.message.edit_text(
|
||||
f'{message}\n\n'
|
||||
f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
@@ -4727,12 +4701,8 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
text = '💳 <b>Покупка тарифа для пользователя</b>\n\n'
|
||||
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
|
||||
@@ -4742,7 +4712,7 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
traffic = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
|
||||
prices = tariff.period_prices or {}
|
||||
min_price = min(prices.values()) if prices else 0
|
||||
text += f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n'
|
||||
text += f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n'
|
||||
|
||||
keyboard = []
|
||||
for tariff in tariffs:
|
||||
@@ -4787,18 +4757,14 @@ async def admin_buy_tariff_period(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.answer('❌ Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
|
||||
|
||||
text = '💳 <b>Покупка тарифа для пользователя</b>\n\n'
|
||||
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
|
||||
text += f'📦 <b>Тариф: {tariff.name}</b>\n'
|
||||
text += f'📦 <b>Тариф: {html.escape(tariff.name)}</b>\n'
|
||||
text += f'📊 Трафик: {traffic}\n'
|
||||
text += f'📱 Устройств: {tariff.device_limit}\n'
|
||||
text += f'🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n'
|
||||
@@ -4876,18 +4842,14 @@ async def admin_buy_tariff_confirm(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
|
||||
|
||||
text = '💳 <b>Подтверждение покупки тарифа</b>\n\n'
|
||||
text += f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n'
|
||||
text += f'📦 <b>Тариф: {tariff.name}</b>\n'
|
||||
text += f'📦 <b>Тариф: {html.escape(tariff.name)}</b>\n'
|
||||
text += f'📊 Трафик: {traffic}\n'
|
||||
text += f'📱 Устройств: {tariff.device_limit}\n'
|
||||
text += f'📅 Период: {period} дней\n'
|
||||
@@ -5055,18 +5017,14 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
description=f'Покупка тарифа {tariff.name} на {period} дней (администратор)',
|
||||
)
|
||||
|
||||
if target_user.telegram_id:
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
target_user_id_display = target_user.telegram_id
|
||||
else:
|
||||
target_user_link = f'<b>{target_user.full_name}</b>'
|
||||
target_user_id_display = target_user.email or f'#{target_user.id}'
|
||||
target_user_link = user_html_link(target_user)
|
||||
target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}'
|
||||
traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно куплен!</b>\n\n'
|
||||
f'👤 {target_user_link} (ID: {target_user_id_display})\n'
|
||||
f'📦 Тариф: {tariff.name}\n'
|
||||
f'📦 Тариф: {html.escape(tariff.name)}\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {period} дней\n'
|
||||
@@ -5090,7 +5048,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.bot.send_message(
|
||||
chat_id=target_user.telegram_id,
|
||||
text=f'💳 <b>Администратор оформил вам тариф</b>\n\n'
|
||||
f'📦 Тариф: {tariff.name}\n'
|
||||
f'📦 Тариф: {html.escape(tariff.name)}\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {period} дней\n'
|
||||
@@ -5234,14 +5192,11 @@ async def show_admin_tariff_change(callback: types.CallbackQuery, db_user: User,
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
text = '📦 <b>Смена тарифа пользователя</b>\n\n'
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b> ({user.email or f"#{user.id}"})'
|
||||
user_link = user_html_link(user)
|
||||
text += f'👤 {user_link}\n\n'
|
||||
|
||||
if current_tariff:
|
||||
text += f'<b>Текущий тариф:</b> {current_tariff.name}\n\n'
|
||||
text += f'<b>Текущий тариф:</b> {html.escape(current_tariff.name)}\n\n'
|
||||
else:
|
||||
text += '<b>Текущий тариф:</b> не установлен\n\n'
|
||||
|
||||
@@ -5307,12 +5262,9 @@ async def select_admin_tariff_change(callback: types.CallbackQuery, db_user: Use
|
||||
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
|
||||
|
||||
text = '📦 <b>Подтверждение смены тарифа</b>\n\n'
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b> ({user.email or f"#{user.id}"})'
|
||||
user_link = user_html_link(user)
|
||||
text += f'👤 {user_link}\n\n'
|
||||
text += f'<b>Новый тариф:</b> {tariff.name}\n'
|
||||
text += f'<b>Новый тариф:</b> {html.escape(tariff.name)}\n'
|
||||
text += f'• Устройства: {tariff.device_limit}\n'
|
||||
text += f'• Трафик: {traffic_str}\n'
|
||||
text += f'• Серверы: {servers_count}\n\n'
|
||||
@@ -5431,7 +5383,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно изменен</b>\n\n'
|
||||
f'Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'• Устройства: {subscription.device_limit}\n'
|
||||
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
|
||||
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
|
||||
@@ -5451,7 +5403,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
await db.rollback()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Ошибка смены тарифа</b>\n\nДетали: {e!s}',
|
||||
f'❌ <b>Ошибка смены тарифа</b>\n\nДетали: {html.escape(str(e))}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for CloudPayments balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -137,7 +139,7 @@ async def process_cloudpayments_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -203,7 +205,7 @@ async def start_cloudpayments_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -21,7 +23,7 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -85,7 +87,7 @@ async def process_cryptobot_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -161,7 +161,7 @@ async def process_freekassa_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -250,7 +250,7 @@ async def _start_freekassa_topup_impl(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -28,7 +29,7 @@ async def start_heleket_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -99,7 +100,7 @@ async def process_heleket_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for KassaAI balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -45,7 +47,7 @@ async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User)
|
||||
if not getattr(db_user, 'restriction_topup', False):
|
||||
return False
|
||||
texts = get_texts(db_user.language)
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -173,7 +175,7 @@ async def process_kassa_ai_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
@@ -233,7 +235,7 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
|
||||
text += f'{emoji} {amount_text}\n'
|
||||
text += f'📝 {transaction.description}\n'
|
||||
text += f'📝 {html.escape(transaction.description or "")}\n'
|
||||
text += f'📅 {transaction.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||||
|
||||
keyboard = []
|
||||
@@ -266,7 +268,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -563,7 +565,16 @@ async def handle_topup_amount_callback(
|
||||
|
||||
try:
|
||||
# Особые случаи, требующие специальной логики
|
||||
if method == 'platega':
|
||||
if method.startswith('platega_m'):
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
platega_method_code = int(method[len('platega_m') :])
|
||||
await state.update_data(payment_method='platega', platega_method=platega_method_code)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
elif method == 'platega':
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
from .platega import process_platega_payment_amount, start_platega_payment
|
||||
@@ -633,13 +644,17 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith('pal24_method_'),
|
||||
)
|
||||
|
||||
from .platega import handle_platega_method_selection, start_platega_payment
|
||||
from .platega import handle_platega_method_selection, start_platega_direct_method, start_platega_payment
|
||||
|
||||
dp.callback_query.register(start_platega_payment, F.data == 'topup_platega')
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith('platega_method_'),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_platega_direct_method,
|
||||
F.data.regexp(r'^topup_platega_m\d+$'),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -25,7 +27,7 @@ async def start_mulenpay_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -92,7 +94,7 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -262,7 +262,7 @@ async def start_pal24_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -330,7 +330,7 @@ async def process_pal24_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -98,7 +100,7 @@ async def start_platega_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -189,6 +191,55 @@ async def handle_platega_method_selection(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_direct_method(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Handle direct Platega method selection from the main payment screen (inline mode)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
method_code = int(callback.data.removeprefix('topup_platega_m'))
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('❌ Некорректный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n'
|
||||
'Если вы считаете это ошибкой, вы можете обжаловать решение.',
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'PLATEGA_TEMPORARILY_UNAVAILABLE',
|
||||
'❌ Оплата через Platega временно недоступна',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer('⚠️ Этот способ сейчас недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
@@ -201,7 +252,7 @@ async def process_platega_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for RioPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -142,7 +144,7 @@ async def process_riopay_payment_amount(
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
@@ -202,7 +204,7 @@ async def start_riopay_topup(
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for SeverPay balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -142,7 +144,7 @@ async def process_severpay_payment_amount(
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
@@ -202,7 +204,7 @@ async def start_severpay_topup(
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -25,7 +27,7 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -62,7 +64,7 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
|
||||
@@ -19,7 +21,7 @@ async def start_tribute_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -28,7 +29,7 @@ async def start_wata_payment(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -88,7 +89,7 @@ async def process_wata_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -24,7 +25,7 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -70,7 +71,7 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -118,7 +119,7 @@ async def process_yookassa_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -273,7 +274,7 @@ async def process_yookassa_sbp_payment_amount(
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1183,12 +1183,13 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
if tariff:
|
||||
is_daily_tariff = getattr(tariff, 'is_daily', False)
|
||||
# Формируем краткий блок информации о тарифе для главного меню
|
||||
tariff_info_block = f'\n📦 Тариф: {tariff.name}'
|
||||
tariff_info_block = f'\n📦 Тариф: {html.escape(tariff.name)}'
|
||||
except Exception as e:
|
||||
logger.debug('Не удалось загрузить тариф для главного меню', error=e)
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff)
|
||||
user_name=html.escape(user.full_name or ''),
|
||||
subscription_status=_get_subscription_status(user, texts, is_daily_tariff),
|
||||
)
|
||||
|
||||
# Добавляем информацию о тарифе перед "Выберите действие"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -39,7 +40,7 @@ async def _render_question_text(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f'🗳️ <b>{poll_title}</b>', '', header, '', question.text]
|
||||
lines = [f'🗳️ <b>{html.escape(poll_title)}</b>', '', header, '', html.escape(question.text)]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
|
||||
+19
-34
@@ -507,10 +507,9 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
|
||||
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
bonus_block = ''
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
invite_text += '\n\n' + texts.t(
|
||||
bonus_block = '\n\n' + texts.t(
|
||||
'REFERRAL_INVITE_BONUS',
|
||||
'💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!',
|
||||
).format(
|
||||
@@ -518,40 +517,26 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
)
|
||||
|
||||
invite_text += (
|
||||
'\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение')
|
||||
+ '\n'
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру')
|
||||
+ '\n'
|
||||
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
|
||||
+ '\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
|
||||
+ f'\n{bot_referral_link}'
|
||||
)
|
||||
|
||||
cabinet_block = ''
|
||||
if cabinet_referral_link:
|
||||
invite_text += (
|
||||
'\n\n'
|
||||
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
|
||||
+ f'\n{cabinet_referral_link}'
|
||||
)
|
||||
cabinet_block = f'\n\n🌐 {cabinet_referral_link}'
|
||||
|
||||
# Compact share text for switch_inline_query (256-char limit)
|
||||
share_text = invite_text
|
||||
if len(share_text) > 256:
|
||||
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
|
||||
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
|
||||
share_text += f'\n🌐 {cabinet_referral_link}'
|
||||
share_text = share_text[:256]
|
||||
invite_text = texts.t(
|
||||
'REFERRAL_INVITE_TEXT',
|
||||
'🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n'
|
||||
'🚀 Быстрое подключение\n'
|
||||
'🌍 Серверы по всему миру\n'
|
||||
'🔒 Надежная защита\n\n'
|
||||
'👇 Переходи по ссылке:\n'
|
||||
'{link}{cabinet_block}',
|
||||
).format(
|
||||
bonus_block=bonus_block,
|
||||
link=bot_referral_link,
|
||||
cabinet_block=cabinet_block,
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
|
||||
]
|
||||
)
|
||||
@@ -563,10 +548,10 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
+ '\n\n'
|
||||
+ texts.t(
|
||||
'REFERRAL_INVITE_CREATED_INSTRUCTION',
|
||||
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
|
||||
'Нажмите на текст ниже, чтобы скопировать:',
|
||||
)
|
||||
+ '\n\n'
|
||||
f'<code>{html_escape(invite_text)}</code>'
|
||||
f'<blockquote><code>{html_escape(invite_text)}</code></blockquote>'
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ async def start_simple_subscription_purchase(
|
||||
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import structlog
|
||||
@@ -136,7 +137,7 @@ async def _handle_wheel_spin_payment(
|
||||
emoji = selected_prize.emoji or '🎁'
|
||||
await message.answer(
|
||||
f'🎰 <b>Колесо удачи!</b>\n\n'
|
||||
f'{emoji} <b>{selected_prize.display_name}</b>\n\n'
|
||||
f'{emoji} <b>{html.escape(selected_prize.display_name)}</b>\n\n'
|
||||
f'{prize_message}\n\n'
|
||||
f'⭐ Потрачено: {stars_amount} Stars',
|
||||
parse_mode='HTML',
|
||||
|
||||
+66
-159
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -8,7 +9,6 @@ from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -43,6 +43,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.phantom_service import claim_phantom, merge_phantom_into_user, sync_remnawave_after_phantom_merge
|
||||
from app.services.pinned_message_service import (
|
||||
deliver_pinned_message_to_user,
|
||||
get_active_pinned_message,
|
||||
@@ -117,7 +118,7 @@ async def _activate_pending_gift_after_registration(
|
||||
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
await db.flush()
|
||||
await svc_activate(db, gift_purchase.token, skip_notification=True)
|
||||
tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else ''
|
||||
tariff_name = html.escape(gift_purchase.tariff.name) if gift_purchase.tariff else ''
|
||||
await answer_func(
|
||||
f'🎁 <b>Подарок активирован!</b>\n'
|
||||
f'{tariff_name} — {gift_purchase.period_days} дн.\n\n'
|
||||
@@ -131,147 +132,6 @@ async def _activate_pending_gift_after_registration(
|
||||
)
|
||||
|
||||
|
||||
async def _claim_phantom_user(
|
||||
db: AsyncSession,
|
||||
phantom: 'User',
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language: str,
|
||||
referrer_id: int | None,
|
||||
) -> tuple[bool, 'User | None']:
|
||||
"""Claim a phantom user by backfilling Telegram profile data.
|
||||
|
||||
Returns (success, user). On IntegrityError falls back to existing user lookup.
|
||||
|
||||
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
|
||||
by username only. Since Telegram usernames are changeable and reassignable, this is
|
||||
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
|
||||
purchase time, telegram_id is stored on the user and the phantom path is not used.
|
||||
"""
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
phantom.telegram_id = telegram_id
|
||||
phantom.username = username
|
||||
phantom.first_name = sanitize_telegram_name(first_name)
|
||||
phantom.last_name = sanitize_telegram_name(last_name)
|
||||
phantom.language = language
|
||||
phantom.status = UserStatus.ACTIVE.value
|
||||
if referrer_id and referrer_id != phantom.id:
|
||||
phantom.referred_by_id = referrer_id
|
||||
if not phantom.referral_code:
|
||||
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
phantom.updated_at = datetime.now(UTC)
|
||||
phantom.last_activity = datetime.now(UTC)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'IntegrityError claiming phantom user, falling back to existing user lookup',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
existing = await get_user_by_telegram_id(db, telegram_id)
|
||||
return False, existing
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
logger.info(
|
||||
'Claimed phantom user from guest purchase',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
|
||||
if phantom.subscription:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, phantom.subscription)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
'Failed to update Remnawave panel after phantom claim',
|
||||
phantom_user_id=phantom.id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
return True, phantom
|
||||
|
||||
|
||||
async def _merge_phantom_into_active_user(
|
||||
db: AsyncSession,
|
||||
phantom: 'User',
|
||||
active_user: 'User',
|
||||
) -> None:
|
||||
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
|
||||
|
||||
Transfers GuestPurchase records and handles subscription conflict.
|
||||
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
|
||||
audit trail and avoid CASCADE deletion of payment/transaction records.
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
logger.info(
|
||||
'Merging phantom user into active user',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=active_user.id,
|
||||
phantom_username=phantom.username,
|
||||
)
|
||||
|
||||
# Transfer GuestPurchase.user_id references
|
||||
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
|
||||
|
||||
# Transfer GuestPurchase.buyer_user_id references
|
||||
await db.execute(
|
||||
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
|
||||
)
|
||||
|
||||
# Transfer balance
|
||||
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
|
||||
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
|
||||
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
|
||||
|
||||
# Handle subscription
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
await db.refresh(active_user, ['subscription'])
|
||||
|
||||
if phantom.subscription and not active_user.subscription:
|
||||
# Transfer subscription from phantom to active user
|
||||
phantom.subscription.user_id = active_user.id
|
||||
# Transfer remnawave_uuid
|
||||
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
|
||||
active_user.remnawave_uuid = phantom.remnawave_uuid
|
||||
phantom.remnawave_uuid = None
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Transferred subscription from phantom to active user',
|
||||
subscription_id=phantom.subscription.id,
|
||||
)
|
||||
elif phantom.subscription:
|
||||
# Both have subscriptions — disable phantom's Remnawave user and free server slots
|
||||
logger.warning(
|
||||
'Both phantom and active user have subscriptions, disabling phantom',
|
||||
phantom_subscription_id=phantom.subscription.id,
|
||||
active_subscription_id=active_user.subscription.id,
|
||||
)
|
||||
if phantom.remnawave_uuid:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
|
||||
await decrement_subscription_server_counts(db, phantom.subscription)
|
||||
|
||||
# Soft-delete phantom: clear identifiers to prevent future matches,
|
||||
# preserve record for audit trail and avoid CASCADE deletion of payments/transactions
|
||||
phantom.status = UserStatus.DELETED.value
|
||||
phantom.username = None
|
||||
phantom.remnawave_uuid = None
|
||||
await db.flush()
|
||||
|
||||
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
@@ -322,13 +182,13 @@ async def _apply_campaign_bonus_if_needed(
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
amount=amount_text,
|
||||
name=campaign.name,
|
||||
name=html.escape(campaign.name),
|
||||
)
|
||||
|
||||
if result.bonus_type == 'subscription':
|
||||
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
|
||||
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
|
||||
name=campaign.name,
|
||||
name=html.escape(campaign.name),
|
||||
days=result.subscription_days,
|
||||
traffic=traffic_text,
|
||||
devices=result.subscription_device_limit,
|
||||
@@ -703,10 +563,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
phantom = await find_phantom_user_by_username(db, message.from_user.username)
|
||||
if phantom and phantom.id != user.id:
|
||||
try:
|
||||
await _merge_phantom_into_active_user(db, phantom, user)
|
||||
sub_transferred = await merge_phantom_into_user(db, phantom, user)
|
||||
await db.commit()
|
||||
await db.refresh(user, ['subscription'])
|
||||
if sub_transferred:
|
||||
await sync_remnawave_after_phantom_merge(db, user)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
await db.refresh(user, ['subscription'])
|
||||
logger.exception(
|
||||
'Failed to merge phantom user',
|
||||
phantom_id=phantom.id,
|
||||
@@ -1461,7 +1325,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
texts.t(
|
||||
'WELCOME_FALLBACK',
|
||||
'Добро пожаловать, {user_name}!',
|
||||
).format(user_name=existing_user.full_name)
|
||||
).format(user_name=html.escape(existing_user.full_name or ''))
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -1516,7 +1380,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
else None
|
||||
)
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
claimed, user = await claim_phantom(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=callback.from_user.id,
|
||||
@@ -1527,8 +1391,23 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
# IntegrityError fallback — use existing user
|
||||
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
|
||||
# Merge phantom's data into the existing user via full account merge service.
|
||||
sub_transferred = False
|
||||
if phantom.id != user.id:
|
||||
try:
|
||||
sub_transferred = await merge_phantom_into_user(db, phantom, user)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
'Failed to merge phantom into existing user during registration',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=user.id,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if sub_transferred:
|
||||
await sync_remnawave_after_phantom_merge(db, user)
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
@@ -1695,7 +1574,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
texts.t(
|
||||
'WELCOME_FALLBACK',
|
||||
'Добро пожаловать, {user_name}!',
|
||||
).format(user_name=user.full_name)
|
||||
).format(user_name=html.escape(user.full_name or ''))
|
||||
)
|
||||
|
||||
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
|
||||
@@ -1763,7 +1642,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
texts.t(
|
||||
'WELCOME_FALLBACK',
|
||||
'Добро пожаловать, {user_name}!',
|
||||
).format(user_name=existing_user.full_name)
|
||||
).format(user_name=html.escape(existing_user.full_name or ''))
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -1816,7 +1695,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
|
||||
)
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
claimed, user = await claim_phantom(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=message.from_user.id,
|
||||
@@ -1827,7 +1706,23 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
|
||||
# Merge phantom's data into the existing user via full account merge service.
|
||||
sub_transferred = False
|
||||
if phantom.id != user.id:
|
||||
try:
|
||||
sub_transferred = await merge_phantom_into_user(db, phantom, user)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
'Failed to merge phantom into existing user during registration',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=user.id,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if sub_transferred:
|
||||
await sync_remnawave_after_phantom_merge(db, user)
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
@@ -1898,7 +1793,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
logger.warning(
|
||||
'⚠️ Не удалось активировать промокод',
|
||||
promocode_to_activate=promocode_to_activate,
|
||||
get=promocode_result.get('error'),
|
||||
error=promocode_result.get('error'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e)
|
||||
@@ -2030,7 +1925,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
texts.t(
|
||||
'WELCOME_FALLBACK',
|
||||
'Добро пожаловать, {user_name}!',
|
||||
).format(user_name=user.full_name)
|
||||
).format(user_name=html.escape(user.full_name or ''))
|
||||
)
|
||||
|
||||
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
|
||||
@@ -2140,8 +2035,6 @@ def get_referral_code_keyboard(language: str):
|
||||
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
import html
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=html.escape(user.full_name or ''), subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
@@ -2189,8 +2082,6 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
import html
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=html.escape(user_name or ''), subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
@@ -2425,7 +2316,7 @@ async def required_sub_channel_check(
|
||||
else None
|
||||
)
|
||||
if phantom:
|
||||
claimed, user = await _claim_phantom_user(
|
||||
claimed, user = await claim_phantom(
|
||||
db,
|
||||
phantom,
|
||||
telegram_id=query.from_user.id,
|
||||
@@ -2436,7 +2327,23 @@ async def required_sub_channel_check(
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
if not claimed and user:
|
||||
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
|
||||
# Merge phantom's data into the existing user via full account merge service.
|
||||
sub_transferred = False
|
||||
if phantom.id != user.id:
|
||||
try:
|
||||
sub_transferred = await merge_phantom_into_user(db, phantom, user)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
'Failed to merge phantom into existing user during registration',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=user.id,
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
if sub_transferred:
|
||||
await sync_remnawave_after_phantom_merge(db, user)
|
||||
elif not claimed:
|
||||
logger.critical(
|
||||
'Phantom claim failed with no fallback user, proceeding to normal registration',
|
||||
|
||||
@@ -336,7 +336,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
tariff_type_str = '🔄 Суточный' if is_daily else '📅 Периодный'
|
||||
|
||||
tariff_info_lines = [
|
||||
f'<b>📦 {tariff.name}</b>',
|
||||
f'<b>📦 {html.escape(tariff.name)}</b>',
|
||||
f'Тип: {tariff_type_str}',
|
||||
f'Трафик: {tariff.traffic_limit_gb} ГБ' if tariff.traffic_limit_gb > 0 else 'Трафик: ∞ Безлимит',
|
||||
f'Устройства: {tariff.device_limit}',
|
||||
@@ -453,7 +453,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
full_name=html.escape(db_user.full_name or ''),
|
||||
balance=settings.format_price(db_user.balance_kopeks),
|
||||
status_emoji=status_emoji,
|
||||
status_display=status_display,
|
||||
@@ -761,7 +761,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
@@ -2068,7 +2068,7 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||
texts = get_texts(db_user.language)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
@@ -2921,6 +2921,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
return
|
||||
|
||||
if needs_resume:
|
||||
resume_transaction = None
|
||||
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
|
||||
if daily_price > 0 and is_inactive:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
@@ -2946,7 +2947,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
resume_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
@@ -2961,22 +2962,79 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
|
||||
subscription = await resume_daily_subscription(db, subscription)
|
||||
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
|
||||
# Восстанавливаем connected_squads из тарифа, если очищены деактивацией
|
||||
try:
|
||||
if not subscription.connected_squads:
|
||||
squads = tariff.allowed_squads or []
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
if squads:
|
||||
subscription.connected_squads = squads
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as sq_err:
|
||||
logger.warning('Не удалось восстановить connected_squads', error=sq_err)
|
||||
|
||||
# Синхронизируем с Remnawave - активируем пользователя
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
if getattr(db_user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
# POST может игнорировать activeInternalSquads — отправляем PATCH
|
||||
await db.refresh(db_user)
|
||||
if getattr(db_user, 'remnawave_uuid', None) and subscription.connected_squads:
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as patch_err:
|
||||
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
|
||||
logger.info(
|
||||
'✅ Синхронизировано с Remnawave после возобновления суточной подписки', subscription_id=subscription.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
|
||||
|
||||
# Отправляем уведомление администраторам о возобновлении суточной подписки
|
||||
if resume_transaction is not None:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=db_user,
|
||||
subscription=subscription,
|
||||
transaction=resume_transaction,
|
||||
period_days=1,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
except Exception as notif_err:
|
||||
logger.error('Не удалось отправить уведомление администраторам при возобновлении', error=notif_err)
|
||||
else:
|
||||
# Подписка активна, ставим на паузу
|
||||
subscription = await toggle_daily_subscription_pause(db, subscription)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Покупка подписки по тарифам."""
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -102,11 +103,11 @@ def format_tariffs_list_text(
|
||||
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
|
||||
|
||||
# Компактный формат: Название — 250 ГБ / 10 📱 от 179₽🔥
|
||||
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||||
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||||
|
||||
# Описание тарифа если есть
|
||||
if tariff.description:
|
||||
lines.append(f'<i>{tariff.description}</i>')
|
||||
lines.append(f'<i>{html.escape(tariff.description)}</i>')
|
||||
|
||||
lines.append('')
|
||||
|
||||
@@ -238,7 +239,7 @@ def format_tariff_info_for_user(
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
text = f"""📦 <b>{tariff.name}</b>
|
||||
text = f"""📦 <b>{html.escape(tariff.name)}</b>
|
||||
|
||||
<b>Параметры:</b>
|
||||
• Трафик: {traffic}
|
||||
@@ -246,7 +247,7 @@ def format_tariff_info_for_user(
|
||||
"""
|
||||
|
||||
if tariff.description:
|
||||
text += f'\n📝 {tariff.description}\n'
|
||||
text += f'\n📝 {html.escape(tariff.description)}\n'
|
||||
|
||||
if discount_percent > 0:
|
||||
text += f'\n🎁 <b>Ваша скидка: {discount_percent}%</b>\n'
|
||||
@@ -438,7 +439,7 @@ async def format_custom_tariff_preview(
|
||||
|
||||
traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
text = f"""📦 <b>{tariff.name}</b>
|
||||
text = f"""📦 <b>{html.escape(tariff.name)}</b>
|
||||
|
||||
<b>Настройте параметры:</b>
|
||||
"""
|
||||
@@ -554,7 +555,7 @@ async def select_tariff(
|
||||
if user_balance >= daily_price:
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: <b>Суточный</b>\n\n'
|
||||
@@ -589,7 +590,7 @@ async def select_tariff(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
@@ -991,7 +992,7 @@ async def handle_custom_confirm(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic_display}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {format_period(custom_days)}\n'
|
||||
@@ -1115,7 +1116,7 @@ async def select_tariff_period(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
@@ -1150,7 +1151,7 @@ async def select_tariff_period(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
f'💰 Стоимость: {format_price_kopeks(final_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -1364,7 +1365,7 @@ async def confirm_tariff_purchase(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
@@ -1597,7 +1598,7 @@ async def confirm_daily_tariff_purchase(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Суточная подписка оформлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
@@ -1738,7 +1739,7 @@ async def show_tariff_extend(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n\n'
|
||||
'Выберите период продления:',
|
||||
@@ -1805,7 +1806,7 @@ async def select_tariff_extend_period(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
@@ -1840,7 +1841,7 @@ async def select_tariff_extend_period(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
f'💰 К оплате: {format_price_kopeks(final_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -1982,7 +1983,7 @@ async def confirm_tariff_extend(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Добавлено: {format_period(period)}\n'
|
||||
@@ -2059,10 +2060,10 @@ def format_tariff_switch_list_text(
|
||||
discount_icon = '🔥'
|
||||
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
|
||||
|
||||
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||||
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||||
|
||||
if tariff.description:
|
||||
lines.append(f'<i>{tariff.description}</i>')
|
||||
lines.append(f'<i>{html.escape(tariff.description)}</i>')
|
||||
|
||||
lines.append('')
|
||||
|
||||
@@ -2198,7 +2199,7 @@ async def show_tariff_switch_list(
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||||
if current_tariff:
|
||||
current_tariff_name = current_tariff.name
|
||||
current_tariff_name = html.escape(current_tariff.name)
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
@@ -2269,7 +2270,7 @@ async def select_tariff_switch(
|
||||
if user_balance >= daily_price:
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение смены тарифа</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: <b>Суточный</b>\n\n'
|
||||
@@ -2295,7 +2296,7 @@ async def select_tariff_switch(
|
||||
missing = daily_price - user_balance
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
@@ -2312,7 +2313,7 @@ async def select_tariff_switch(
|
||||
)
|
||||
else:
|
||||
# Для обычного тарифа показываем выбор периода
|
||||
info_text = f"""📦 <b>{tariff.name}</b>
|
||||
info_text = f"""📦 <b>{html.escape(tariff.name)}</b>
|
||||
|
||||
<b>Параметры нового тарифа:</b>
|
||||
• Трафик: {traffic}
|
||||
@@ -2320,7 +2321,7 @@ async def select_tariff_switch(
|
||||
"""
|
||||
|
||||
if tariff.description:
|
||||
info_text += f'\n📝 {tariff.description}\n'
|
||||
info_text += f'\n📝 {html.escape(tariff.description)}\n'
|
||||
|
||||
info_text += '\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:'
|
||||
|
||||
@@ -2381,7 +2382,7 @@ async def select_tariff_switch_period(
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||||
if current_tariff:
|
||||
current_tariff_name = current_tariff.name
|
||||
current_tariff_name = html.escape(current_tariff.name)
|
||||
|
||||
# Получаем текущую подписку для расчёта оставшегося времени
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
@@ -2399,7 +2400,7 @@ async def select_tariff_switch_period(
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
|
||||
f'📌 Текущий тариф: <b>{current_tariff_name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'{time_info}\n'
|
||||
@@ -2414,7 +2415,7 @@ async def select_tariff_switch_period(
|
||||
missing = final_price - user_balance
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
f'💰 К оплате: {format_price_kopeks(final_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -2591,7 +2592,7 @@ async def confirm_tariff_switch(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'💰 Списано: {format_price_kopeks(final_price)}\n'
|
||||
@@ -2788,7 +2789,7 @@ async def confirm_daily_tariff_switch(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
@@ -2864,7 +2865,7 @@ def format_instant_switch_list_text(
|
||||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||||
lines = [
|
||||
'📦 <b>Мгновенная смена тарифа</b>',
|
||||
f'📌 Текущий: <b>{current_tariff.name}</b>',
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>',
|
||||
f'⏰ Осталось: <b>{remaining_days} дн.</b>',
|
||||
'',
|
||||
'💡 При переключении остаток дней сохраняется.',
|
||||
@@ -2888,10 +2889,10 @@ def format_instant_switch_list_text(
|
||||
else:
|
||||
cost_text = '⬇️ Бесплатно'
|
||||
|
||||
lines.append(f'<b>{tariff.name}</b> — {traffic} / {tariff.device_limit} 📱 {cost_text}')
|
||||
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {cost_text}')
|
||||
|
||||
if tariff.description:
|
||||
lines.append(f'<i>{tariff.description}</i>')
|
||||
lines.append(f'<i>{html.escape(tariff.description)}</i>')
|
||||
|
||||
lines.append('')
|
||||
|
||||
@@ -3109,10 +3110,10 @@ async def preview_instant_switch(
|
||||
if user_balance >= daily_price:
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Переключение на суточный тариф</b>\n\n'
|
||||
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
|
||||
f' • Трафик: {current_traffic}\n'
|
||||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||||
f'📦 Новый: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f' • Трафик: {traffic}\n'
|
||||
f' • Устройств: {new_tariff.device_limit}\n'
|
||||
f' • Тип: 🔄 Суточный\n\n'
|
||||
@@ -3128,7 +3129,7 @@ async def preview_instant_switch(
|
||||
missing = daily_price - user_balance
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||||
f'{discount_text}\n\n'
|
||||
@@ -3154,10 +3155,10 @@ async def preview_instant_switch(
|
||||
if user_balance >= upgrade_cost:
|
||||
await callback.message.edit_text(
|
||||
f'⬆️ <b>Повышение тарифа</b>\n\n'
|
||||
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
|
||||
f' • Трафик: {current_traffic}\n'
|
||||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||||
f'📦 Новый: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f' • Трафик: {traffic}\n'
|
||||
f' • Устройств: {new_tariff.device_limit}\n\n'
|
||||
f'⏰ Осталось дней: <b>{remaining_days}</b>\n'
|
||||
@@ -3171,7 +3172,7 @@ async def preview_instant_switch(
|
||||
missing = upgrade_cost - user_balance
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f'💰 Требуется доплата: {format_price_kopeks(upgrade_cost)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>',
|
||||
@@ -3182,10 +3183,10 @@ async def preview_instant_switch(
|
||||
# Downgrade или тот же уровень - бесплатно
|
||||
await callback.message.edit_text(
|
||||
f'⬇️ <b>Переключение тарифа</b>\n\n'
|
||||
f'📌 Текущий: <b>{current_tariff.name}</b>\n'
|
||||
f'📌 Текущий: <b>{html.escape(current_tariff.name)}</b>\n'
|
||||
f' • Трафик: {current_traffic}\n'
|
||||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||||
f'📦 Новый: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f' • Трафик: {traffic}\n'
|
||||
f' • Устройств: {new_tariff.device_limit}\n\n'
|
||||
f'⏰ Осталось дней: <b>{remaining_days}</b>\n'
|
||||
@@ -3433,7 +3434,7 @@ async def confirm_instant_switch(
|
||||
if is_new_daily:
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {new_tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
@@ -3455,7 +3456,7 @@ async def confirm_instant_switch(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||||
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
|
||||
f'📦 Новый тариф: <b>{html.escape(new_tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {new_tariff.device_limit}\n'
|
||||
f'⏰ Осталось дней: {remaining_days}\n'
|
||||
@@ -3509,7 +3510,7 @@ async def return_to_saved_tariff_cart(
|
||||
if cart_mode == 'daily_tariff_purchase':
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -3521,7 +3522,7 @@ async def return_to_saved_tariff_cart(
|
||||
period = cart_data.get('period_days', 30)
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -3533,7 +3534,7 @@ async def return_to_saved_tariff_cart(
|
||||
period = cart_data.get('period_days', 30)
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n'
|
||||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||||
@@ -3552,7 +3553,7 @@ async def return_to_saved_tariff_cart(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'🔄 Тип: Суточный\n'
|
||||
@@ -3572,7 +3573,7 @@ async def return_to_saved_tariff_cart(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
@@ -3602,7 +3603,7 @@ async def return_to_saved_tariff_cart(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📅 Период: {format_period(period)}\n'
|
||||
|
||||
+15
-11
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
import time
|
||||
|
||||
import structlog
|
||||
@@ -256,7 +257,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
# Ограничим длину подтверждения чтобы не упереться в лимиты
|
||||
safe_title = title if len(title) <= 200 else (title[:197] + '...')
|
||||
safe_title = html.escape(title if len(title) <= 200 else (title[:197] + '...'))
|
||||
creation_text = (
|
||||
f'✅ <b>Тикет #{ticket.id} создан</b>\n\n'
|
||||
f'📝 Заголовок: {safe_title}\n'
|
||||
@@ -542,7 +543,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
|
||||
header = (
|
||||
f'🎫 Тикет #{ticket.id}\n\n'
|
||||
f'📝 Заголовок: {ticket.title}\n'
|
||||
f'📝 Заголовок: {html.escape(ticket.title or "")}\n'
|
||||
f'📊 Статус: {ticket.status_emoji} {status_text}\n'
|
||||
f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n\n'
|
||||
)
|
||||
@@ -551,7 +552,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
|
||||
for msg in ticket.messages:
|
||||
sender = '👤 Вы' if msg.is_user_message else '🛠️ Поддержка'
|
||||
block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{msg.message_text}\n\n'
|
||||
block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{html.escape(msg.message_text or "")}\n\n'
|
||||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||||
block += '📎 Вложение: фото\n\n'
|
||||
message_blocks.append(block)
|
||||
@@ -1006,9 +1007,9 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else 'Unknown'
|
||||
full_name = html.escape(user.full_name or '') if user else 'Unknown'
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
|
||||
|
||||
# Загружаем первое сообщение для получения медиа и превью текста
|
||||
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
|
||||
@@ -1022,17 +1023,19 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
if msg_text:
|
||||
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
|
||||
|
||||
safe_title = html.escape(title) if title else '—'
|
||||
|
||||
notification_text = (
|
||||
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
|
||||
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
|
||||
f'👤 <b>Пользователь:</b> {full_name}\n'
|
||||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||||
f'📱 <b>Username:</b> @{username_display}\n'
|
||||
f'📝 <b>Заголовок:</b> {title or "—"}\n'
|
||||
f'📝 <b>Заголовок:</b> {safe_title}\n'
|
||||
)
|
||||
|
||||
if message_preview:
|
||||
notification_text += f'\n📩 <b>Сообщение:</b>\n{message_preview}\n'
|
||||
notification_text += f'\n📩 <b>Сообщение:</b>\n{html.escape(message_preview)}\n'
|
||||
|
||||
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||||
|
||||
@@ -1076,20 +1079,21 @@ async def notify_admins_about_ticket_reply(
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else 'Unknown'
|
||||
full_name = html.escape(user.full_name or '') if user else 'Unknown'
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует')
|
||||
|
||||
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
|
||||
safe_title = html.escape(title) if title else '—'
|
||||
|
||||
notification_text = (
|
||||
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
|
||||
f'🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n'
|
||||
f'📝 <b>Заголовок:</b> {title or "—"}\n'
|
||||
f'📝 <b>Заголовок:</b> {safe_title}\n'
|
||||
f'👤 <b>Пользователь:</b> {full_name}\n'
|
||||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||||
f'📱 <b>Username:</b> @{username_display}\n\n'
|
||||
f'📩 <b>Сообщение:</b>\n{reply_preview}\n'
|
||||
f'📩 <b>Сообщение:</b>\n{html.escape(reply_preview)}\n'
|
||||
)
|
||||
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
+19
-7
@@ -1612,14 +1612,26 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
platega_name = settings.get_platega_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'),
|
||||
callback_data=_build_callback('platega'),
|
||||
if settings.PLATEGA_INLINE_METHODS:
|
||||
for method_code in settings.get_platega_active_methods():
|
||||
title = settings.get_platega_method_display_title(method_code)
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{title} ({platega_name})',
|
||||
callback_data=_build_callback(f'platega_m{method_code}'),
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'),
|
||||
callback_data=_build_callback('platega'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
|
||||
@@ -1306,7 +1306,7 @@
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the text below to copy:",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
|
||||
@@ -1314,6 +1314,7 @@
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
|
||||
"REFERRAL_INVITE_TEXT": "🎉 Join the VPN service!{bonus_block}\n\n🚀 Fast connection\n🌍 Servers worldwide\n🔒 Reliable protection\n\n👇 Follow the link:\n{link}{cabinet_block}",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
@@ -1724,19 +1725,16 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
|
||||
|
||||
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
|
||||
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
|
||||
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
|
||||
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal",
|
||||
|
||||
"PAUSE_DAILY_BUTTON": "⏸️ Pause subscription",
|
||||
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Subscription activated</b>\n\nYour subscription is active again. Enjoy!",
|
||||
@@ -1755,4 +1753,4 @@
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
|
||||
}
|
||||
}
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 اولین شارژ",
|
||||
"REFERRAL_INFO": "🤝 <b>دعوت دوستان</b>\n\nلینک دعوت را به اشتراک بگذارید و از هر خرید کمیسیون بگیرید!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 با اولین شارژ از {minimum} مبلغ {bonus} پاداش دریافت میکنی!",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "دکمه «📤 اشتراکگذاری» را بزنید یا متن زیر را کپی کنید:",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "روی متن زیر بزنید تا کپی شود:",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>دعوتنامه ایجاد شد!</b>",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 اتصال سریع",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 امنیت بالا",
|
||||
@@ -1335,6 +1335,7 @@
|
||||
"REFERRAL_INVITE_FOOTER": "📢 دوستان را دعوت کنید و درآمد کسب کنید!",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 از لینک زیر استفاده کن:",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>دعوت به سرویس VPN</b>\n\nسلام! به سرویس VPN عالی دعوتت میکنم!\n\n🎁 با لینک من پاداش بگیر: {bonus}\n\n🔗 لینک: {link}\n🎫 یا کد: {code}\n\n💪 سریع، امن، مقرونبهصرفه!\n",
|
||||
"REFERRAL_INVITE_TEXT": "🎉 به سرویس VPN بپیوند!{bonus_block}\n\n🚀 اتصال سریع\n🌍 سرورهای سراسر جهان\n🔒 امنیت بالا\n\n👇 از لینک زیر استفاده کن:\n{link}{cabinet_block}",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
|
||||
|
||||
@@ -1327,7 +1327,7 @@
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите на текст ниже, чтобы скопировать:",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
|
||||
@@ -1335,6 +1335,7 @@
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
|
||||
"REFERRAL_INVITE_TEXT": "🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n🚀 Быстрое подключение\n🌍 Серверы по всему миру\n🔒 Надежная защита\n\n👇 Переходи по ссылке:\n{link}{cabinet_block}",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
|
||||
@@ -1243,7 +1243,7 @@
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Перше поповнення",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальна програма</b>\n\n👥 <b>Запрошено:</b> {referrals_count} друзів\n💰 <b>Зароблено:</b> {earned_amount}\n\n🔗 <b>Ваше реферальне посилання:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Умови:</b>\n• За кожного друга: {registration_bonus}\n• Відсоток з поповнень: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При першому поповненні від {minimum} ти отримаєш {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть кнопку «📤 Поділитися», щоб надіслати запрошення в будь-який чат, або скопіюйте текст нижче:",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть на текст нижче, щоб скопіювати:",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Запрошення створено!</b>",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Швидке підключення",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надійний захист",
|
||||
@@ -1251,6 +1251,7 @@
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Запрошуйте друзів та заробляйте!",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходь за посиланням:",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Запрошення до VPN сервісу</b>\n\nПривіт! Запрошую тебе у відмінний VPN сервіс!\n\n🎁 За моїм посиланням ти отримаєш бонус: {bonus}\n\n🔗 Переходь: {link}\n🎫 Або використовуй промокод: {code}\n\n💪 Швидко, надійно, недорого!\n",
|
||||
"REFERRAL_INVITE_TEXT": "🎉 Приєднуйся до VPN сервісу!{bonus_block}\n\n🚀 Швидке підключення\n🌍 Сервери по всьому світу\n🔒 Надійний захист\n\n👇 Переходь за посиланням:\n{link}{cabinet_block}",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
|
||||
|
||||
@@ -1241,7 +1241,7 @@
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉首次充值",
|
||||
"REFERRAL_INFO": "\n🤝<b>推荐计划</b>\n\n👥<b>已邀请:</b>{referrals_count}位朋友\n💰<b>已赚取:</b>{earned_amount}\n\n🔗<b>您的推荐链接:</b>\n<code>{referral_link}</code>\n\n🎫<b>您的优惠码:</b>\n<code>{referral_code}</code>\n\n💰<b>条件:</b>\n•每位朋友:{registration_bonus}\n•充值百分比:{commission_percent}%\n",
|
||||
"REFERRAL_INVITE_BONUS": "💎首次充值{minimum}起,您将获得{bonus}余额奖励!",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击“📤分享”按钮将邀请发送到任何聊天,或复制以下文本:",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击下方文本即可复制:",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝<b>邀请已创建!</b>",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀快速连接",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒可靠保护",
|
||||
@@ -1249,6 +1249,7 @@
|
||||
"REFERRAL_INVITE_FOOTER": "📢邀请朋友并赚钱!",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇点击链接:",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯<b>邀请加入VPN服务</b>\n\n嗨!我邀请您加入一个很棒的VPN服务!\n\n🎁通过我的链接,您将获得奖励:{bonus}\n\n🔗点击:{link}\n🎫或使用优惠码:{code}\n\n💪快速、可靠、不贵!\n",
|
||||
"REFERRAL_INVITE_TEXT": "🎉加入VPN服务!{bonus_block}\n\n🚀快速连接\n🌍全球服务器\n🔒可靠保护\n\n👇点击链接:\n{link}{cabinet_block}",
|
||||
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
|
||||
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
|
||||
|
||||
@@ -243,9 +243,13 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.debug('AuthMiddleware: bot blocked by user, skipping')
|
||||
return None
|
||||
except TelegramBadRequest as e:
|
||||
if 'query is too old' in str(e):
|
||||
error_msg = str(e).lower()
|
||||
if 'query is too old' in error_msg:
|
||||
logger.debug('AuthMiddleware: callback query expired, skipping')
|
||||
return None
|
||||
if 'message is not modified' in error_msg:
|
||||
logger.debug('AuthMiddleware: message not modified, skipping')
|
||||
return None
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Ошибка в AuthMiddleware', error=e)
|
||||
|
||||
@@ -23,9 +23,11 @@ from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FreekassaPayment,
|
||||
GuestPurchase,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
NewsArticle,
|
||||
Pal24Payment,
|
||||
PartnerApplication,
|
||||
PartnerStatus,
|
||||
@@ -40,10 +42,14 @@ from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralEarning,
|
||||
RioPayPayment,
|
||||
SavedPaymentMethod,
|
||||
SentNotification,
|
||||
SeverPayPayment,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
SupportAuditLog,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
@@ -78,6 +84,8 @@ _PAYMENT_MODELS: tuple[type, ...] = (
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
@@ -280,10 +288,12 @@ async def _handle_subscription_merge(
|
||||
if not has_primary_sub and has_secondary_sub:
|
||||
assert secondary_sub is not None
|
||||
secondary_sub.user_id = primary.id
|
||||
# Переносим remnawave_uuid с secondary на primary
|
||||
# Переносим remnawave_uuid (clear→flush→assign — unique constraint safety)
|
||||
if secondary.remnawave_uuid:
|
||||
primary.remnawave_uuid = secondary.remnawave_uuid
|
||||
uuid_to_transfer = secondary.remnawave_uuid
|
||||
secondary.remnawave_uuid = None
|
||||
await db.flush()
|
||||
primary.remnawave_uuid = uuid_to_transfer
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Мерж подписок: перенесена подписка secondary на primary',
|
||||
@@ -301,15 +311,19 @@ async def _handle_subscription_merge(
|
||||
if primary.remnawave_uuid:
|
||||
await _delete_remnawave_user_with_fallback(primary.remnawave_uuid)
|
||||
primary.remnawave_uuid = None
|
||||
# Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности)
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == primary_sub.id))
|
||||
# Удаляем запись подписки primary
|
||||
await db.delete(primary_sub)
|
||||
await db.flush()
|
||||
# Переносим подписку secondary на primary
|
||||
secondary_sub.user_id = primary.id
|
||||
# Переносим remnawave_uuid
|
||||
# Переносим remnawave_uuid (clear→flush→assign — unique constraint safety)
|
||||
if secondary.remnawave_uuid:
|
||||
primary.remnawave_uuid = secondary.remnawave_uuid
|
||||
uuid_to_transfer = secondary.remnawave_uuid
|
||||
secondary.remnawave_uuid = None
|
||||
await db.flush()
|
||||
primary.remnawave_uuid = uuid_to_transfer
|
||||
# Flush сразу — гарантируем, что DELETE предшествует UPDATE (unique constraint на subscription.user_id)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
@@ -323,6 +337,8 @@ async def _handle_subscription_merge(
|
||||
if secondary.remnawave_uuid:
|
||||
await _delete_remnawave_user_with_fallback(secondary.remnawave_uuid)
|
||||
secondary.remnawave_uuid = None
|
||||
# Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности)
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == secondary_sub.id))
|
||||
# Удаляем запись подписки secondary
|
||||
await db.delete(secondary_sub)
|
||||
await db.flush()
|
||||
@@ -492,6 +508,11 @@ async def execute_merge(
|
||||
for payment_model in _PAYMENT_MODELS:
|
||||
await db.execute(update(payment_model).where(payment_model.user_id == secondary.id).values(user_id=primary.id))
|
||||
|
||||
# 7b. Переназначение saved_payment_methods (FK без ondelete)
|
||||
await db.execute(
|
||||
update(SavedPaymentMethod).where(SavedPaymentMethod.user_id == secondary.id).values(user_id=primary.id)
|
||||
)
|
||||
|
||||
# 8. Переназначение referral_earnings
|
||||
# 8a. Удаляем cross-referral записи между участниками мержа (иначе станут self-referral)
|
||||
await db.execute(
|
||||
@@ -702,6 +723,13 @@ async def execute_merge(
|
||||
await db.execute(update(PinnedMessage).where(PinnedMessage.created_by == secondary.id).values(created_by=None))
|
||||
await db.execute(update(AdminRole).where(AdminRole.created_by == secondary.id).values(created_by=None))
|
||||
await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == secondary.id).values(created_by=None))
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.created_by == secondary.id).values(created_by=None))
|
||||
|
||||
# 10s. Переназначение guest_purchases (оба FK — buyer_user_id и user_id)
|
||||
await db.execute(
|
||||
update(GuestPurchase).where(GuestPurchase.buyer_user_id == secondary.id).values(buyer_user_id=primary.id)
|
||||
)
|
||||
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == secondary.id).values(user_id=primary.id))
|
||||
|
||||
# 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -77,11 +77,11 @@ class AdminNotificationService:
|
||||
return f'ID {referred_by_id} (не найден)'
|
||||
|
||||
if referrer.username:
|
||||
return f'@{referrer.username} (ID: {referred_by_id})'
|
||||
return f'@{html.escape(referrer.username)} (ID: {referred_by_id})'
|
||||
if referrer.telegram_id:
|
||||
return f'ID {referrer.telegram_id}'
|
||||
if referrer.email:
|
||||
return f'📧 {referrer.email}'
|
||||
return f'📧 {html.escape(referrer.email)}'
|
||||
return f'User#{referred_by_id}'
|
||||
|
||||
except Exception as e:
|
||||
@@ -118,17 +118,17 @@ class AdminNotificationService:
|
||||
def _get_user_display(self, user: User) -> str:
|
||||
first_name = getattr(user, 'first_name', '') or ''
|
||||
if first_name:
|
||||
return first_name
|
||||
return html.escape(first_name)
|
||||
|
||||
username = getattr(user, 'username', '') or ''
|
||||
if username:
|
||||
return username
|
||||
return html.escape(username)
|
||||
|
||||
telegram_id = getattr(user, 'telegram_id', None)
|
||||
if telegram_id is None:
|
||||
email = getattr(user, 'email', None)
|
||||
if email:
|
||||
return email
|
||||
return html.escape(email)
|
||||
return f'User#{getattr(user, "id", "Unknown")}'
|
||||
return f'ID{telegram_id}'
|
||||
|
||||
@@ -140,7 +140,7 @@ class AdminNotificationService:
|
||||
|
||||
email = getattr(user, 'email', None)
|
||||
if email:
|
||||
return f'📧 {email}'
|
||||
return f'📧 {html.escape(email)}'
|
||||
|
||||
return f'User#{getattr(user, "id", "Unknown")}'
|
||||
|
||||
@@ -249,7 +249,7 @@ class AdminNotificationService:
|
||||
if not promo_group:
|
||||
return f'{icon} <b>{title}:</b> —'
|
||||
|
||||
lines = [f'{icon} <b>{title}:</b> {promo_group.name}']
|
||||
lines = [f'{icon} <b>{title}:</b> {html.escape(promo_group.name)}']
|
||||
|
||||
discount_lines = self._format_promo_group_discounts(promo_group)
|
||||
if discount_lines:
|
||||
@@ -265,6 +265,8 @@ class AdminNotificationService:
|
||||
PromoCodeType.BALANCE.value: '💰 Бонус на баланс',
|
||||
PromoCodeType.SUBSCRIPTION_DAYS.value: '⏰ Доп. дни подписки',
|
||||
PromoCodeType.TRIAL_SUBSCRIPTION.value: '🎁 Триал подписка',
|
||||
PromoCodeType.PROMO_GROUP.value: '👥 Промогруппа',
|
||||
PromoCodeType.DISCOUNT.value: '💸 Скидка',
|
||||
}
|
||||
|
||||
if not promo_type:
|
||||
@@ -357,14 +359,14 @@ class AdminNotificationService:
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
|
||||
f'👥 <b>Статус:</b> {user_status}',
|
||||
'',
|
||||
]
|
||||
|
||||
# Промогруппа — только название, без скидок
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
|
||||
message_lines.append(f'🏷️ <b>Промогруппа:</b> {html.escape(promo_group.name)}')
|
||||
else:
|
||||
message_lines.append('🏷️ <b>Промогруппа:</b> —')
|
||||
|
||||
@@ -417,7 +419,7 @@ class AdminNotificationService:
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
return tariff.name
|
||||
return html.escape(tariff.name)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -494,7 +496,7 @@ class AdminNotificationService:
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
message_lines.append(f'📱 @{html.escape(username)}')
|
||||
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
@@ -657,13 +659,13 @@ class AdminNotificationService:
|
||||
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
message_lines.append(f'📱 @{html.escape(username)}')
|
||||
|
||||
message_lines.append(f'💳 {topup_status}')
|
||||
|
||||
# Промогруппа -- только название
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
@@ -697,7 +699,7 @@ class AdminNotificationService:
|
||||
desc = transaction.description
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + '...'
|
||||
detail_lines.append(f'Описание: {desc}')
|
||||
detail_lines.append(f'Описание: {html.escape(desc)}')
|
||||
|
||||
if transaction.created_at:
|
||||
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
|
||||
@@ -916,7 +918,7 @@ class AdminNotificationService:
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{html.escape(getattr(user, 'username', None) or 'отсутствует')}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -1003,7 +1005,7 @@ class AdminNotificationService:
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
|
||||
'',
|
||||
promo_block,
|
||||
'',
|
||||
@@ -1013,13 +1015,21 @@ class AdminNotificationService:
|
||||
f'📊 Использования: {usage_info}',
|
||||
]
|
||||
|
||||
promo_type = promocode_data.get('type')
|
||||
balance_bonus = promocode_data.get('balance_bonus_kopeks', 0)
|
||||
if balance_bonus:
|
||||
message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}')
|
||||
|
||||
subscription_days = promocode_data.get('subscription_days', 0)
|
||||
if subscription_days:
|
||||
message_lines.append(f'📅 Доп. дни подписки: {subscription_days}')
|
||||
|
||||
if promo_type == PromoCodeType.DISCOUNT.value:
|
||||
message_lines.append(f'💸 Скидка: {balance_bonus}%')
|
||||
if subscription_days:
|
||||
message_lines.append(f'⏳ Срок действия скидки: {subscription_days} ч.')
|
||||
else:
|
||||
message_lines.append('⏳ Срок действия скидки: до первой покупки')
|
||||
else:
|
||||
if balance_bonus:
|
||||
message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}')
|
||||
if subscription_days:
|
||||
message_lines.append(f'📅 Доп. дни подписки: {subscription_days}')
|
||||
|
||||
valid_until = promocode_data.get('valid_until')
|
||||
if valid_until:
|
||||
@@ -1094,13 +1104,13 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
'📣 <b>ПЕРЕХОД ПО РК</b>',
|
||||
'',
|
||||
f'🧾 {campaign.name} (<code>{campaign.start_parameter}</code>)',
|
||||
f'🧾 {html.escape(campaign.name)} (<code>{html.escape(campaign.start_parameter)}</code>)',
|
||||
'',
|
||||
f'👤 {full_name} (<code>{telegram_user.id}</code>)',
|
||||
f'👤 {html.escape(full_name)} (<code>{telegram_user.id}</code>)',
|
||||
]
|
||||
|
||||
if telegram_user.username:
|
||||
message_lines.append(f'📱 @{telegram_user.username}')
|
||||
message_lines.append(f'📱 @{html.escape(telegram_user.username)}')
|
||||
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
@@ -1108,7 +1118,7 @@ class AdminNotificationService:
|
||||
if user:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
@@ -1120,7 +1130,7 @@ class AdminNotificationService:
|
||||
|
||||
tariff = await get_tariff_by_id(db, campaign.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
tariff_name = html.escape(tariff.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1186,7 +1196,9 @@ class AdminNotificationService:
|
||||
title = '🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ' if automatic else '👥 СМЕНА ПРОМОГРУППЫ'
|
||||
initiator_line = None
|
||||
if initiator:
|
||||
initiator_line = f'👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})'
|
||||
initiator_line = (
|
||||
f'👮 <b>Инициатор:</b> {html.escape(initiator.full_name)} (ID: {initiator.telegram_id})'
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = '🤖 Автоматическое назначение'
|
||||
user_display = self._get_user_display(user)
|
||||
@@ -1198,7 +1210,7 @@ class AdminNotificationService:
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
f'📱 <b>Username:</b> @{html.escape(getattr(user, "username", None) or "отсутствует")}',
|
||||
'',
|
||||
self._format_promo_group_block(new_group, title='Новая промогруппа', icon='🏆'),
|
||||
]
|
||||
@@ -1643,7 +1655,7 @@ class AdminNotificationService:
|
||||
|
||||
elif status == 'maintenance':
|
||||
if details.get('maintenance_reason'):
|
||||
message_parts.append(f'🔧 <b>Причина:</b> {details["maintenance_reason"]}')
|
||||
message_parts.append(f'🔧 <b>Причина:</b> {html.escape(details["maintenance_reason"])}')
|
||||
|
||||
if details.get('estimated_duration'):
|
||||
message_parts.append(f'⏰ <b>Ожидаемая длительность:</b> {details["estimated_duration"]}')
|
||||
@@ -1700,7 +1712,7 @@ class AdminNotificationService:
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
message_lines.append(f'📱 @{html.escape(username)}')
|
||||
|
||||
# Тариф (если есть)
|
||||
if tariff_name:
|
||||
@@ -1806,7 +1818,7 @@ class AdminNotificationService:
|
||||
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
message_lines.append(f'📱 @{html.escape(username)}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
@@ -1861,7 +1873,7 @@ class AdminNotificationService:
|
||||
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
message_lines.append(f'📱 @{html.escape(username)}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
@@ -1906,7 +1918,7 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
'🛑 <b>МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ</b>',
|
||||
'',
|
||||
f'👮 <b>Администратор:</b> {admin_name}',
|
||||
f'👮 <b>Администратор:</b> {html.escape(admin_name)}',
|
||||
f'🆔 <b>ID администратора:</b> {admin_user_id}',
|
||||
'',
|
||||
'📊 <b>Результаты:</b>',
|
||||
|
||||
@@ -79,9 +79,19 @@ class ChannelSubscriptionService:
|
||||
return ch
|
||||
return None
|
||||
|
||||
def should_disable_subscription(self, channel: dict, is_trial: bool) -> bool:
|
||||
"""Check if a channel's settings require subscription deactivation."""
|
||||
@staticmethod
|
||||
def should_disable_subscription(channel: dict, is_trial: bool) -> bool:
|
||||
"""Check if a channel's settings require subscription deactivation.
|
||||
|
||||
Respects both global and per-channel settings:
|
||||
- Global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=False overrides per-channel for trials
|
||||
- Per-channel disable_trial_on_leave / disable_paid_on_leave for fine-grained control
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
if is_trial:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
return False
|
||||
return channel.get('disable_trial_on_leave', True)
|
||||
return channel.get('disable_paid_on_leave', False)
|
||||
|
||||
|
||||
@@ -197,17 +197,54 @@ class DailySubscriptionService:
|
||||
user_id_display=user_id_display,
|
||||
)
|
||||
|
||||
# Восстанавливаем connected_squads из тарифа, если очищены деактивацией
|
||||
try:
|
||||
if not subscription.connected_squads:
|
||||
squads = tariff.allowed_squads or []
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
if squads:
|
||||
subscription.connected_squads = squads
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as sq_err:
|
||||
logger.warning('Не удалось восстановить connected_squads', error=sq_err)
|
||||
|
||||
# Синхронизируем с Remnawave (обновляем срок подписки)
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
# POST может игнорировать activeInternalSquads — отправляем PATCH
|
||||
await db.refresh(user)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as patch_err:
|
||||
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось обновить Remnawave', error=e)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,6 +26,7 @@ from app.database.crud.subscription import (
|
||||
get_expired_subscriptions,
|
||||
get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay,
|
||||
reactivate_subscription,
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
@@ -585,15 +587,11 @@ class MonitoringService:
|
||||
When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions
|
||||
(not just trials). Otherwise only checks trial subscriptions.
|
||||
"""
|
||||
from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
logger.debug('Channel unsubscribe check disabled')
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.debug('Skipping channel subscription check - bot unavailable')
|
||||
return
|
||||
@@ -606,6 +604,14 @@ class MonitoringService:
|
||||
if not channels:
|
||||
return
|
||||
|
||||
# When no channel has any disable-on-leave rule, skip deactivation but
|
||||
# still run reactivation to restore orphaned DISABLED subscriptions
|
||||
# (e.g., admin turned off disable flags after subscriptions were already disabled).
|
||||
has_any_disable_rule = any(
|
||||
ch.get('disable_trial_on_leave', True) or ch.get('disable_paid_on_leave', False) for ch in channels
|
||||
)
|
||||
skip_deactivation = not has_any_disable_rule and not settings.CHANNEL_REQUIRED_FOR_ALL
|
||||
|
||||
# Ensure bot is set on service
|
||||
if not channel_subscription_service.bot:
|
||||
channel_subscription_service.bot = self.bot
|
||||
@@ -623,9 +629,13 @@ class MonitoringService:
|
||||
last_id = 0
|
||||
|
||||
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
|
||||
# Also include paid subs if any channel has disable_paid_on_leave=True,
|
||||
# so monitoring can reconcile missed real-time events for paid users.
|
||||
from sqlalchemy import true as sa_true
|
||||
|
||||
is_trial_filter = sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
|
||||
has_paid_disable_rule = any(ch.get('disable_paid_on_leave', False) for ch in channels)
|
||||
include_all = settings.CHANNEL_REQUIRED_FOR_ALL or has_paid_disable_rule
|
||||
is_trial_filter = sa_true() if include_all else Subscription.is_trial.is_(True)
|
||||
|
||||
while True:
|
||||
# Fresh session per batch to avoid long-running connections
|
||||
@@ -666,6 +676,10 @@ class MonitoringService:
|
||||
if not user or not user.telegram_id:
|
||||
continue
|
||||
|
||||
# Skip admins -- consistent with channel_member.py and channel_checker.py
|
||||
if settings.is_admin(user.telegram_id):
|
||||
continue
|
||||
|
||||
# Existing guard: skip if recently updated by webhook
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
@@ -678,6 +692,7 @@ class MonitoringService:
|
||||
|
||||
# Rate-limited check for ALL channels
|
||||
all_subscribed = True
|
||||
unsubscribed_channels: list[dict] = []
|
||||
for ch in channels:
|
||||
is_member = await channel_subscription_service._rate_limited_check(
|
||||
user.telegram_id, ch['channel_id']
|
||||
@@ -688,14 +703,22 @@ class MonitoringService:
|
||||
|
||||
if not is_member:
|
||||
all_subscribed = False
|
||||
unsubscribed_channels.append(ch)
|
||||
|
||||
# DEACTIVATE: was active, now not subscribed to all
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
|
||||
# Guard: always skip paid subscriptions (user paid money)
|
||||
if is_active_paid_subscription(subscription):
|
||||
if skip_deactivation:
|
||||
continue
|
||||
|
||||
subscription = await deactivate_subscription(batch_db, subscription)
|
||||
# Respect per-channel disable_trial_on_leave / disable_paid_on_leave settings
|
||||
should_disable = any(
|
||||
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
|
||||
for ch in unsubscribed_channels
|
||||
)
|
||||
if not should_disable:
|
||||
continue
|
||||
|
||||
subscription = await deactivate_subscription(batch_db, subscription, commit=False)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
'Subscription deactivated (channel unsubscribe)',
|
||||
@@ -728,6 +751,7 @@ class MonitoringService:
|
||||
user.id,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# REACTIVATE: was disabled, now subscribed to all
|
||||
@@ -761,10 +785,12 @@ class MonitoringService:
|
||||
)
|
||||
continue
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
restored_count += 1
|
||||
subscription = await reactivate_subscription(batch_db, subscription, commit=False)
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
# reactivate_subscription silently skipped (expired or wrong status)
|
||||
continue
|
||||
|
||||
restored_count += 1
|
||||
logger.info(
|
||||
'Subscription restored (channel resubscribe)',
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -774,8 +800,11 @@ class MonitoringService:
|
||||
|
||||
try:
|
||||
if user.remnawave_uuid:
|
||||
await self.subscription_service.update_remnawave_user(batch_db, subscription)
|
||||
await self.subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
else:
|
||||
# create_remnawave_user calls db.commit() internally --
|
||||
# flush accumulated batch state first to preserve atomicity.
|
||||
await batch_db.commit()
|
||||
await self.subscription_service.create_remnawave_user(batch_db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
@@ -788,6 +817,7 @@ class MonitoringService:
|
||||
batch_db,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Commit all changes for this batch
|
||||
@@ -1886,9 +1916,12 @@ class MonitoringService:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Детали пользователя: имя, Telegram ID и username
|
||||
full_name = ticket.user.full_name if ticket.user else 'Unknown'
|
||||
full_name = html.escape(ticket.user.full_name or '') if ticket.user else 'Unknown'
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else '—'
|
||||
username_display = (ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует'
|
||||
username_display = html.escape(
|
||||
(ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует'
|
||||
)
|
||||
safe_title = html.escape(title) if title else '—'
|
||||
|
||||
text = (
|
||||
f'⏰ <b>Ожидание ответа на тикет превышено</b>\n\n'
|
||||
@@ -1896,7 +1929,7 @@ class MonitoringService:
|
||||
f'👤 <b>Пользователь:</b> {full_name}\n'
|
||||
f'🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n'
|
||||
f'📱 <b>Username:</b> @{username_display}\n'
|
||||
f'📝 <b>Заголовок:</b> {title or "—"}\n'
|
||||
f'📝 <b>Заголовок:</b> {safe_title}\n'
|
||||
f'⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n'
|
||||
)
|
||||
|
||||
|
||||
@@ -158,7 +158,9 @@ class NalogoQueueService:
|
||||
|
||||
# Логируем количество попыток (чек никогда не удаляется из очереди)
|
||||
if attempts >= 10:
|
||||
logger.warning('Чек уже попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts)
|
||||
logger.warning(
|
||||
'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts
|
||||
)
|
||||
|
||||
# Пытаемся отправить чек
|
||||
try:
|
||||
@@ -181,11 +183,14 @@ class NalogoQueueService:
|
||||
|
||||
# Формируем описание заново из настроек (если есть данные)
|
||||
if amount_kopeks is not None:
|
||||
receipt_name = settings.get_balance_payment_description(amount_kopeks, telegram_user_id)
|
||||
receipt_name = settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=telegram_user_id
|
||||
)
|
||||
else:
|
||||
# Fallback на сохранённое имя
|
||||
receipt_name = receipt_data.get(
|
||||
'name', settings.get_balance_payment_description(int(amount * 100), telegram_user_id)
|
||||
'name',
|
||||
settings.get_balance_payment_description(int(amount * 100), telegram_user_id=telegram_user_id),
|
||||
)
|
||||
|
||||
receipt_uuid = await self._nalogo_service.create_receipt(
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Media processing service for news article images and videos.
|
||||
|
||||
Handles file validation (magic bytes), image resizing via Pillow,
|
||||
thumbnail generation, and atomic file writes with UUID filenames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Hard limit on decompressed image pixels to prevent decompression bombs.
|
||||
# 25M pixels ≈ 5000x5000, roughly 75 MB of raw RGB data — safe for a news editor.
|
||||
Image.MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
# Minimum file size to attempt magic byte detection
|
||||
_MIN_MAGIC_BYTES = 12
|
||||
|
||||
# --- Magic byte signatures for file type detection ---
|
||||
|
||||
ALLOWED_IMAGE_SIGNATURES: dict[bytes, str] = {
|
||||
b'\xff\xd8\xff': '.jpg',
|
||||
b'\x89PNG': '.png',
|
||||
# WebP: starts with RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
|
||||
}
|
||||
|
||||
ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = {
|
||||
# MP4: bytes 4-7 = 'ftyp'
|
||||
b'\x1a\x45\xdf\xa3': '.webm',
|
||||
}
|
||||
|
||||
# Known ISO base media file format brands for video.
|
||||
# Rejects HEIC/HEIF image brands (heic, heix, mif1, msf1, avif) that share the ftyp box format.
|
||||
_MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset(
|
||||
{
|
||||
b'isom',
|
||||
b'iso2',
|
||||
b'iso3',
|
||||
b'iso4',
|
||||
b'iso5',
|
||||
b'iso6',
|
||||
b'mp41',
|
||||
b'mp42',
|
||||
b'mp71',
|
||||
b'M4V ',
|
||||
b'M4VH',
|
||||
b'M4VP',
|
||||
b'MSNV',
|
||||
b'avc1',
|
||||
b'mmp4',
|
||||
b'dash',
|
||||
b'3gp4',
|
||||
b'3gp5',
|
||||
b'3gp6',
|
||||
b'NDAS',
|
||||
b'NDSC',
|
||||
b'NDSH',
|
||||
b'NDSS',
|
||||
b'NDSM',
|
||||
b'NDSP',
|
||||
b'qt ',
|
||||
}
|
||||
)
|
||||
|
||||
_IMAGES_DIR = 'images'
|
||||
_VIDEOS_DIR = 'videos'
|
||||
_THUMBNAILS_DIR = 'thumbnails'
|
||||
|
||||
_THUMBNAIL_SIZE = (400, 400)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SavedMedia:
|
||||
"""Result of saving a media file."""
|
||||
|
||||
filename: str
|
||||
relative_path: str
|
||||
thumbnail_path: str | None
|
||||
media_type: Literal['image', 'video']
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
width: int | None
|
||||
height: int | None
|
||||
|
||||
|
||||
def ensure_upload_dirs(upload_path: Path) -> None:
|
||||
"""Create images/, videos/, thumbnails/ subdirectories under upload_path."""
|
||||
for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR):
|
||||
(upload_path / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
MediaType = Literal['image', 'video']
|
||||
|
||||
|
||||
def detect_file_type(data: bytes) -> tuple[MediaType, str]:
|
||||
"""Detect media type and extension from magic bytes.
|
||||
|
||||
Returns:
|
||||
Tuple of (media_type, extension), e.g. ('image', '.jpg').
|
||||
|
||||
Raises:
|
||||
ValueError: If file type is not recognized.
|
||||
"""
|
||||
if len(data) < _MIN_MAGIC_BYTES:
|
||||
msg = 'File too small to identify'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Check WebP: RIFF at offset 0, WEBP at offset 8
|
||||
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
return 'image', '.webp'
|
||||
|
||||
# Check standard image signatures
|
||||
for signature, ext in ALLOWED_IMAGE_SIGNATURES.items():
|
||||
if data[: len(signature)] == signature:
|
||||
return 'image', ext
|
||||
|
||||
# Check MP4/MOV: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand.
|
||||
# Rejects HEIC/HEIF images (ftypheic, ftypmif1, etc.) which share the ftyp box format.
|
||||
if data[4:8] == b'ftyp':
|
||||
brand = data[8:12]
|
||||
if brand in _MP4_VIDEO_BRANDS:
|
||||
return 'video', '.mp4'
|
||||
logger.warning('Unknown ftyp brand rejected', brand=brand.decode('ascii', errors='replace'))
|
||||
|
||||
# Check standard video signatures
|
||||
for signature, ext in ALLOWED_VIDEO_SIGNATURES.items():
|
||||
if data[: len(signature)] == signature:
|
||||
return 'video', ext
|
||||
|
||||
msg = 'Unsupported file type: magic bytes do not match any allowed format'
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _process_and_save_image(
|
||||
data: bytes,
|
||||
upload_path: Path,
|
||||
max_dim: int,
|
||||
quality: int,
|
||||
) -> SavedMedia:
|
||||
"""Process image: validate, resize, convert to JPEG, generate thumbnail.
|
||||
|
||||
This is a CPU-bound function intended to be run via asyncio.to_thread.
|
||||
"""
|
||||
img = Image.open(io.BytesIO(data))
|
||||
try:
|
||||
# Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS)
|
||||
if img.size[0] * img.size[1] > 25_000_000:
|
||||
msg = 'Image dimensions too large'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Fix EXIF orientation (rotated photos from phones)
|
||||
transposed = ImageOps.exif_transpose(img)
|
||||
if transposed is not None:
|
||||
old_img = img
|
||||
img = transposed
|
||||
old_img.close()
|
||||
|
||||
# Normalize to RGB for consistent JPEG output
|
||||
if img.mode != 'RGB':
|
||||
old_img = img
|
||||
img = img.convert('RGB')
|
||||
old_img.close()
|
||||
|
||||
original_width, original_height = img.size
|
||||
|
||||
# Resize if any dimension exceeds max_dim (preserving aspect ratio)
|
||||
if original_width > max_dim or original_height > max_dim:
|
||||
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
|
||||
|
||||
width, height = img.size
|
||||
filename = f'{uuid.uuid4().hex}.jpg'
|
||||
image_dir = upload_path / _IMAGES_DIR
|
||||
target_path = image_dir / filename
|
||||
|
||||
# Atomic write: save to temp file, then rename
|
||||
tmp_path = target_path.with_suffix('.tmp')
|
||||
try:
|
||||
img.save(tmp_path, format='JPEG', quality=quality, optimize=True)
|
||||
tmp_path.rename(target_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
size_bytes = target_path.stat().st_size
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_filename = f'thumb_{filename}'
|
||||
thumbnail_dir = upload_path / _THUMBNAILS_DIR
|
||||
thumbnail_target = thumbnail_dir / thumbnail_filename
|
||||
|
||||
tmp_thumb = thumbnail_target.with_suffix('.tmp')
|
||||
try:
|
||||
thumb = img.copy()
|
||||
try:
|
||||
thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS)
|
||||
thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True)
|
||||
tmp_thumb.rename(thumbnail_target)
|
||||
finally:
|
||||
thumb.close()
|
||||
except Exception:
|
||||
tmp_thumb.unlink(missing_ok=True)
|
||||
# Non-fatal: log and continue without thumbnail
|
||||
logger.warning('Failed to generate thumbnail', filename=filename, exc_info=True)
|
||||
thumbnail_filename = None
|
||||
|
||||
relative_path = f'{_IMAGES_DIR}/{filename}'
|
||||
thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None
|
||||
|
||||
return SavedMedia(
|
||||
filename=filename,
|
||||
relative_path=relative_path,
|
||||
thumbnail_path=thumbnail_path,
|
||||
media_type='image',
|
||||
content_type='image/jpeg',
|
||||
size_bytes=size_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
finally:
|
||||
img.close()
|
||||
|
||||
|
||||
async def save_image(
|
||||
data: bytes,
|
||||
upload_path: Path,
|
||||
max_dim: int,
|
||||
quality: int,
|
||||
) -> SavedMedia:
|
||||
"""Validate, resize, and save an image file. Runs PIL operations in a thread."""
|
||||
return await asyncio.to_thread(_process_and_save_image, data, upload_path, max_dim, quality)
|
||||
|
||||
|
||||
def _save_video_sync(data: bytes, upload_path: Path) -> SavedMedia:
|
||||
"""Save a video file. CPU-bound function for asyncio.to_thread."""
|
||||
media_type, ext = detect_file_type(data)
|
||||
if media_type != 'video':
|
||||
msg = 'Data does not contain a recognized video format'
|
||||
raise ValueError(msg)
|
||||
|
||||
filename = f'{uuid.uuid4().hex}{ext}'
|
||||
video_dir = upload_path / _VIDEOS_DIR
|
||||
target_path = video_dir / filename
|
||||
|
||||
content_type_map: dict[str, str] = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
}
|
||||
|
||||
# Atomic write
|
||||
tmp_path = target_path.with_suffix('.tmp')
|
||||
try:
|
||||
tmp_path.write_bytes(data)
|
||||
tmp_path.rename(target_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
size_bytes = target_path.stat().st_size
|
||||
|
||||
return SavedMedia(
|
||||
filename=filename,
|
||||
relative_path=f'{_VIDEOS_DIR}/{filename}',
|
||||
thumbnail_path=None,
|
||||
media_type='video',
|
||||
content_type=content_type_map.get(ext, 'application/octet-stream'),
|
||||
size_bytes=size_bytes,
|
||||
width=None,
|
||||
height=None,
|
||||
)
|
||||
|
||||
|
||||
async def save_video(data: bytes, upload_path: Path) -> SavedMedia:
|
||||
"""Validate and save a video file. Runs I/O in a thread."""
|
||||
return await asyncio.to_thread(_save_video_sync, data, upload_path)
|
||||
|
||||
|
||||
def delete_media_file(filename: str, upload_path: Path) -> bool:
|
||||
"""Delete a media file by filename with path traversal protection.
|
||||
|
||||
Searches images/, videos/, thumbnails/ directories.
|
||||
|
||||
Returns:
|
||||
True if at least one file was deleted, False otherwise.
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR):
|
||||
candidate = (upload_path / subdir / filename).resolve()
|
||||
base_dir = (upload_path / subdir).resolve()
|
||||
|
||||
# Path traversal guard
|
||||
if not candidate.is_relative_to(base_dir):
|
||||
logger.warning(
|
||||
'Path traversal attempt blocked',
|
||||
filename=filename,
|
||||
resolved=str(candidate),
|
||||
)
|
||||
continue
|
||||
|
||||
if candidate.is_file():
|
||||
candidate.unlink()
|
||||
deleted = True
|
||||
logger.info('Deleted media file', path=str(candidate))
|
||||
|
||||
# Also try to delete matching thumbnail
|
||||
if not filename.startswith('thumb_'):
|
||||
thumb_name = f'thumb_{filename}'
|
||||
thumb_path = (upload_path / _THUMBNAILS_DIR / thumb_name).resolve()
|
||||
thumb_base = (upload_path / _THUMBNAILS_DIR).resolve()
|
||||
|
||||
if thumb_path.is_relative_to(thumb_base) and thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
logger.info('Deleted thumbnail', path=str(thumb_path))
|
||||
|
||||
return deleted
|
||||
@@ -147,6 +147,15 @@ class CloudPaymentsPaymentMixin:
|
||||
token = webhook_data.get('token')
|
||||
test_mode = webhook_data.get('test_mode', False)
|
||||
|
||||
# Reject test-mode payments when not in test mode
|
||||
if test_mode and not getattr(settings, 'CLOUDPAYMENTS_TEST_MODE', False):
|
||||
logger.warning(
|
||||
'CloudPayments: rejecting test_mode payment in production',
|
||||
invoice_id=invoice_id,
|
||||
test_mode=test_mode,
|
||||
)
|
||||
return False
|
||||
|
||||
if not invoice_id:
|
||||
logger.error('CloudPayments webhook без invoice_id')
|
||||
return False
|
||||
|
||||
@@ -253,6 +253,24 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
if status in {'PAID', 'SUCCESS', 'OVERPAID'}:
|
||||
# Verify payment amount matches expected
|
||||
callback_amount_str = callback.get('OutSum') or callback.get('out_sum') or callback.get('Amount')
|
||||
if callback_amount_str is not None:
|
||||
try:
|
||||
from decimal import Decimal
|
||||
|
||||
received_kopeks = int(Decimal(str(callback_amount_str)) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Pal24 amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
bill_id=payment.bill_id,
|
||||
)
|
||||
return False
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning('Pal24: не удалось распарсить сумму из callback', error=str(e))
|
||||
|
||||
metadata = getattr(payment, 'metadata_json', {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
@@ -409,6 +409,14 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return True
|
||||
|
||||
# Reject test-mode payments in production
|
||||
if getattr(payment, 'test_mode', False) and not getattr(settings, 'YOOKASSA_TEST_MODE', False):
|
||||
logger.warning(
|
||||
'YooKassa: rejecting test_mode payment in production',
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
@@ -1243,7 +1251,9 @@ class YooKassaPaymentMixin:
|
||||
try:
|
||||
amount_rubles = payment.amount_kopeks / 100
|
||||
# Формируем описание из настроек (включает сумму и ID пользователя)
|
||||
receipt_name = settings.get_balance_payment_description(payment.amount_kopeks, telegram_user_id)
|
||||
receipt_name = settings.get_balance_payment_description(
|
||||
payment.amount_kopeks, telegram_user_id=telegram_user_id
|
||||
)
|
||||
|
||||
receipt_uuid = await self.nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Service layer for phantom user claiming and merging.
|
||||
|
||||
Phantom users are created during guest landing purchases when Bot.get_chat() fails —
|
||||
the user record has @username but no telegram_id. When the real user later presses /start,
|
||||
we match by username and either claim or merge the phantom into their active account.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AuditLogCRUD
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import User, UserStatus
|
||||
from app.services.account_merge_service import execute_merge
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def claim_phantom(
|
||||
db: AsyncSession,
|
||||
phantom: User,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language: str,
|
||||
referrer_id: int | None,
|
||||
) -> tuple[bool, User | None]:
|
||||
"""Claim a phantom user by backfilling Telegram profile data.
|
||||
|
||||
Commits internally on success; rolls back on IntegrityError.
|
||||
Returns (success, user). On IntegrityError falls back to existing user lookup.
|
||||
|
||||
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
|
||||
by username only. Since Telegram usernames are changeable and reassignable, this is
|
||||
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
|
||||
purchase time, telegram_id is stored on the user and the phantom path is not used.
|
||||
"""
|
||||
phantom.telegram_id = telegram_id
|
||||
phantom.username = username
|
||||
phantom.first_name = sanitize_telegram_name(first_name)
|
||||
phantom.last_name = sanitize_telegram_name(last_name)
|
||||
phantom.language = language
|
||||
phantom.status = UserStatus.ACTIVE.value
|
||||
if referrer_id and referrer_id != phantom.id:
|
||||
phantom.referred_by_id = referrer_id
|
||||
if not phantom.referral_code:
|
||||
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
phantom.updated_at = datetime.now(UTC)
|
||||
phantom.last_activity = datetime.now(UTC)
|
||||
|
||||
# Write audit log in a savepoint — if it fails, the claim mutations are not affected
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await AuditLogCRUD.create(
|
||||
db,
|
||||
user_id=phantom.id,
|
||||
action='phantom_claimed',
|
||||
resource_type='user',
|
||||
resource_id=str(phantom.id),
|
||||
details={
|
||||
'telegram_id': telegram_id,
|
||||
'username': username,
|
||||
},
|
||||
status='success',
|
||||
)
|
||||
except Exception:
|
||||
logger.warning('Failed to write phantom claim audit log', phantom_id=phantom.id, exc_info=True)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'IntegrityError claiming phantom user, falling back to existing user lookup',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
existing = await get_user_by_telegram_id(db, telegram_id)
|
||||
return False, existing
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
|
||||
# SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time).
|
||||
# Telegram usernames are changeable/reassignable, so the claimer may not be the intended
|
||||
# recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed
|
||||
# to fully prevent username spoofing attacks on phantom claims.
|
||||
logger.warning(
|
||||
'Phantom user claimed by username match (verify intended recipient)',
|
||||
phantom_user_id=phantom.id,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
has_subscription=phantom.subscription is not None,
|
||||
)
|
||||
|
||||
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
|
||||
if phantom.subscription:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, phantom.subscription)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to update Remnawave panel after phantom claim',
|
||||
phantom_user_id=phantom.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True, phantom
|
||||
|
||||
|
||||
async def merge_phantom_into_user(
|
||||
db: AsyncSession,
|
||||
phantom: User,
|
||||
active_user: User,
|
||||
) -> bool:
|
||||
"""Merge phantom user into active user using the full account merge service.
|
||||
|
||||
Uses execute_merge which handles 30+ tables, subscription transfer, balance,
|
||||
unique constraint safety, and soft-deletion. Caller is responsible for commit/rollback.
|
||||
|
||||
Returns True if a subscription was transferred from phantom (caller should sync
|
||||
Remnawave panel AFTER commit via ``sync_remnawave_after_phantom_merge``).
|
||||
"""
|
||||
# Determine which subscription to keep: phantom's if active user has none, otherwise active's
|
||||
await db.refresh(phantom, ['subscription'])
|
||||
await db.refresh(active_user, ['subscription'])
|
||||
keep_from: Literal['primary', 'secondary'] = (
|
||||
'secondary' if phantom.subscription and not active_user.subscription else 'primary'
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
'Merging phantom user into active user via execute_merge',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=active_user.id,
|
||||
keep_subscription_from=keep_from,
|
||||
phantom_has_sub=phantom.subscription is not None,
|
||||
active_has_sub=active_user.subscription is not None,
|
||||
)
|
||||
|
||||
await execute_merge(
|
||||
db,
|
||||
primary_user_id=active_user.id,
|
||||
secondary_user_id=phantom.id,
|
||||
keep_subscription_from=keep_from,
|
||||
provider='phantom_merge',
|
||||
)
|
||||
|
||||
# Durable audit log in a savepoint — if it fails, the merge itself is not affected
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await AuditLogCRUD.create(
|
||||
db,
|
||||
user_id=active_user.id,
|
||||
action='phantom_merged',
|
||||
resource_type='user',
|
||||
resource_id=str(phantom.id),
|
||||
details={
|
||||
'phantom_id': phantom.id,
|
||||
'active_user_id': active_user.id,
|
||||
'keep_subscription_from': keep_from,
|
||||
'phantom_username': phantom.username,
|
||||
},
|
||||
status='success',
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to write phantom merge audit log',
|
||||
phantom_id=phantom.id,
|
||||
active_user_id=active_user.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return keep_from == 'secondary'
|
||||
|
||||
|
||||
async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> None:
|
||||
"""Sync Remnawave panel after a phantom merge that transferred a subscription.
|
||||
|
||||
Must be called AFTER db.commit() to avoid holding FOR UPDATE locks during HTTP calls.
|
||||
"""
|
||||
await db.refresh(user, ['subscription'])
|
||||
if not user.subscription:
|
||||
return
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to update Remnawave panel after phantom merge',
|
||||
user_id=user.id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from collections.abc import Iterable
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -28,9 +29,9 @@ logger = structlog.get_logger(__name__)
|
||||
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
|
||||
lines: list[str] = [f'🗳️ <b>{poll.title}</b>']
|
||||
lines: list[str] = [f'🗳️ <b>{html.escape(poll.title)}</b>']
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
lines.append(html.escape(poll.description))
|
||||
|
||||
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
|
||||
reward_line = texts.t(
|
||||
|
||||
@@ -479,6 +479,11 @@ class PricingEngine:
|
||||
Prevents purchased top-ups from inflating the tier lookup."""
|
||||
total_gb = traffic_limit_gb or 0
|
||||
purchased_gb = purchased_traffic_gb or 0
|
||||
|
||||
# 0 = unlimited traffic — has its own price tier, return directly
|
||||
if total_gb == 0:
|
||||
return settings.get_traffic_price(0)
|
||||
|
||||
base_gb = max(0, total_gb - purchased_gb)
|
||||
|
||||
base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0
|
||||
|
||||
@@ -277,7 +277,9 @@ async def _process_single_subscription(
|
||||
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
|
||||
return 'skipped'
|
||||
|
||||
description = settings.get_balance_payment_description(topup_amount_kopeks)
|
||||
description = settings.get_balance_payment_description(
|
||||
topup_amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
)
|
||||
metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -281,7 +282,7 @@ class ReferralContestService:
|
||||
|
||||
lines = [
|
||||
'🏆 <b>Конкурс рефералов</b>',
|
||||
f'Название: <b>{contest.title}</b>',
|
||||
f'Название: <b>{html.escape(contest.title)}</b>',
|
||||
f'Статус: {"финал" if is_final else "дневная сводка"}',
|
||||
f'Временная зона: <code>{tz.key}</code>',
|
||||
f'Всего рефералов: <b>{total_events}</b>',
|
||||
@@ -292,13 +293,13 @@ class ReferralContestService:
|
||||
if leaderboard:
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append('')
|
||||
lines.append(f'Приз: {contest.prize_text}')
|
||||
lines.append(f'Приз: {html.escape(contest.prize_text)}')
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -330,7 +331,7 @@ class ReferralContestService:
|
||||
return
|
||||
|
||||
lines = [
|
||||
f'🏆 {contest.title}',
|
||||
f'🏆 {html.escape(contest.title)}',
|
||||
'🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги',
|
||||
f'Время зоны: {tz.key}',
|
||||
f'Всего участников: <b>{len(leaderboard)}</b>',
|
||||
@@ -340,13 +341,13 @@ class ReferralContestService:
|
||||
|
||||
if leaderboard:
|
||||
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f'{idx}. {name} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append('')
|
||||
lines.append(f'Приз: {contest.prize_text}')
|
||||
lines.append(f'Приз: {html.escape(contest.prize_text)}')
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -372,7 +373,7 @@ class ReferralContestService:
|
||||
) -> str:
|
||||
status_line = '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги'
|
||||
lines = [
|
||||
f'🏆 {contest.title}',
|
||||
f'🏆 {html.escape(contest.title)}',
|
||||
status_line,
|
||||
'',
|
||||
f'Ваше место: <b>{rank}</b>',
|
||||
@@ -383,7 +384,7 @@ class ReferralContestService:
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append('')
|
||||
lines.append(f'Призовой фонд: {contest.prize_text}')
|
||||
lines.append(f'Призовой фонд: {html.escape(contest.prize_text)}')
|
||||
|
||||
if not is_final:
|
||||
remaining = contest.end_at - datetime.now(UTC)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from sqlalchemy import delete
|
||||
@@ -116,7 +118,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
f'🎉 <b>Добро пожаловать!</b>\n\n'
|
||||
f'Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!'
|
||||
f'Вы перешли по реферальной ссылке пользователя <b>{html.escape(referrer.full_name)}</b>!'
|
||||
)
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
referral_notification += (
|
||||
@@ -127,7 +129,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
|
||||
|
||||
inviter_notification = (
|
||||
f'👥 <b>Новый реферал!</b>\n\n'
|
||||
f'По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n'
|
||||
f'По вашей ссылке зарегистрировался пользователь <b>{html.escape(new_user.full_name)}</b>!\n\n'
|
||||
f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, '
|
||||
)
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
@@ -227,7 +229,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f'💰 <b>Реферальная комиссия!</b>\n\n'
|
||||
f'Ваш реферал <b>{user.full_name}</b> пополнил баланс на '
|
||||
f'Ваш реферал <b>{html.escape(user.full_name)}</b> пополнил баланс на '
|
||||
f'{settings.format_price(topup_amount_kopeks)}\n\n'
|
||||
f'🎁 Ваша комиссия ({commission_percent}%): '
|
||||
f'{settings.format_price(commission_amount)}\n\n'
|
||||
@@ -344,7 +346,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
bonus_breakdown = ' + '.join(bonus_parts)
|
||||
inviter_bonus_notification = (
|
||||
f'💰 <b>Реферальная награда!</b>\n\n'
|
||||
f'Ваш реферал <b>{user.full_name}</b> сделал первое пополнение '
|
||||
f'Ваш реферал <b>{html.escape(user.full_name)}</b> сделал первое пополнение '
|
||||
f'на {settings.format_price(topup_amount_kopeks)}!\n\n'
|
||||
f'🎁 Ваша награда: {settings.format_price(inviter_bonus)}'
|
||||
f' ({bonus_breakdown})\n\n'
|
||||
@@ -398,7 +400,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f'💰 <b>Реферальная комиссия!</b>\n\n'
|
||||
f'Ваш реферал <b>{user.full_name}</b> пополнил баланс на '
|
||||
f'Ваш реферал <b>{html.escape(user.full_name)}</b> пополнил баланс на '
|
||||
f'{settings.format_price(topup_amount_kopeks)}\n\n'
|
||||
f'🎁 Ваша комиссия ({commission_percent}%): '
|
||||
f'{settings.format_price(commission_amount)}\n\n'
|
||||
@@ -479,7 +481,7 @@ async def process_referral_purchase(
|
||||
if bot:
|
||||
purchase_commission_notification = (
|
||||
f'💰 <b>Комиссия с покупки!</b>\n\n'
|
||||
f'Ваш реферал <b>{user.full_name}</b> совершил покупку на '
|
||||
f'Ваш реферал <b>{html.escape(user.full_name)}</b> совершил покупку на '
|
||||
f'{settings.format_price(purchase_amount_kopeks)}\n\n'
|
||||
f'🎁 Ваша комиссия ({commission_percent}%): '
|
||||
f'{settings.format_price(commission_amount)}\n\n'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
с анализом на подозрительную активность (отмывание денег).
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -656,7 +657,7 @@ class ReferralWithdrawalService:
|
||||
if details.get('suspicious_referrals'):
|
||||
text += '\n🚨 <b>Подозрительные рефералы:</b>\n'
|
||||
for sr in details['suspicious_referrals'][:5]:
|
||||
text += f'• {sr["name"]}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}₽\n'
|
||||
text += f'• {html.escape(sr["name"])}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}₽\n'
|
||||
text += f' Флаги: {", ".join(sr["flags"])}\n'
|
||||
|
||||
# Источники дохода
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -180,6 +181,35 @@ async def _prepare_auto_extend_context(
|
||||
if tariff_id:
|
||||
tariff_id = _safe_int(tariff_id)
|
||||
|
||||
# Validate period_days against tariff or global renewal periods
|
||||
if tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff
|
||||
|
||||
_tariff = await _get_tariff(db, tariff_id)
|
||||
if _tariff and _tariff.period_prices and not getattr(_tariff, 'is_daily', False):
|
||||
available_periods = [int(p) for p in _tariff.period_prices.keys()]
|
||||
if period_days not in available_periods:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: period_days из корзины не входит в доступные периоды тарифа',
|
||||
period_days=period_days,
|
||||
available_periods=available_periods,
|
||||
tariff_id=tariff_id,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return None
|
||||
else:
|
||||
from app.config import settings as _settings
|
||||
|
||||
available_periods = _settings.get_available_renewal_periods()
|
||||
if period_days not in available_periods:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: period_days из корзины не входит в доступные периоды продления',
|
||||
period_days=period_days,
|
||||
available_periods=available_periods,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return None
|
||||
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
from app.services.pricing_engine import pricing_engine as _pricing_engine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
@@ -621,6 +651,29 @@ async def _auto_purchase_tariff(
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate period_days against tariff's configured periods (prevent arbitrary periods from saved cart)
|
||||
is_daily_tariff = getattr(tariff, 'is_daily', False)
|
||||
if not is_daily_tariff:
|
||||
if tariff.period_prices:
|
||||
available_periods = [int(p) for p in tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = []
|
||||
custom_days_allowed = (
|
||||
hasattr(tariff, 'can_purchase_custom_days')
|
||||
and tariff.can_purchase_custom_days()
|
||||
and hasattr(tariff, 'get_price_for_custom_days')
|
||||
and tariff.get_price_for_custom_days(period_days) is not None
|
||||
)
|
||||
if period_days not in available_periods and not custom_days_allowed:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка тарифа: period_days не входит в доступные периоды тарифа',
|
||||
tariff_id=tariff_id,
|
||||
period_days=period_days,
|
||||
available_periods=available_periods,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
@@ -1157,7 +1210,7 @@ async def _auto_purchase_daily_tariff(
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
message = (
|
||||
f'✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n'
|
||||
f'✅ <b>Суточный тариф «{html.escape(tariff.name)}» активирован!</b>\n\n'
|
||||
f'💰 Списано: {final_price / 100:.0f} ₽ за первый день\n'
|
||||
f'🔄 Средства будут списываться автоматически раз в сутки.\n\n'
|
||||
f'ℹ️ Вы можете приостановить подписку в любой момент.'
|
||||
@@ -2375,15 +2428,60 @@ async def try_resume_disabled_daily_after_topup(
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Restore connected_squads from tariff if cleared by deactivation sync
|
||||
try:
|
||||
if not subscription.connected_squads:
|
||||
squads = tariff.allowed_squads or []
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
if squads:
|
||||
subscription.connected_squads = squads
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Авто-возобновление daily: не удалось восстановить connected_squads',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
# POST may ignore activeInternalSquads — follow up with PATCH
|
||||
await db.refresh(user)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as patch_err:
|
||||
logger.warning(
|
||||
'⚠️ Авто-возобновление daily: не удалось синхронизировать сквады',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=patch_err,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
|
||||
@@ -2426,7 +2524,7 @@ async def try_resume_disabled_daily_after_topup(
|
||||
'💳 Списано: {amount}\n'
|
||||
'💰 Остаток: {balance}',
|
||||
).format(
|
||||
tariff_name=tariff.name,
|
||||
tariff_name=html.escape(tariff.name),
|
||||
amount=settings.format_price(daily_price),
|
||||
balance=settings.format_price(user.balance_kopeks),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
|
||||
@@ -694,11 +695,9 @@ class TrafficMonitoringServiceV2:
|
||||
db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid)
|
||||
if db_user:
|
||||
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
|
||||
user_info = (
|
||||
f'👤 <b>{db_user.full_name or "Без имени"}</b>\n🆔 ID: <code>{user_id_display}</code>\n'
|
||||
)
|
||||
user_info = f'👤 <b>{html.escape(db_user.full_name or "Без имени")}</b>\n🆔 ID: <code>{user_id_display}</code>\n'
|
||||
if db_user.username:
|
||||
user_info += f'📱 Username: @{db_user.username}\n'
|
||||
user_info += f'📱 Username: @{html.escape(db_user.username)}\n'
|
||||
|
||||
if violation.check_type == 'fast':
|
||||
check_type_emoji = '⚡'
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
"""Shared formatting utilities for traffic, price, and period display."""
|
||||
|
||||
import html
|
||||
|
||||
|
||||
def safe_html_name(name: str | None) -> str:
|
||||
"""HTML-escape a display name for Telegram HTML messages."""
|
||||
return html.escape(name or '')
|
||||
|
||||
|
||||
def user_html_link(user) -> str:
|
||||
"""Build an HTML-safe clickable user link for Telegram messages."""
|
||||
safe = safe_html_name(user.full_name)
|
||||
if getattr(user, 'telegram_id', None):
|
||||
return f'<a href="tg://user?id={user.telegram_id}">{safe}</a>'
|
||||
return f'<b>{safe}</b>'
|
||||
|
||||
|
||||
def format_traffic(gb: int) -> str:
|
||||
"""Форматирует трафик."""
|
||||
|
||||
@@ -218,7 +218,16 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
raise
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
try:
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
return None
|
||||
if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower():
|
||||
return None
|
||||
if 'message is not modified' in str(error).lower():
|
||||
return None
|
||||
raise
|
||||
if self.photo:
|
||||
language = _get_language(self)
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
|
||||
@@ -113,15 +113,28 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
platega_name = settings.get_platega_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'platega',
|
||||
'name': 'Банковская карта',
|
||||
'icon': '💳',
|
||||
'description': f'через {platega_name} (карты + СБП)',
|
||||
'callback': 'topup_platega',
|
||||
}
|
||||
)
|
||||
if settings.PLATEGA_INLINE_METHODS:
|
||||
for method_code in settings.get_platega_active_methods():
|
||||
info = settings.get_platega_method_definitions().get(method_code, {})
|
||||
methods.append(
|
||||
{
|
||||
'id': f'platega_m{method_code}',
|
||||
'name': info.get('name', f'Метод {method_code}'),
|
||||
'icon': info.get('title', '💳').split(' ', 1)[0] if info.get('title') else '💳',
|
||||
'description': f'через {platega_name}',
|
||||
'callback': f'topup_platega_m{method_code}',
|
||||
}
|
||||
)
|
||||
else:
|
||||
methods.append(
|
||||
{
|
||||
'id': 'platega',
|
||||
'name': 'Банковская карта',
|
||||
'icon': '💳',
|
||||
'description': f'через {platega_name} (карты + СБП)',
|
||||
'callback': 'topup_platega',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_cloudpayments_enabled():
|
||||
cloudpayments_name = settings.get_cloudpayments_display_name()
|
||||
@@ -282,6 +295,14 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_heleket_enabled()
|
||||
if method_id == 'platega':
|
||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||
if method_id.startswith('platega_m'):
|
||||
if not settings.is_platega_enabled():
|
||||
return False
|
||||
try:
|
||||
code = int(method_id[len('platega_m') :])
|
||||
except ValueError:
|
||||
return False
|
||||
return code in settings.get_platega_active_methods()
|
||||
if method_id == 'cloudpayments':
|
||||
return settings.is_cloudpayments_enabled()
|
||||
if method_id == 'freekassa':
|
||||
|
||||
+156
-18
@@ -603,6 +603,14 @@ async def _resolve_user_from_init_data(
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
# Block access for banned/deleted users
|
||||
user_status = getattr(user, 'status', None)
|
||||
if user_status in ('blocked', 'deleted'):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is blocked or deleted',
|
||||
)
|
||||
|
||||
return user, webapp_data
|
||||
|
||||
|
||||
@@ -898,6 +906,12 @@ async def create_payment_link(
|
||||
) -> MiniAppPaymentCreateResponse:
|
||||
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||||
|
||||
if getattr(user, 'restriction_topup', False):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail='Balance top-up is restricted for this account',
|
||||
)
|
||||
|
||||
method = (payload.method or '').strip().lower()
|
||||
if not method:
|
||||
raise HTTPException(
|
||||
@@ -934,7 +948,9 @@ async def create_payment_link(
|
||||
payment_service = PaymentService(bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
payload=invoice_payload,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
@@ -971,7 +987,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
)
|
||||
confirmation_url = result.get('confirmation_url') if result else None
|
||||
if not result or not confirmation_url:
|
||||
@@ -1009,7 +1027,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
)
|
||||
if not result or not result.get('confirmation_url'):
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment')
|
||||
@@ -1041,7 +1061,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=user.language,
|
||||
)
|
||||
if not result or not result.get('payment_url'):
|
||||
@@ -1083,7 +1105,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
@@ -1121,7 +1145,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=user.language,
|
||||
)
|
||||
payment_url = result.get('payment_url') if result else None
|
||||
@@ -1160,7 +1186,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
if not result:
|
||||
@@ -1238,7 +1266,9 @@ async def create_payment_link(
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
payload=f'balance_{user.id}_{amount_kopeks}',
|
||||
)
|
||||
if not result:
|
||||
@@ -1288,7 +1318,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -1333,7 +1365,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
telegram_id=user.telegram_id,
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
@@ -1374,7 +1408,9 @@ async def create_payment_link(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
@@ -1405,7 +1441,9 @@ async def create_payment_link(
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=user.telegram_id,
|
||||
amount_kopeks=amount_kopeks or 0,
|
||||
description=settings.get_balance_payment_description(amount_kopeks or 0),
|
||||
description=settings.get_balance_payment_description(
|
||||
amount_kopeks or 0, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -4692,6 +4730,14 @@ async def _authorize_miniapp_user(
|
||||
detail={'code': 'user_not_found', 'message': 'User not found'},
|
||||
)
|
||||
|
||||
# Block access for banned/deleted users
|
||||
user_status = getattr(user, 'status', None)
|
||||
if user_status in ('blocked', 'deleted'):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={'code': 'account_blocked', 'message': 'Account is blocked or deleted'},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -5101,6 +5147,16 @@ async def submit_subscription_renewal_endpoint(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionRenewalResponse:
|
||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'subscription_restricted',
|
||||
'message': 'Subscription purchases are restricted for this account',
|
||||
},
|
||||
)
|
||||
|
||||
subscription = _ensure_paid_subscription(
|
||||
user,
|
||||
allowed_statuses={'active', 'trial', 'expired'},
|
||||
@@ -5414,6 +5470,15 @@ async def subscription_purchase_endpoint(
|
||||
) -> MiniAppSubscriptionPurchaseResponse:
|
||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'subscription_restricted',
|
||||
'message': 'Subscription purchases are restricted for this account',
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
@@ -6363,6 +6428,15 @@ async def purchase_tariff_endpoint(
|
||||
"""Покупка или смена тарифа."""
|
||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'subscription_restricted',
|
||||
'message': 'Subscription purchases are restricted for this account',
|
||||
},
|
||||
)
|
||||
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -7268,17 +7342,81 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
|
||||
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
|
||||
if not new_paused_state and was_disabled:
|
||||
# Restore connected_squads from tariff if cleared by deactivation sync
|
||||
try:
|
||||
if not subscription.connected_squads:
|
||||
squads = tariff.allowed_squads or []
|
||||
if not squads:
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
if squads:
|
||||
subscription.connected_squads = squads
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as sq_err:
|
||||
logger.warning('Failed to restore connected_squads (miniapp)', error=sq_err)
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
# POST /api/users may ignore activeInternalSquads —
|
||||
# follow up with PATCH to ensure internal squads are assigned
|
||||
await db.refresh(user)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads:
|
||||
try:
|
||||
await service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
sync_squads=True,
|
||||
)
|
||||
except Exception as squad_err:
|
||||
logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
|
||||
|
||||
# Send admin notification about daily subscription resume
|
||||
if resume_transaction is not None:
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=resume_transaction,
|
||||
period_days=1,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as notif_err:
|
||||
logger.error('Failed to send admin notification for daily resume (miniapp)', error=notif_err)
|
||||
|
||||
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
|
||||
if new_paused_state:
|
||||
message = 'Суточная подписка приостановлена' if lang == 'ru' else 'Daily subscription paused'
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
subtract_user_balance,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
|
||||
@@ -311,14 +312,24 @@ async def update_balance(
|
||||
if not found_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
found_user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or 'Корректировка через веб-API',
|
||||
create_transaction=payload.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
if payload.amount_kopeks > 0:
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
found_user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or 'Корректировка через веб-API',
|
||||
create_transaction=payload.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
else:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
found_user,
|
||||
amount_kopeks=abs(payload.amount_kopeks),
|
||||
description=payload.description or 'Корректировка через веб-API',
|
||||
create_transaction=payload.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to update balance')
|
||||
|
||||
@@ -28,24 +28,24 @@ class SubscriptionResponse(BaseModel):
|
||||
class SubscriptionCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
is_trial: bool = False
|
||||
duration_days: int | None = None
|
||||
traffic_limit_gb: int | None = None
|
||||
device_limit: int | None = None
|
||||
duration_days: int | None = Field(None, ge=1, le=36500)
|
||||
traffic_limit_gb: int | None = Field(None, ge=0, le=1_000_000)
|
||||
device_limit: int | None = Field(None, ge=1, le=10_000)
|
||||
squad_uuid: str | None = None
|
||||
connected_squads: list[str] | None = None
|
||||
replace_existing: bool = False
|
||||
|
||||
|
||||
class SubscriptionExtendRequest(BaseModel):
|
||||
days: int = Field(..., gt=0)
|
||||
days: int = Field(..., gt=0, le=36500)
|
||||
|
||||
|
||||
class SubscriptionTrafficRequest(BaseModel):
|
||||
gb: int = Field(..., gt=0)
|
||||
gb: int = Field(..., gt=0, le=1_000_000)
|
||||
|
||||
|
||||
class SubscriptionDevicesRequest(BaseModel):
|
||||
devices: int = Field(..., gt=0)
|
||||
devices: int = Field(..., gt=0, le=10_000)
|
||||
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user