fix PR problems

This commit is contained in:
firewookie
2026-03-10 14:29:29 +05:00
parent dcfd54a7cb
commit fd3466b75c
50 changed files with 216 additions and 211 deletions
+4 -3
View File
@@ -85,6 +85,7 @@ async def update_partner_settings(
admin: User = Depends(require_permission('partners:settings')), admin: User = Depends(require_permission('partners:settings')),
): ):
"""Update partner system settings.""" """Update partner system settings."""
import asyncio
from pathlib import Path from pathlib import Path
# Update in-memory settings # Update in-memory settings
@@ -104,8 +105,8 @@ async def update_partner_settings(
# Persist to .env file # Persist to .env file
try: try:
env_file = Path('.env') env_file = Path('.env')
if env_file.exists(): if await asyncio.to_thread(env_file.exists):
lines = env_file.read_text().splitlines() lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates: dict[str, str] = {} updates: dict[str, str] = {}
if request.withdrawal_enabled is not None: if request.withdrawal_enabled is not None:
@@ -143,7 +144,7 @@ async def update_partner_settings(
if key not in updated_keys: if key not in updated_keys:
new_lines.append(f'{key}={value}') new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n') await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id) logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e: except Exception as e:
logger.warning('Failed to update .env file', error=e) logger.warning('Failed to update .env file', error=e)
+1 -1
View File
@@ -210,7 +210,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA: if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'} return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT: if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'} return status_str == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS: if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'} return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA: if record.method == PaymentMethod.FREEKASSA:
+1 -1
View File
@@ -303,7 +303,7 @@ async def create_new_tariff(
period_prices=period_prices_dict, period_prices=period_prices_dict,
allowed_squads=request.allowed_squads, allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict, server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None, promo_group_ids=request.promo_group_ids or None,
# Произвольное количество дней # Произвольное количество дней
custom_days_enabled=request.custom_days_enabled, custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks, price_per_day_kopeks=request.price_per_day_kopeks,
+4 -3
View File
@@ -246,6 +246,7 @@ async def update_ticket_settings(
db: AsyncSession = Depends(get_cabinet_db), db: AsyncSession = Depends(get_cabinet_db),
): ):
"""Update ticket system settings.""" """Update ticket system settings."""
import asyncio
from pathlib import Path from pathlib import Path
from app.services.support_settings_service import SupportSettingsService from app.services.support_settings_service import SupportSettingsService
@@ -280,8 +281,8 @@ async def update_ticket_settings(
# Try to persist to .env file # Try to persist to .env file
try: try:
env_file = Path('.env') env_file = Path('.env')
if env_file.exists(): if await asyncio.to_thread(env_file.exists):
lines = env_file.read_text().splitlines() lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates = {} updates = {}
if request.sla_enabled is not None: if request.sla_enabled is not None:
@@ -314,7 +315,7 @@ async def update_ticket_settings(
if key not in updated_keys: if key not in updated_keys:
new_lines.append(f'{key}={value}') new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n') await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file') logger.info('Updated ticket settings in .env file')
except Exception as e: except Exception as e:
logger.warning('Failed to update .env file', error=e) logger.warning('Failed to update .env file', error=e)
+6 -6
View File
@@ -774,7 +774,7 @@ async def register_email(
}, },
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
await asyncio.to_thread( await asyncio.to_thread(
email_service.send_verification_email, email_service.send_verification_email,
@@ -911,7 +911,7 @@ async def register_email_standalone(
}, },
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
await asyncio.to_thread( await asyncio.to_thread(
email_service.send_verification_email, email_service.send_verification_email,
@@ -1049,7 +1049,7 @@ async def resend_verification(
}, },
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
await asyncio.to_thread( await asyncio.to_thread(
email_service.send_verification_email, email_service.send_verification_email,
@@ -1356,7 +1356,7 @@ async def forgot_password(
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)}, context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
await asyncio.to_thread( await asyncio.to_thread(
email_service.send_password_reset_email, email_service.send_password_reset_email,
@@ -1524,7 +1524,7 @@ async def request_email_change(
}, },
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
try: try:
await asyncio.to_thread( await asyncio.to_thread(
@@ -1579,7 +1579,7 @@ async def request_email_change(
}, },
db=db, db=db,
) )
custom_subject, custom_body = override if override else (None, None) custom_subject, custom_body = override or (None, None)
await asyncio.to_thread( await asyncio.to_thread(
email_service.send_email_change_code, email_service.send_email_change_code,
+2 -2
View File
@@ -198,7 +198,7 @@ async def get_payment_methods(
'description': description, 'description': description,
} }
) )
options = formatted_options if formatted_options else None options = formatted_options or None
methods.append( methods.append(
PaymentMethodResponse( PaymentMethodResponse(
@@ -885,7 +885,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA: if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'} return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT: if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'} return status == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS: if record.method == PaymentMethod.CLOUDPAYMENTS:
return status in {'pending', 'authorized'} return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA: if record.method == PaymentMethod.FREEKASSA:
+8 -7
View File
@@ -1,5 +1,6 @@
"""Branding routes for cabinet - logo, project name, and theme colors management.""" """Branding routes for cabinet - logo, project name, and theme colors management."""
import asyncio
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -401,7 +402,7 @@ async def get_logo():
""" """
logo_path = get_logo_path() logo_path = get_logo_path()
if logo_path is None or not logo_path.exists(): if logo_path is None or not await asyncio.to_thread(logo_path.exists):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set') raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension # Determine media type from file extension
@@ -470,7 +471,7 @@ async def upload_logo(
) )
# Ensure directory exists # Ensure directory exists
ensure_branding_dir() await asyncio.to_thread(ensure_branding_dir)
# Determine file extension from content type # Determine file extension from content type
ext_map = { ext_map = {
@@ -483,12 +484,12 @@ async def upload_logo(
extension = ext_map.get(file.content_type, '.png') extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension # Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'): for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
old_file.unlink() await asyncio.to_thread(old_file.unlink)
# Save new logo # Save new logo
logo_path = BRANDING_DIR / f'logo{extension}' logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content) await asyncio.to_thread(logo_path.write_bytes, content)
# Mark that we have a custom logo # Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom') await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
@@ -517,8 +518,8 @@ async def delete_logo(
): ):
"""Delete custom logo and revert to letter. Admin only.""" """Delete custom logo and revert to letter. Admin only."""
# Remove logo files # Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'): for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
old_file.unlink() await asyncio.to_thread(old_file.unlink)
# Update setting # Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default') await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
+23 -19
View File
@@ -260,13 +260,17 @@ async def create_gift_purchase(
detail='payment_method is required for gateway mode', detail='payment_method is required for gateway mode',
) )
purchase_kwargs: dict = { purchase_kwargs: dict = (
'gift_recipient_type': body.recipient_type, {
'gift_recipient_value': body.recipient_value, 'gift_recipient_type': body.recipient_type,
'gift_message': body.gift_message, 'gift_recipient_value': body.recipient_value,
} if has_recipient else { 'gift_message': body.gift_message,
'gift_message': body.gift_message, }
} if has_recipient
else {
'gift_message': body.gift_message,
}
)
try: try:
purchase = await create_purchase( purchase = await create_purchase(
@@ -345,13 +349,17 @@ async def create_gift_purchase(
) )
# Create purchase record # Create purchase record
balance_purchase_kwargs: dict = { balance_purchase_kwargs: dict = (
'gift_recipient_type': body.recipient_type, {
'gift_recipient_value': body.recipient_value, 'gift_recipient_type': body.recipient_type,
'gift_message': body.gift_message, 'gift_recipient_value': body.recipient_value,
} if has_recipient else { 'gift_message': body.gift_message,
'gift_message': body.gift_message, }
} if has_recipient
else {
'gift_message': body.gift_message,
}
)
try: try:
purchase = await create_purchase( purchase = await create_purchase(
@@ -497,11 +505,7 @@ async def get_gift_purchase_status(
else: else:
token_filter = GuestPurchase.token.startswith(token) token_filter = GuestPurchase.token.startswith(token)
result = await db.execute( result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter)
)
purchase = result.scalars().first() purchase = result.scalars().first()
if purchase is None: if purchase is None:
raise HTTPException( raise HTTPException(
+1 -1
View File
@@ -160,7 +160,7 @@ async def get_rules(
db: AsyncSession = Depends(get_cabinet_db), db: AsyncSession = Depends(get_cabinet_db),
): ):
"""Get service rules - uses same function as bot.""" """Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower() requested_lang = language.split('-', maxsplit=1)[0].lower()
# Use the same function as bot to ensure consistent content # Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang) content = await get_current_rules_content(db, requested_lang)
+1 -1
View File
@@ -1469,7 +1469,7 @@ async def activate_trial(
duration_days=trial_duration, duration_days=trial_duration,
traffic_limit_gb=trial_traffic_limit, traffic_limit_gb=trial_traffic_limit,
device_limit=trial_device_limit, device_limit=trial_device_limit,
connected_squads=trial_squads if trial_squads else None, connected_squads=trial_squads or None,
tariff_id=tariff_id_for_trial, tariff_id=tariff_id_for_trial,
) )
+4 -4
View File
@@ -1,13 +1,13 @@
"""Schemas for Admin Users management in cabinet.""" """Schemas for Admin Users management in cabinet."""
from datetime import datetime from datetime import datetime
from enum import Enum from enum import StrEnum
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class UserStatusEnum(str, Enum): class UserStatusEnum(StrEnum):
"""User status enum.""" """User status enum."""
ACTIVE = 'active' ACTIVE = 'active'
@@ -15,7 +15,7 @@ class UserStatusEnum(str, Enum):
DELETED = 'deleted' DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum): class SubscriptionStatusEnum(StrEnum):
"""Subscription status enum.""" """Subscription status enum."""
TRIAL = 'trial' TRIAL = 'trial'
@@ -25,7 +25,7 @@ class SubscriptionStatusEnum(str, Enum):
PENDING = 'pending' PENDING = 'pending'
class SortByEnum(str, Enum): class SortByEnum(StrEnum):
"""Sort options for users list.""" """Sort options for users list."""
CREATED_AT = 'created_at' CREATED_AT = 'created_at'
+3 -3
View File
@@ -1,7 +1,7 @@
"""Схемы для колеса удачи (Fortune Wheel).""" """Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime from datetime import datetime
from enum import Enum from enum import StrEnum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -9,14 +9,14 @@ from pydantic import BaseModel, Field
# ==================== ENUMS ==================== # ==================== ENUMS ====================
class WheelPaymentType(str, Enum): class WheelPaymentType(StrEnum):
"""Способы оплаты спина.""" """Способы оплаты спина."""
TELEGRAM_STARS = 'telegram_stars' TELEGRAM_STARS = 'telegram_stars'
SUBSCRIPTION_DAYS = 'subscription_days' SUBSCRIPTION_DAYS = 'subscription_days'
class WheelPrizeType(str, Enum): class WheelPrizeType(StrEnum):
"""Типы призов.""" """Типы призов."""
SUBSCRIPTION_DAYS = 'subscription_days' SUBSCRIPTION_DAYS = 'subscription_days'
+22 -22
View File
@@ -949,12 +949,12 @@ class Settings(BaseSettings):
def get_test_email(self) -> str | None: def get_test_email(self) -> str | None:
"""Get test email for development/testing.""" """Get test email for development/testing."""
email = (self.TEST_EMAIL or '').strip().lower() email = (self.TEST_EMAIL or '').strip().lower()
return email if email else None return email or None
def get_test_email_password(self) -> str | None: def get_test_email_password(self) -> str | None:
"""Get test email password.""" """Get test email password."""
password = (self.TEST_EMAIL_PASSWORD or '').strip() password = (self.TEST_EMAIL_PASSWORD or '').strip()
return password if password else None return password or None
def is_test_email(self, email: str) -> bool: def is_test_email(self, email: str) -> bool:
"""Check if email is the configured test email.""" """Check if email is the configured test email."""
@@ -1519,7 +1519,7 @@ class Settings(BaseSettings):
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
return packages if packages else self.get_traffic_packages() return packages or self.get_traffic_packages()
def get_traffic_topup_price(self, gb: int | None) -> int: def get_traffic_topup_price(self, gb: int | None) -> int:
"""Возвращает цену докупки для указанного количества ГБ.""" """Возвращает цену докупки для указанного количества ГБ."""
@@ -1609,7 +1609,7 @@ class Settings(BaseSettings):
def get_yookassa_display_name(self) -> str: def get_yookassa_display_name(self) -> str:
name = (self.YOOKASSA_DISPLAY_NAME or '').strip() name = (self.YOOKASSA_DISPLAY_NAME or '').strip()
return name if name else 'YooKassa' return name or 'YooKassa'
def is_nalogo_enabled(self) -> bool: def is_nalogo_enabled(self) -> bool:
return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None
@@ -1629,14 +1629,14 @@ class Settings(BaseSettings):
def get_cryptobot_display_name(self) -> str: def get_cryptobot_display_name(self) -> str:
name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip() name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip()
return name if name else 'CryptoBot' return name or 'CryptoBot'
def is_heleket_enabled(self) -> bool: def is_heleket_enabled(self) -> bool:
return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None
def get_heleket_display_name(self) -> str: def get_heleket_display_name(self) -> str:
name = (self.HELEKET_DISPLAY_NAME or '').strip() name = (self.HELEKET_DISPLAY_NAME or '').strip()
return name if name else 'Heleket Crypto' return name or 'Heleket Crypto'
def is_mulenpay_enabled(self) -> bool: def is_mulenpay_enabled(self) -> bool:
return ( return (
@@ -1674,7 +1674,7 @@ class Settings(BaseSettings):
def get_pal24_display_name(self) -> str: def get_pal24_display_name(self) -> str:
name = (self.PAL24_DISPLAY_NAME or '').strip() name = (self.PAL24_DISPLAY_NAME or '').strip()
return name if name else 'PAL24' return name or 'PAL24'
def is_platega_enabled(self) -> bool: def is_platega_enabled(self) -> bool:
return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None
@@ -1754,7 +1754,7 @@ class Settings(BaseSettings):
def get_wata_display_name(self) -> str: def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or '').strip() name = (self.WATA_DISPLAY_NAME or '').strip()
return name if name else 'Wata' return name or 'Wata'
def is_cloudpayments_enabled(self) -> bool: def is_cloudpayments_enabled(self) -> bool:
return ( return (
@@ -1765,7 +1765,7 @@ class Settings(BaseSettings):
def get_cloudpayments_display_name(self) -> str: def get_cloudpayments_display_name(self) -> str:
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip() name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip()
return name if name else 'CloudPayments' return name or 'CloudPayments'
def is_freekassa_enabled(self) -> bool: def is_freekassa_enabled(self) -> bool:
return ( return (
@@ -1778,7 +1778,7 @@ class Settings(BaseSettings):
def get_freekassa_display_name(self) -> str: def get_freekassa_display_name(self) -> str:
name = (self.FREEKASSA_DISPLAY_NAME or '').strip() name = (self.FREEKASSA_DISPLAY_NAME or '').strip()
return name if name else 'Freekassa' return name or 'Freekassa'
def get_freekassa_display_name_html(self) -> str: def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name()) return html.escape(self.get_freekassa_display_name())
@@ -1788,7 +1788,7 @@ class Settings(BaseSettings):
def get_freekassa_sbp_display_name(self) -> str: def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip() name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)' return name or 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str: def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name()) return html.escape(self.get_freekassa_sbp_display_name())
@@ -1798,7 +1798,7 @@ class Settings(BaseSettings):
def get_freekassa_card_display_name(self) -> str: def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip() name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ' return name or 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str: def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name()) return html.escape(self.get_freekassa_card_display_name())
@@ -1813,7 +1813,7 @@ class Settings(BaseSettings):
def get_kassa_ai_display_name(self) -> str: def get_kassa_ai_display_name(self) -> str:
name = (self.KASSA_AI_DISPLAY_NAME or '').strip() name = (self.KASSA_AI_DISPLAY_NAME or '').strip()
return name if name else 'KassaAI' return name or 'KassaAI'
def get_kassa_ai_display_name_html(self) -> str: def get_kassa_ai_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_display_name()) return html.escape(self.get_kassa_ai_display_name())
@@ -1823,7 +1823,7 @@ class Settings(BaseSettings):
def get_riopay_display_name(self) -> str: def get_riopay_display_name(self) -> str:
name = (self.RIOPAY_DISPLAY_NAME or '').strip() name = (self.RIOPAY_DISPLAY_NAME or '').strip()
return name if name else 'RioPay' return name or 'RioPay'
def get_riopay_display_name_html(self) -> str: def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_display_name()) return html.escape(self.get_riopay_display_name())
@@ -1923,7 +1923,7 @@ class Settings(BaseSettings):
'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()), 'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()),
} }
link = links.get(platform_key) link = links.get(platform_key)
return link if link else None return link or None
def is_maintenance_mode(self) -> bool: def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE return self.MAINTENANCE_MODE
@@ -2011,7 +2011,7 @@ class Settings(BaseSettings):
# т.к. в режиме classic цена складывается из серверов/трафика/устройств) # т.к. в режиме classic цена складывается из серверов/трафика/устройств)
periods = sorted(allowed_periods) periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180] return periods or [30, 90, 180]
def get_available_renewal_periods(self) -> list[int]: def get_available_renewal_periods(self) -> list[int]:
""" """
@@ -2036,7 +2036,7 @@ class Settings(BaseSettings):
# Возвращаем только разрешённые периоды (без фильтрации по цене) # Возвращаем только разрешённые периоды (без фильтрации по цене)
periods = sorted(allowed_periods) periods = sorted(allowed_periods)
return periods if periods else [30, 90, 180] return periods or [30, 90, 180]
def get_configured_subscription_periods(self) -> list[int]: def get_configured_subscription_periods(self) -> list[int]:
""" """
@@ -2101,7 +2101,7 @@ class Settings(BaseSettings):
def get_telegram_stars_display_name(self) -> str: def get_telegram_stars_display_name(self) -> str:
name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip() name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip()
return name if name else 'Telegram Stars' return name or 'Telegram Stars'
def stars_to_rubles(self, stars: int) -> float: def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate() return stars * self.get_stars_rate()
@@ -2138,7 +2138,7 @@ class Settings(BaseSettings):
def get_backup_archive_password(self) -> str | None: def get_backup_archive_password(self) -> str | None:
password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip() password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip()
return password if password else None return password or None
# === Log Rotation Methods === # === Log Rotation Methods ===
@@ -2219,7 +2219,7 @@ class Settings(BaseSettings):
except ValueError: except ValueError:
continue continue
return packages if packages else self._get_fallback_traffic_packages() return packages or self._get_fallback_traffic_packages()
except Exception as e: except Exception as e:
logger.warning('ERROR PARSING CONFIG', error=e) logger.warning('ERROR PARSING CONFIG', error=e)
@@ -2352,13 +2352,13 @@ class Settings(BaseSettings):
if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')): if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')):
url = self.get_support_contact_url() url = self.get_support_contact_url()
return url if url else contact return url or contact
contact_without_prefix = contact.lstrip('@') contact_without_prefix = contact.lstrip('@')
if '.' in contact_without_prefix: if '.' in contact_without_prefix:
url = self.get_support_contact_url() url = self.get_support_contact_url()
return url if url else contact return url or contact
if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix): if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix):
return f'@{contact_without_prefix}' return f'@{contact_without_prefix}'
+1 -1
View File
@@ -187,7 +187,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
return None return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован) # Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None return user_promo_groups[0].promo_group or None
except Exception as error: except Exception as error:
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error) logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
+2 -2
View File
@@ -8,7 +8,7 @@ def _aware(dt: datetime | None) -> datetime | None:
return dt return dt
from enum import Enum from enum import Enum, StrEnum
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
@@ -3124,7 +3124,7 @@ class LandingPage(Base):
return f"<LandingPage slug='{self.slug}' active={self.is_active}>" return f"<LandingPage slug='{self.slug}' active={self.is_active}>"
class GuestPurchaseStatus(str, Enum): class GuestPurchaseStatus(StrEnum):
PENDING = 'pending' PENDING = 'pending'
PAID = 'paid' PAID = 'paid'
DELIVERED = 'delivered' DELIVERED = 'delivered'
+1 -1
View File
@@ -29,7 +29,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User,
blacklist_count = len(await blacklist_service.get_all_blacklisted_users()) blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
status_text = '✅ Включена' if is_enabled else '❌ Отключена' status_text = '✅ Включена' if is_enabled else '❌ Отключена'
url_text = github_url if github_url else 'Не задан' url_text = github_url or 'Не задан'
text = f""" text = f"""
🔐 <b>Настройки черного списка</b> 🔐 <b>Настройки черного списка</b>
+4 -4
View File
@@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery):
await callback.answer('🔄 Анализирую логи платежей...', show_alert=False) await callback.answer('🔄 Анализирую логи платежей...', show_alert=False)
# Путь к файлу логов платежей (logs/current/) # Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve() log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent log_dir = log_file_path.parent
current_dir = log_dir / 'current' current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists(): if not await asyncio.to_thread(payments_log.exists):
try: try:
await callback.message.edit_text( await callback.message.edit_text(
'❌ <b>Файл логов не найден</b>\n\n' '❌ <b>Файл логов не найден</b>\n\n'
@@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
await callback.answer('🔄 Загружаю детали...', show_alert=False) await callback.answer('🔄 Загружаю детали...', show_alert=False)
# Путь к логам (logs/current/) # Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve() log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
log_dir = log_file_path.parent log_dir = log_file_path.parent
current_dir = log_dir / 'current' current_dir = log_dir / 'current'
payments_log = current_dir / settings.LOG_PAYMENTS_FILE payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists(): if not await asyncio.to_thread(payments_log.exists):
await callback.answer('❌ Файл логов не найден', show_alert=True) await callback.answer('❌ Файл логов не найден', show_alert=True)
return return
+2 -2
View File
@@ -188,7 +188,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA: if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'} return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT: if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'} return status == 'active'
if record.method == PaymentMethod.FREEKASSA: if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', ''} return status in {'pending', 'created', ''}
if record.method == PaymentMethod.KASSA_AI: if record.method == PaymentMethod.KASSA_AI:
@@ -380,7 +380,7 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str)
amount = f'{crypto_amount} {crypto_asset}' amount = f'{crypto_amount} {crypto_asset}'
created = format_datetime(record.created_at) created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language) age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id raw_identifier = record.identifier or record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else '' identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ''
lines = [ lines = [
texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'), texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'),
+5 -4
View File
@@ -1,3 +1,4 @@
import asyncio
import json import json
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -756,8 +757,8 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
# Информация о логах # Информация о логах
log_path = referral_diagnostics_service.log_path log_path = referral_diagnostics_service.log_path
log_exists = log_path.exists() log_exists = await asyncio.to_thread(log_path.exists)
log_size = log_path.stat().st_size if log_exists else 0 log_size = (await asyncio.to_thread(log_path.stat)).st_size if log_exists else 0
text += f'\n<i>📂 {log_path.name}' text += f'\n<i>📂 {log_path.name}'
if log_exists: if log_exists:
@@ -1434,9 +1435,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
finally: finally:
# Удаляем временный файл # Удаляем временный файл
if temp_file_path and Path(temp_file_path).exists(): if temp_file_path and await asyncio.to_thread(Path(temp_file_path).exists):
try: try:
Path(temp_file_path).unlink() await asyncio.to_thread(Path(temp_file_path).unlink)
logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path) logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path)
except Exception as e: except Exception as e:
logger.error('Ошибка удаления временного файла', error=e) logger.error('Ошибка удаления временного файла', error=e)
+1 -1
View File
@@ -81,7 +81,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
if current.strip(): if current.strip():
pages.append(current) pages.append(current)
return pages if pages else [header] return pages or [header]
async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+2 -2
View File
@@ -21,8 +21,8 @@ logger = structlog.get_logger(__name__)
FREEKASSA_SUB_METHODS = { FREEKASSA_SUB_METHODS = {
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()}, 'freekassa_sbp': {'payment_system_id': 44, 'get_name': settings.get_freekassa_sbp_display_name},
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()}, 'freekassa_card': {'payment_system_id': 36, 'get_name': settings.get_freekassa_card_display_name},
} }
+1 -1
View File
@@ -169,7 +169,7 @@ def _split_into_pages(
pages.append((current_online, current_offline)) pages.append((current_online, current_offline))
return pages if pages else [([], [])] return pages or [([], [])]
def _format_server_lines( def _format_server_lines(
+1 -1
View File
@@ -970,7 +970,7 @@ async def handle_simple_subscription_payment_method(
from aiogram.types import BufferedInputFile from aiogram.types import BufferedInputFile
# Используем qr_confirmation_data если доступно, иначе confirmation_url # Используем qr_confirmation_data если доступно, иначе confirmation_url
qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url qr_data = qr_confirmation_data or confirmation_url
# Создаем QR-код из полученных данных # Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5) qr = qrcode.QRCode(version=1, box_size=10, border=5)
+1 -1
View File
@@ -565,7 +565,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if referral_code: if referral_code:
await state.update_data(referral_code=referral_code) await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent: if campaign and not campaign_notification_sent:
try: try:
+4 -20
View File
@@ -277,11 +277,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку # Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = ( effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if effective_max and new_devices_count > effective_max: if effective_max and new_devices_count > effective_max:
await callback.answer( await callback.answer(
texts.t( texts.t(
@@ -571,11 +567,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
# Re-validate: prevent double-charge and max-limit violation # Re-validate: prevent double-charge and max-limit violation
if new_devices_count > current_devices: if new_devices_count > current_devices:
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = ( max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if max_devices and new_devices_count > max_devices: if max_devices and new_devices_count > max_devices:
if price > 0: if price > 0:
user_refund = await db.execute( user_refund = await db.execute(
@@ -1139,11 +1131,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку # Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
effective_max = ( effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
tariff_max_devices
if tariff_max_devices
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if effective_max and new_total_devices > effective_max: if effective_max and new_total_devices > effective_max:
await callback.answer( await callback.answer(
texts.t( texts.t(
@@ -1278,11 +1266,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
actual_current = subscription.device_limit or 1 actual_current = subscription.device_limit or 1
actual_new = actual_current + devices_count actual_new = actual_current + devices_count
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
max_devices = ( max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
tariff_max_recheck
if tariff_max_recheck
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
)
if max_devices and actual_new > max_devices: if max_devices and actual_new > max_devices:
# Concurrent purchase exceeded limit — refund # Concurrent purchase exceeded limit — refund
user_refund = await db.execute( user_refund = await db.execute(
+1 -1
View File
@@ -313,7 +313,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
devices_used_str = str(devices_used) devices_used_str = str(devices_used)
servers_names = await get_servers_display_names(subscription.connected_squads) servers_names = await get_servers_display_names(subscription.connected_squads)
servers_display = servers_names if servers_names else texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов') servers_display = servers_names or texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов')
# Получаем информацию о тарифе для режима тарифов # Получаем информацию о тарифе для режима тарифов
tariff_info_block = '' tariff_info_block = ''
+1 -1
View File
@@ -504,7 +504,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int
if current.strip(): if current.strip():
pages.append(current) pages.append(current)
return pages if pages else [header] return pages or [header]
async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession): async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+2 -2
View File
@@ -398,7 +398,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Notify user about deactivation # Notify user about deactivation
try: try:
normalized = _normalize_channels(channels) normalized = _normalize_channels(channels)
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) texts = get_texts(user.language or DEFAULT_LANGUAGE)
notification_text = texts.t( notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE', 'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n' '🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
@@ -468,7 +468,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
# Notify user about reactivation # Notify user about reactivation
try: try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) texts = get_texts(user.language or DEFAULT_LANGUAGE)
notification_text = texts.t( notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE', 'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.', '✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.',
+50 -42
View File
@@ -347,7 +347,7 @@ class BackupService:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir) temp_path = Path(temp_dir)
staging_dir = temp_path / 'backup' staging_dir = temp_path / 'backup'
staging_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(staging_dir.mkdir, True, True)
database_info = await self._dump_database(staging_dir, include_logs=include_logs) database_info = await self._dump_database(staging_dir, include_logs=include_logs)
database_info.setdefault('tables_count', overview.get('tables_count', 0)) database_info.setdefault('tables_count', overview.get('tables_count', 0))
@@ -376,10 +376,10 @@ class BackupService:
mode = 'w:gz' if compress else 'w' mode = 'w:gz' if compress else 'w'
with tarfile.open(backup_path, mode) as tar: with tarfile.open(backup_path, mode) as tar:
for item in staging_dir.iterdir(): for item in await asyncio.to_thread(lambda: list(staging_dir.iterdir())):
tar.add(item, arcname=item.name) tar.add(item, arcname=item.name)
file_size = backup_path.stat().st_size file_size = (await asyncio.to_thread(backup_path.stat)).st_size
await self._cleanup_old_backups() await self._cleanup_old_backups()
@@ -415,7 +415,7 @@ class BackupService:
logger.info('📄 Начинаем восстановление из', backup_file_path=backup_file_path) logger.info('📄 Начинаем восстановление из', backup_file_path=backup_file_path)
backup_path = Path(backup_file_path) backup_path = Path(backup_file_path)
if not backup_path.exists(): if not await asyncio.to_thread(backup_path.exists):
return False, f'❌ Файл бекапа не найден: {backup_file_path}' return False, f'❌ Файл бекапа не найден: {backup_file_path}'
if self._is_archive_backup(backup_path): if self._is_archive_backup(backup_path):
@@ -473,7 +473,11 @@ class BackupService:
if pg_dump_path: if pg_dump_path:
dump_path = staging_dir / 'database.sql' dump_path = staging_dir / 'database.sql'
await self._dump_postgres(dump_path, pg_dump_path) await self._dump_postgres(dump_path, pg_dump_path)
size = dump_path.stat().st_size if dump_path.exists() else 0 size = (
(await asyncio.to_thread(dump_path.stat)).st_size
if await asyncio.to_thread(dump_path.exists)
else 0
)
return { return {
'type': 'postgresql', 'type': 'postgresql',
'path': dump_path.name, 'path': dump_path.name,
@@ -488,7 +492,7 @@ class BackupService:
dump_path = staging_dir / 'database.sqlite' dump_path = staging_dir / 'database.sqlite'
await self._dump_sqlite(dump_path) await self._dump_sqlite(dump_path)
size = dump_path.stat().st_size if dump_path.exists() else 0 size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
return { return {
'type': 'sqlite', 'type': 'sqlite',
'path': dump_path.name, 'path': dump_path.name,
@@ -516,9 +520,9 @@ class BackupService:
] ]
logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path) logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path)
dump_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(dump_path.parent.mkdir, True, True)
with dump_path.open('wb') as dump_file: with open(dump_path, 'wb') as dump_file:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*command, *command,
stdout=dump_file, stdout=dump_file,
@@ -558,7 +562,7 @@ class BackupService:
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as dump_file: async with aiofiles.open(dump_path, 'w', encoding='utf-8') as dump_file:
await dump_file.write(json_lib.dumps(dump_structure, ensure_ascii=False, indent=2)) await dump_file.write(json_lib.dumps(dump_structure, ensure_ascii=False, indent=2))
size = dump_path.stat().st_size if dump_path.exists() else 0 size = (await asyncio.to_thread(dump_path.stat)).st_size if await asyncio.to_thread(dump_path.exists) else 0
logger.info('✅ PostgreSQL экспортирован через ORM в JSON', dump_path=dump_path) logger.info('✅ PostgreSQL экспортирован через ORM в JSON', dump_path=dump_path)
@@ -575,10 +579,10 @@ class BackupService:
async def _dump_sqlite(self, dump_path: Path): async def _dump_sqlite(self, dump_path: Path):
sqlite_path = Path(settings.SQLITE_PATH) sqlite_path = Path(settings.SQLITE_PATH)
if not sqlite_path.exists(): if not await asyncio.to_thread(sqlite_path.exists):
raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}') raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}')
dump_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(dump_path.parent.mkdir, True, True)
await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path) await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path)
logger.info('✅ SQLite база данных скопирована', dump_path=dump_path) logger.info('✅ SQLite база данных скопирована', dump_path=dump_path)
@@ -662,11 +666,11 @@ class BackupService:
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]: async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
files_info: list[dict[str, Any]] = [] files_info: list[dict[str, Any]] = []
files_dir = staging_dir / 'files' files_dir = staging_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(files_dir.mkdir, True, True)
if include_logs and settings.LOG_FILE: if include_logs and settings.LOG_FILE:
log_path = Path(settings.LOG_FILE) log_path = Path(settings.LOG_FILE)
if log_path.exists(): if await asyncio.to_thread(log_path.exists):
dest = files_dir / log_path.name dest = files_dir / log_path.name
await asyncio.to_thread(shutil.copy2, log_path, dest) await asyncio.to_thread(shutil.copy2, log_path, dest)
files_info.append( files_info.append(
@@ -676,8 +680,8 @@ class BackupService:
} }
) )
if not files_info and files_dir.exists(): if not files_info and await asyncio.to_thread(files_dir.exists):
files_dir.rmdir() await asyncio.to_thread(files_dir.rmdir)
return files_info return files_info
@@ -688,7 +692,7 @@ class BackupService:
'items': 0, 'items': 0,
} }
if not self.data_dir.exists(): if not await asyncio.to_thread(self.data_dir.exists):
return snapshot_info return snapshot_info
counter = {'items': 0} counter = {'items': 0}
@@ -732,7 +736,7 @@ class BackupService:
tar.extractall(temp_path, filter='data') tar.extractall(temp_path, filter='data')
metadata_path = temp_path / 'metadata.json' metadata_path = temp_path / 'metadata.json'
if not metadata_path.exists(): if not await asyncio.to_thread(metadata_path.exists):
return False, '❌ Метаданные бекапа отсутствуют' return False, '❌ Метаданные бекапа отсутствуют'
async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file: async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file:
@@ -758,7 +762,7 @@ class BackupService:
await self._restore_sqlite(dump_file, clear_existing) await self._restore_sqlite(dump_file, clear_existing)
data_dir = temp_path / 'data' data_dir = temp_path / 'data'
if data_dir.exists(): if await asyncio.to_thread(data_dir.exists):
await self._restore_data_snapshot(data_dir, clear_existing) await self._restore_data_snapshot(data_dir, clear_existing)
if files_info: if files_info:
@@ -775,7 +779,7 @@ class BackupService:
return True, message return True, message
async def _restore_postgres(self, dump_path: Path, clear_existing: bool): async def _restore_postgres(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists(): if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'Dump PostgreSQL не найден: {dump_path}') raise FileNotFoundError(f'Dump PostgreSQL не найден: {dump_path}')
psql_path = self._resolve_command_path('psql', 'PSQL_PATH') psql_path = self._resolve_command_path('psql', 'PSQL_PATH')
@@ -833,7 +837,7 @@ class BackupService:
logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path) logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path)
async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool): async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists(): if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'JSON дамп PostgreSQL не найден: {dump_path}') raise FileNotFoundError(f'JSON дамп PostgreSQL не найден: {dump_path}')
async with aiofiles.open(dump_path, encoding='utf-8') as dump_file: async with aiofiles.open(dump_path, encoding='utf-8') as dump_file:
@@ -853,20 +857,20 @@ class BackupService:
logger.info('✅ PostgreSQL восстановлен из ORM JSON', dump_path=dump_path) logger.info('✅ PostgreSQL восстановлен из ORM JSON', dump_path=dump_path)
async def _restore_sqlite(self, dump_path: Path, clear_existing: bool): async def _restore_sqlite(self, dump_path: Path, clear_existing: bool):
if not dump_path.exists(): if not await asyncio.to_thread(dump_path.exists):
raise FileNotFoundError(f'SQLite файл не найден: {dump_path}') raise FileNotFoundError(f'SQLite файл не найден: {dump_path}')
target_path = Path(settings.SQLITE_PATH) target_path = Path(settings.SQLITE_PATH)
target_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(target_path.parent.mkdir, True, True)
if clear_existing and target_path.exists(): if clear_existing and await asyncio.to_thread(target_path.exists):
target_path.unlink() await asyncio.to_thread(target_path.unlink)
await asyncio.to_thread(shutil.copy2, dump_path, target_path) await asyncio.to_thread(shutil.copy2, dump_path, target_path)
logger.info('✅ SQLite база восстановлена', target_path=target_path) logger.info('✅ SQLite база восстановлена', target_path=target_path)
async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool): async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool):
if not source_dir.exists(): if not await asyncio.to_thread(source_dir.exists):
return return
def _restore(): def _restore():
@@ -891,7 +895,7 @@ class BackupService:
logger.info('📁 Снимок директории data восстановлен') logger.info('📁 Снимок директории data восстановлен')
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path): async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
allowed_base = self.data_dir.resolve() allowed_base = await asyncio.to_thread(self.data_dir.resolve)
for file_info in files_info: for file_info in files_info:
relative_path = file_info.get('relative_path') relative_path = file_info.get('relative_path')
@@ -899,21 +903,22 @@ class BackupService:
if not relative_path or not target_path: if not relative_path or not target_path:
continue continue
target_resolved = target_path.resolve() target_resolved = await asyncio.to_thread(target_path.resolve)
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base: if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path) logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path)
continue continue
source_file = (temp_path / relative_path).resolve() source_file = await asyncio.to_thread((temp_path / relative_path).resolve)
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep): temp_path_resolved = await asyncio.to_thread(temp_path.resolve)
if not str(source_file).startswith(str(temp_path_resolved) + os.sep):
logger.warning('Path traversal в relative_path', relative_path=relative_path) logger.warning('Path traversal в relative_path', relative_path=relative_path)
continue continue
if not source_file.exists(): if not await asyncio.to_thread(source_file.exists):
logger.warning('Файл отсутствует в архиве', relative_path=relative_path) logger.warning('Файл отсутствует в архиве', relative_path=relative_path)
continue continue
target_resolved.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(target_resolved.parent.mkdir, True, True)
await asyncio.to_thread(shutil.copy2, source_file, target_resolved) await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
logger.info('📁 Файл восстановлен', target_resolved=target_resolved) logger.info('📁 Файл восстановлен', target_resolved=target_resolved)
@@ -1573,8 +1578,10 @@ class BackupService:
backups = [] backups = []
try: try:
for backup_file in sorted(self.backup_dir.glob('backup_*'), reverse=True): for backup_file in sorted(
if not backup_file.is_file(): await asyncio.to_thread(lambda: list(self.backup_dir.glob('backup_*'))), reverse=True
):
if not await asyncio.to_thread(backup_file.is_file):
continue continue
try: try:
@@ -1598,7 +1605,7 @@ class BackupService:
backup_structure = json_lib.load(f) backup_structure = json_lib.load(f)
metadata = backup_structure.get('metadata', {}) metadata = backup_structure.get('metadata', {})
file_stats = backup_file.stat() file_stats = await asyncio.to_thread(backup_file.stat)
backup_info = { backup_info = {
'filename': backup_file.name, 'filename': backup_file.name,
@@ -1626,7 +1633,7 @@ class BackupService:
except Exception as e: except Exception as e:
logger.error('Ошибка чтения метаданных', backup_file=backup_file, error=e) logger.error('Ошибка чтения метаданных', backup_file=backup_file, error=e)
file_stats = backup_file.stat() file_stats = await asyncio.to_thread(backup_file.stat)
backups.append( backups.append(
{ {
'filename': backup_file.name, 'filename': backup_file.name,
@@ -1651,14 +1658,15 @@ class BackupService:
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]: async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
try: try:
backup_path = (self.backup_dir / backup_filename).resolve() backup_path = await asyncio.to_thread((self.backup_dir / backup_filename).resolve)
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep): backup_dir_resolved = await asyncio.to_thread(self.backup_dir.resolve)
if not str(backup_path).startswith(str(backup_dir_resolved) + os.sep):
return False, '❌ Недопустимое имя файла бекапа' return False, '❌ Недопустимое имя файла бекапа'
if not backup_path.is_file(): if not await asyncio.to_thread(backup_path.is_file):
return False, f'❌ Файл бекапа не найден: {backup_filename}' return False, f'❌ Файл бекапа не найден: {backup_filename}'
backup_path.unlink() await asyncio.to_thread(backup_path.unlink)
message = f'✅ Бекап {backup_filename} удален' message = f'✅ Бекап {backup_filename} удален'
logger.info(message) logger.info(message)
@@ -1826,9 +1834,9 @@ class BackupService:
await self.bot.send_document(**send_kwargs) await self.bot.send_document(**send_kwargs)
logger.info('Бекап отправлен в чат', chat_id=chat_id) logger.info('Бекап отправлен в чат', chat_id=chat_id)
if temp_zip_path and Path(temp_zip_path).exists(): if temp_zip_path and await asyncio.to_thread(Path(temp_zip_path).exists):
try: try:
Path(temp_zip_path).unlink() await asyncio.to_thread(Path(temp_zip_path).unlink)
except Exception as cleanup_error: except Exception as cleanup_error:
logger.warning('Не удалось удалить временный архив', cleanup_error=cleanup_error) logger.warning('Не удалось удалить временный архив', cleanup_error=cleanup_error)
@@ -1838,7 +1846,7 @@ class BackupService:
async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None: async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None:
try: try:
source_path = Path(file_path) source_path = Path(file_path)
if not source_path.exists(): if not await asyncio.to_thread(source_path.exists):
logger.error('Исходный файл бекапа не найден', file_path=file_path) logger.error('Исходный файл бекапа не найден', file_path=file_path)
return None return None
+4 -4
View File
@@ -1,9 +1,9 @@
"""Enum classes for contest system.""" """Enum classes for contest system."""
from enum import Enum from enum import StrEnum
class GameType(str, Enum): class GameType(StrEnum):
"""Types of daily contest games.""" """Types of daily contest games."""
QUEST_BUTTONS = 'quest_buttons' QUEST_BUTTONS = 'quest_buttons'
@@ -30,14 +30,14 @@ class GameType(str, Enum):
} }
class RoundStatus(str, Enum): class RoundStatus(StrEnum):
"""Contest round status.""" """Contest round status."""
ACTIVE = 'active' ACTIVE = 'active'
FINISHED = 'finished' FINISHED = 'finished'
class PrizeType(str, Enum): class PrizeType(StrEnum):
"""Types of prizes for contests.""" """Types of prizes for contests."""
DAYS = 'days' DAYS = 'days'
+9 -9
View File
@@ -78,8 +78,8 @@ class LogRotationService:
async def initialize(self) -> None: async def initialize(self) -> None:
"""Создать необходимые директории.""" """Создать необходимые директории."""
self.current_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(self.current_dir.mkdir, True, True)
self.archive_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(self.archive_dir.mkdir, True, True)
async def start(self) -> None: async def start(self) -> None:
"""Запустить сервис ротации.""" """Запустить сервис ротации."""
@@ -170,7 +170,7 @@ class LogRotationService:
# Собираем файлы для архивации # Собираем файлы для архивации
files_to_archive: list[tuple[Path, str]] = [] files_to_archive: list[tuple[Path, str]] = []
for name, log_path in self.log_files.items(): for name, log_path in self.log_files.items():
if log_path.exists() and log_path.stat().st_size > 0: if await asyncio.to_thread(log_path.exists) and (await asyncio.to_thread(log_path.stat)).st_size > 0:
files_to_archive.append((log_path, f'{name}.log')) files_to_archive.append((log_path, f'{name}.log'))
if not files_to_archive: if not files_to_archive:
@@ -184,7 +184,7 @@ class LogRotationService:
if archive_path: if archive_path:
# Очищаем текущие лог-файлы # Очищаем текущие лог-файлы
for log_path, _ in files_to_archive: for log_path, _ in files_to_archive:
log_path.write_text('') await asyncio.to_thread(log_path.write_text, '')
# Очистка старых архивов # Очистка старых архивов
await self._cleanup_old_archives() await self._cleanup_old_archives()
@@ -247,12 +247,12 @@ class LogRotationService:
keep_days = settings.LOG_ROTATION_KEEP_DAYS keep_days = settings.LOG_ROTATION_KEEP_DAYS
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days) cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
if not self.archive_dir.exists(): if not await asyncio.to_thread(self.archive_dir.exists):
return return
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar # Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
for archive_file in self.archive_dir.iterdir(): for archive_file in await asyncio.to_thread(lambda: list(self.archive_dir.iterdir())):
if not archive_file.is_file(): if not await asyncio.to_thread(archive_file.is_file):
continue continue
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz # Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
@@ -267,7 +267,7 @@ class LogRotationService:
file_date = file_date.replace(tzinfo=get_local_timezone()) file_date = file_date.replace(tzinfo=get_local_timezone())
if file_date < cutoff_date: if file_date < cutoff_date:
archive_file.unlink() await asyncio.to_thread(archive_file.unlink)
logger.info('Удален старый архив логов', archive_file_name=archive_file.name) logger.info('Удален старый архив логов', archive_file_name=archive_file.name)
except ValueError: except ValueError:
# Пропускаем файлы с некорректным форматом имени # Пропускаем файлы с некорректным форматом имени
@@ -287,7 +287,7 @@ class LogRotationService:
topic_id = settings.get_log_rotation_topic_id() topic_id = settings.get_log_rotation_topic_id()
try: try:
file_size_kb = archive_path.stat().st_size / 1024 file_size_kb = (await asyncio.to_thread(archive_path.stat)).st_size / 1024
caption = ( caption = (
f'<b>Логи бота</b>\n' f'<b>Логи бота</b>\n'
f'Дата: {date_str}\n' f'Дата: {date_str}\n'
+5 -1
View File
@@ -111,7 +111,11 @@ class MonitoringService:
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status) logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
return None return None
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not caption_exceeds_telegram_limit(text): if (
settings.ENABLE_LOGO_MODE
and await asyncio.to_thread(LOGO_PATH.exists)
and not caption_exceeds_telegram_limit(text)
):
try: try:
from app.utils.message_patch import _cache_logo_file_id, get_logo_media from app.utils.message_patch import _cache_logo_file_id, get_logo_media
+1 -1
View File
@@ -767,7 +767,7 @@ class YooKassaPaymentMixin:
) )
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой # Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
user_for_referrer = full_user if full_user else user user_for_referrer = full_user or user
referrer_info = format_referrer_info(user_for_referrer) referrer_info = format_referrer_info(user_for_referrer)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
@@ -154,7 +154,7 @@ def _get_platega_sub_options() -> list[dict] | None:
'name': info.get('title') or info.get('name') or f'Platega {method_code}', 'name': info.get('title') or info.get('name') or f'Platega {method_code}',
} }
) )
return options if options else None return options or None
except Exception: except Exception:
return None return None
@@ -527,7 +527,7 @@ class MiniAppSubscriptionPurchaseService:
value = int(package.get('gb') or 0) value = int(package.get('gb') or 0)
price_per_month = int(package.get('price') or 0) price_per_month = int(package.get('price') or 0)
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent) discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
label = texts.format_traffic(value if value else 0) label = texts.format_traffic(value or 0)
options.append( options.append(
PurchaseTrafficOption( PurchaseTrafficOption(
value=value, value=value,
@@ -587,7 +587,7 @@ class MiniAppSubscriptionPurchaseService:
options=options, options=options,
min_selectable=1 if options else 0, min_selectable=1 if options else 0,
max_selectable=len(options), max_selectable=len(options),
default_selection=default_selection if default_selection else [opt.uuid for opt in options[:1]], default_selection=default_selection or [opt.uuid for opt in options[:1]],
hint=None, hint=None,
) )
+1 -1
View File
@@ -1536,7 +1536,7 @@ class SubscriptionService:
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc) logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа # Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
sample_tariff = subscriptions[0].tariff if subscriptions[0].tariff else None sample_tariff = subscriptions[0].tariff or None
traffic_strategy = get_traffic_reset_strategy(sample_tariff) traffic_strategy = get_traffic_reset_strategy(sample_tariff)
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather # Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
+4 -4
View File
@@ -429,7 +429,7 @@ async def get_service_rules(
language: str = Query('ru', min_length=2, max_length=10), language: str = Query('ru', min_length=2, max_length=10),
fallback: bool = Query(True), fallback: bool = Query(True),
) -> ServiceRulesResponse: ) -> ServiceRulesResponse:
requested_lang = language.split('-')[0].lower() requested_lang = language.split('-', maxsplit=1)[0].lower()
rules = await get_rules_by_language(db, requested_lang) rules = await get_rules_by_language(db, requested_lang)
if not rules and fallback: if not rules and fallback:
@@ -467,7 +467,7 @@ async def clear_service_rules(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
language: str = Query('ru', min_length=2, max_length=10), language: str = Query('ru', min_length=2, max_length=10),
) -> Response: ) -> Response:
lang = language.split('-')[0].lower() lang = language.split('-', maxsplit=1)[0].lower()
await clear_all_rules(db, lang) await clear_all_rules(db, lang)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -479,7 +479,7 @@ async def get_service_rules_history(
language: str = Query('ru', min_length=2, max_length=10), language: str = Query('ru', min_length=2, max_length=10),
limit: int = Query(10, ge=1, le=100), limit: int = Query(10, ge=1, le=100),
) -> ServiceRulesHistoryResponse: ) -> ServiceRulesHistoryResponse:
lang = language.split('-')[0].lower() lang = language.split('-', maxsplit=1)[0].lower()
history = await get_all_rules_versions(db, lang, limit=limit) history = await get_all_rules_versions(db, lang, limit=limit)
items = [_serialize_rules(item) for item in history] items = [_serialize_rules(item) for item in history]
return ServiceRulesHistoryResponse( return ServiceRulesHistoryResponse(
@@ -500,7 +500,7 @@ async def restore_service_rules_version(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
language: str = Query('ru', min_length=2, max_length=10), language: str = Query('ru', min_length=2, max_length=10),
) -> ServiceRulesResponse: ) -> ServiceRulesResponse:
lang = language.split('-')[0].lower() lang = language.split('-', maxsplit=1)[0].lower()
restored = await restore_rules_version(db, rule_id, language=lang) restored = await restore_rules_version(db, rule_id, language=lang)
if not restored: if not restored:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Rules version not found') raise HTTPException(status.HTTP_404_NOT_FOUND, 'Rules version not found')
+4 -4
View File
@@ -3,13 +3,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import StrEnum
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class ButtonType(str, Enum): class ButtonType(StrEnum):
"""Тип кнопки меню.""" """Тип кнопки меню."""
BUILTIN = 'builtin' # Встроенная кнопка с callback_data BUILTIN = 'builtin' # Встроенная кнопка с callback_data
@@ -18,7 +18,7 @@ class ButtonType(str, Enum):
CALLBACK = 'callback' # Кастомная кнопка с любым callback_data CALLBACK = 'callback' # Кастомная кнопка с любым callback_data
class ButtonVisibility(str, Enum): class ButtonVisibility(StrEnum):
"""Видимость кнопки.""" """Видимость кнопки."""
ALL = 'all' # Видна всем ALL = 'all' # Видна всем
@@ -27,7 +27,7 @@ class ButtonVisibility(str, Enum):
SUBSCRIBERS = 'subscribers' # Только подписчикам SUBSCRIBERS = 'subscribers' # Только подписчикам
class ButtonOpenMode(str, Enum): class ButtonOpenMode(StrEnum):
"""Режим открытия кнопки.""" """Режим открытия кнопки."""
CALLBACK = 'callback' # Отправляет callback_data боту (по умолчанию) CALLBACK = 'callback' # Отправляет callback_data боту (по умолчанию)
+2 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import StrEnum
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -386,7 +386,7 @@ class MiniAppPaymentMethodsRequest(BaseModel):
init_data: str = Field(..., alias='initData') init_data: str = Field(..., alias='initData')
class MiniAppPaymentIntegrationType(str, Enum): class MiniAppPaymentIntegrationType(StrEnum):
IFRAME = 'iframe' IFRAME = 'iframe'
REDIRECT = 'redirect' REDIRECT = 'redirect'
+1 -1
View File
@@ -723,7 +723,7 @@ async def main():
timeline.log_section( timeline.log_section(
'Активные webhook endpoints', 'Активные webhook endpoints',
webhook_lines if webhook_lines else ['Нет активных endpoints'], webhook_lines or ['Нет активных endpoints'],
icon='🎯', icon='🎯',
) )
@@ -1,7 +1,7 @@
"""add riopay_payments table """add riopay_payments table
Revision ID: 0032 Revision ID: 0036
Revises: 0031 Revises: 0035
Create Date: 2026-03-08 Create Date: 2026-03-08
""" """
@@ -11,8 +11,8 @@ from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = '0032' revision: str = '0036'
down_revision: Union[str, None] = '0031' down_revision: Union[str, None] = '0035'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
+1
View File
@@ -166,6 +166,7 @@ ignore = [
'TC001', 'TC001',
'TC002', 'TC002',
'TC003', 'TC003',
'A003',
'N805', 'N805',
'N815', 'N815',
'RET504', 'RET504',
@@ -246,7 +246,7 @@ class TestPayloadIntegration:
mock_state = AsyncMock() mock_state = AsyncMock()
mock_state.get_data = AsyncMock(return_value=state_storage) mock_state.get_data = AsyncMock(return_value=state_storage)
mock_state.set_data = AsyncMock(side_effect=lambda d: state_storage.update(d)) mock_state.set_data = AsyncMock(side_effect=state_storage.update)
mock_message = _create_mock_message('/start ref_flow_test', 111222) mock_message = _create_mock_message('/start ref_flow_test', 111222)
@@ -341,7 +341,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService2),
) )
class DummyAsyncSession: class DummyAsyncSession:
@@ -354,7 +354,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
async def rollback(self): # pragma: no cover - defensive stub async def rollback(self): # pragma: no cover - defensive stub
return None return None
monkeypatch.setattr(cryptobot_module, 'AsyncSessionLocal', lambda: DummyAsyncSession()) monkeypatch.setattr(cryptobot_module, 'AsyncSessionLocal', DummyAsyncSession)
monkeypatch.setattr(payment_service_module.currency_converter, 'usd_to_rub', AsyncMock(return_value=140.0)) monkeypatch.setattr(payment_service_module.currency_converter, 'usd_to_rub', AsyncMock(return_value=140.0))
monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}', raising=False) monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}', raising=False)
service.build_topup_success_keyboard = AsyncMock(return_value=None) service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -490,7 +490,7 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService),
) )
service.build_topup_success_keyboard = AsyncMock(return_value=None) service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -597,7 +597,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService),
) )
service.build_topup_success_keyboard = AsyncMock(return_value=None) service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -694,7 +694,7 @@ async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.M
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService),
) )
service.build_topup_success_keyboard = AsyncMock(return_value=None) service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -895,7 +895,7 @@ async def test_process_yookassa_webhook_restores_missing_payment(
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService),
) )
service.build_topup_success_keyboard = AsyncMock(return_value=None) service.build_topup_success_keyboard = AsyncMock(return_value=None)
@@ -1050,7 +1050,7 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)), SimpleNamespace(AdminNotificationService=DummyAdminServicePal),
) )
user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))) user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True)))
@@ -1215,7 +1215,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
'app.services.admin_notification_service', 'app.services.admin_notification_service',
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), SimpleNamespace(AdminNotificationService=DummyAdminService),
) )
user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))) user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False)))
@@ -141,7 +141,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
'app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService', 'app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService',
lambda: DummyMiniAppService(), DummyMiniAppService,
) )
monkeypatch.setattr( monkeypatch.setattr(
'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart',
@@ -107,7 +107,7 @@ async def test_initialize_skips_db_value_for_env_override(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
'app.services.system_settings_service.AsyncSessionLocal', 'app.services.system_settings_service.AsyncSessionLocal',
lambda: DummySession(), DummySession,
) )
async def fake_sync(): async def fake_sync():
@@ -52,7 +52,7 @@ def test_init_without_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
_prepare_config(monkeypatch) _prepare_config(monkeypatch)
monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False) monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False)
monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) monkeypatch.setattr(asyncio, 'get_running_loop', DummyLoop, raising=False)
captured_config: dict[str, tuple[str, str]] = {} captured_config: dict[str, tuple[str, str]] = {}
@@ -106,7 +106,7 @@ async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch)
_prepare_config(monkeypatch) _prepare_config(monkeypatch)
monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False) monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False)
monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False)
monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) monkeypatch.setattr(asyncio, 'get_running_loop', DummyLoop, raising=False)
monkeypatch.setattr( monkeypatch.setattr(
YooKassaPayment, YooKassaPayment,
'create', 'create',
@@ -142,7 +142,7 @@ async def test_create_payment_returns_none_when_not_configured(monkeypatch: pyte
@pytest.mark.anyio('asyncio') @pytest.mark.anyio('asyncio')
async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
_prepare_config(monkeypatch) _prepare_config(monkeypatch)
monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) monkeypatch.setattr(asyncio, 'get_running_loop', DummyLoop, raising=False)
monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False) monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False)
monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 'fallback@example.com', raising=False) monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 'fallback@example.com', raising=False)
+1 -1
View File
@@ -1032,7 +1032,7 @@ async def test_create_payment_link_stars_normalizes_amount(monkeypatch):
async def fake_resolve_user(db, init_data): async def fake_resolve_user(db, init_data):
return types.SimpleNamespace(id=7, language='ru'), {} return types.SimpleNamespace(id=7, language='ru'), {}
monkeypatch.setattr(miniapp, 'PaymentService', lambda bot: DummyPaymentService(bot)) monkeypatch.setattr(miniapp, 'PaymentService', DummyPaymentService)
monkeypatch.setattr(miniapp, 'Bot', DummyBot) monkeypatch.setattr(miniapp, 'Bot', DummyBot)
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
+1 -1
View File
@@ -103,7 +103,7 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
'app.services.remnawave_sync_service.AsyncSessionLocal', 'app.services.remnawave_sync_service.AsyncSessionLocal',
lambda: DummySession(), DummySession,
) )
monkeypatch.setattr( monkeypatch.setattr(
'app.services.remnawave_sync_service.sync_with_remnawave', 'app.services.remnawave_sync_service.sync_with_remnawave',
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]] [[package]]
name = "remnawave-bedolaga-telegram-bot" name = "remnawave-bedolaga-telegram-bot"
version = "3.25.0" version = "3.28.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiogram" }, { name = "aiogram" },