diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py index f47c92fd..6f6d65a8 100644 --- a/app/cabinet/routes/admin_partners.py +++ b/app/cabinet/routes/admin_partners.py @@ -85,6 +85,7 @@ async def update_partner_settings( admin: User = Depends(require_permission('partners:settings')), ): """Update partner system settings.""" + import asyncio from pathlib import Path # Update in-memory settings @@ -104,8 +105,8 @@ async def update_partner_settings( # Persist to .env file try: env_file = Path('.env') - if env_file.exists(): - lines = env_file.read_text().splitlines() + if await asyncio.to_thread(env_file.exists): + lines = (await asyncio.to_thread(env_file.read_text)).splitlines() updates: dict[str, str] = {} if request.withdrawal_enabled is not None: @@ -143,7 +144,7 @@ async def update_partner_settings( if key not in updated_keys: 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) except Exception as e: logger.warning('Failed to update .env file', error=e) diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index b8c71254..f8a1e568 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -210,7 +210,7 @@ def _is_checkable(record: PendingPayment) -> bool: if record.method == PaymentMethod.YOOKASSA: return status_str in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status_str in {'active'} + return status_str == 'active' if record.method == PaymentMethod.CLOUDPAYMENTS: return status_str in {'pending', 'authorized'} if record.method == PaymentMethod.FREEKASSA: diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index a64a2d4a..584d22ff 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -303,7 +303,7 @@ async def create_new_tariff( period_prices=period_prices_dict, allowed_squads=request.allowed_squads, 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, price_per_day_kopeks=request.price_per_day_kopeks, diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 47cafe14..16a3dedc 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -246,6 +246,7 @@ async def update_ticket_settings( db: AsyncSession = Depends(get_cabinet_db), ): """Update ticket system settings.""" + import asyncio from pathlib import Path from app.services.support_settings_service import SupportSettingsService @@ -280,8 +281,8 @@ async def update_ticket_settings( # Try to persist to .env file try: env_file = Path('.env') - if env_file.exists(): - lines = env_file.read_text().splitlines() + if await asyncio.to_thread(env_file.exists): + lines = (await asyncio.to_thread(env_file.read_text)).splitlines() updates = {} if request.sla_enabled is not None: @@ -314,7 +315,7 @@ async def update_ticket_settings( if key not in updated_keys: 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') except Exception as e: logger.warning('Failed to update .env file', error=e) diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index 8c8c4e53..c3810443 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -774,7 +774,7 @@ async def register_email( }, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) await asyncio.to_thread( email_service.send_verification_email, @@ -911,7 +911,7 @@ async def register_email_standalone( }, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) await asyncio.to_thread( email_service.send_verification_email, @@ -1049,7 +1049,7 @@ async def resend_verification( }, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) await asyncio.to_thread( 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)}, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) await asyncio.to_thread( email_service.send_password_reset_email, @@ -1524,7 +1524,7 @@ async def request_email_change( }, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) try: await asyncio.to_thread( @@ -1579,7 +1579,7 @@ async def request_email_change( }, db=db, ) - custom_subject, custom_body = override if override else (None, None) + custom_subject, custom_body = override or (None, None) await asyncio.to_thread( email_service.send_email_change_code, diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 0951efd3..995ce29f 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -198,7 +198,7 @@ async def get_payment_methods( 'description': description, } ) - options = formatted_options if formatted_options else None + options = formatted_options or None methods.append( PaymentMethodResponse( @@ -885,7 +885,7 @@ def _is_checkable(record: PendingPayment) -> bool: if record.method == PaymentMethod.YOOKASSA: return status in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status in {'active'} + return status == 'active' if record.method == PaymentMethod.CLOUDPAYMENTS: return status in {'pending', 'authorized'} if record.method == PaymentMethod.FREEKASSA: diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index d8e174c9..4590b84a 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -1,5 +1,6 @@ """Branding routes for cabinet - logo, project name, and theme colors management.""" +import asyncio import json import os from pathlib import Path @@ -401,7 +402,7 @@ async def get_logo(): """ 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') # Determine media type from file extension @@ -470,7 +471,7 @@ async def upload_logo( ) # Ensure directory exists - ensure_branding_dir() + await asyncio.to_thread(ensure_branding_dir) # Determine file extension from content type ext_map = { @@ -483,12 +484,12 @@ async def upload_logo( extension = ext_map.get(file.content_type, '.png') # Remove old logo files with any extension - for old_file in BRANDING_DIR.glob('logo.*'): - old_file.unlink() + for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))): + await asyncio.to_thread(old_file.unlink) # Save new logo 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 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.""" # Remove logo files - for old_file in BRANDING_DIR.glob('logo.*'): - old_file.unlink() + for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))): + await asyncio.to_thread(old_file.unlink) # Update setting await set_setting_value(db, BRANDING_LOGO_KEY, 'default') diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 6ed3cb1c..7192e471 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -260,13 +260,17 @@ async def create_gift_purchase( detail='payment_method is required for gateway mode', ) - purchase_kwargs: dict = { - 'gift_recipient_type': body.recipient_type, - 'gift_recipient_value': body.recipient_value, - 'gift_message': body.gift_message, - } if has_recipient else { - 'gift_message': body.gift_message, - } + purchase_kwargs: dict = ( + { + 'gift_recipient_type': body.recipient_type, + 'gift_recipient_value': body.recipient_value, + 'gift_message': body.gift_message, + } + if has_recipient + else { + 'gift_message': body.gift_message, + } + ) try: purchase = await create_purchase( @@ -345,13 +349,17 @@ async def create_gift_purchase( ) # Create purchase record - balance_purchase_kwargs: dict = { - 'gift_recipient_type': body.recipient_type, - 'gift_recipient_value': body.recipient_value, - 'gift_message': body.gift_message, - } if has_recipient else { - 'gift_message': body.gift_message, - } + balance_purchase_kwargs: dict = ( + { + 'gift_recipient_type': body.recipient_type, + 'gift_recipient_value': body.recipient_value, + 'gift_message': body.gift_message, + } + if has_recipient + else { + 'gift_message': body.gift_message, + } + ) try: purchase = await create_purchase( @@ -497,11 +505,7 @@ async def get_gift_purchase_status( else: token_filter = GuestPurchase.token.startswith(token) - result = await db.execute( - select(GuestPurchase) - .options(selectinload(GuestPurchase.tariff)) - .where(token_filter) - ) + result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter)) purchase = result.scalars().first() if purchase is None: raise HTTPException( diff --git a/app/cabinet/routes/info.py b/app/cabinet/routes/info.py index 68b89cac..78b1330c 100644 --- a/app/cabinet/routes/info.py +++ b/app/cabinet/routes/info.py @@ -160,7 +160,7 @@ async def get_rules( db: AsyncSession = Depends(get_cabinet_db), ): """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 content = await get_current_rules_content(db, requested_lang) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 839788a6..7684ab46 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1469,7 +1469,7 @@ async def activate_trial( duration_days=trial_duration, traffic_limit_gb=trial_traffic_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, ) diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 30b9a764..998921dd 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -1,13 +1,13 @@ """Schemas for Admin Users management in cabinet.""" from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field -class UserStatusEnum(str, Enum): +class UserStatusEnum(StrEnum): """User status enum.""" ACTIVE = 'active' @@ -15,7 +15,7 @@ class UserStatusEnum(str, Enum): DELETED = 'deleted' -class SubscriptionStatusEnum(str, Enum): +class SubscriptionStatusEnum(StrEnum): """Subscription status enum.""" TRIAL = 'trial' @@ -25,7 +25,7 @@ class SubscriptionStatusEnum(str, Enum): PENDING = 'pending' -class SortByEnum(str, Enum): +class SortByEnum(StrEnum): """Sort options for users list.""" CREATED_AT = 'created_at' diff --git a/app/cabinet/schemas/wheel.py b/app/cabinet/schemas/wheel.py index 80e7ac1b..8bc77840 100644 --- a/app/cabinet/schemas/wheel.py +++ b/app/cabinet/schemas/wheel.py @@ -1,7 +1,7 @@ """Схемы для колеса удачи (Fortune Wheel).""" from datetime import datetime -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, Field @@ -9,14 +9,14 @@ from pydantic import BaseModel, Field # ==================== ENUMS ==================== -class WheelPaymentType(str, Enum): +class WheelPaymentType(StrEnum): """Способы оплаты спина.""" TELEGRAM_STARS = 'telegram_stars' SUBSCRIPTION_DAYS = 'subscription_days' -class WheelPrizeType(str, Enum): +class WheelPrizeType(StrEnum): """Типы призов.""" SUBSCRIPTION_DAYS = 'subscription_days' diff --git a/app/config.py b/app/config.py index 634490b5..e74693f5 100644 --- a/app/config.py +++ b/app/config.py @@ -949,12 +949,12 @@ class Settings(BaseSettings): def get_test_email(self) -> str | None: """Get test email for development/testing.""" 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: """Get test email password.""" 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: """Check if email is the configured test email.""" @@ -1519,7 +1519,7 @@ class Settings(BaseSettings): except (ValueError, IndexError): 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: """Возвращает цену докупки для указанного количества ГБ.""" @@ -1609,7 +1609,7 @@ class Settings(BaseSettings): def get_yookassa_display_name(self) -> str: name = (self.YOOKASSA_DISPLAY_NAME or '').strip() - return name if name else 'YooKassa' + return name or 'YooKassa' def is_nalogo_enabled(self) -> bool: 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: name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip() - return name if name else 'CryptoBot' + return name or 'CryptoBot' 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 def get_heleket_display_name(self) -> str: 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: return ( @@ -1674,7 +1674,7 @@ class Settings(BaseSettings): def get_pal24_display_name(self) -> str: name = (self.PAL24_DISPLAY_NAME or '').strip() - return name if name else 'PAL24' + return name or 'PAL24' 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 @@ -1754,7 +1754,7 @@ class Settings(BaseSettings): def get_wata_display_name(self) -> str: name = (self.WATA_DISPLAY_NAME or '').strip() - return name if name else 'Wata' + return name or 'Wata' def is_cloudpayments_enabled(self) -> bool: return ( @@ -1765,7 +1765,7 @@ class Settings(BaseSettings): def get_cloudpayments_display_name(self) -> str: name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip() - return name if name else 'CloudPayments' + return name or 'CloudPayments' def is_freekassa_enabled(self) -> bool: return ( @@ -1778,7 +1778,7 @@ class Settings(BaseSettings): def get_freekassa_display_name(self) -> str: 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: return html.escape(self.get_freekassa_display_name()) @@ -1788,7 +1788,7 @@ class Settings(BaseSettings): def get_freekassa_sbp_display_name(self) -> str: 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: return html.escape(self.get_freekassa_sbp_display_name()) @@ -1798,7 +1798,7 @@ class Settings(BaseSettings): def get_freekassa_card_display_name(self) -> str: 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: return html.escape(self.get_freekassa_card_display_name()) @@ -1813,7 +1813,7 @@ class Settings(BaseSettings): def get_kassa_ai_display_name(self) -> str: 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: return html.escape(self.get_kassa_ai_display_name()) @@ -1823,7 +1823,7 @@ class Settings(BaseSettings): def get_riopay_display_name(self) -> str: 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: 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()), } link = links.get(platform_key) - return link if link else None + return link or None def is_maintenance_mode(self) -> bool: return self.MAINTENANCE_MODE @@ -2011,7 +2011,7 @@ class Settings(BaseSettings): # т.к. в режиме classic цена складывается из серверов/трафика/устройств) 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]: """ @@ -2036,7 +2036,7 @@ class Settings(BaseSettings): # Возвращаем только разрешённые периоды (без фильтрации по цене) 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]: """ @@ -2101,7 +2101,7 @@ class Settings(BaseSettings): def get_telegram_stars_display_name(self) -> str: 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: return stars * self.get_stars_rate() @@ -2138,7 +2138,7 @@ class Settings(BaseSettings): def get_backup_archive_password(self) -> str | None: password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip() - return password if password else None + return password or None # === Log Rotation Methods === @@ -2219,7 +2219,7 @@ class Settings(BaseSettings): except ValueError: continue - return packages if packages else self._get_fallback_traffic_packages() + return packages or self._get_fallback_traffic_packages() except Exception as e: logger.warning('ERROR PARSING CONFIG', error=e) @@ -2352,13 +2352,13 @@ class Settings(BaseSettings): if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')): url = self.get_support_contact_url() - return url if url else contact + return url or contact contact_without_prefix = contact.lstrip('@') if '.' in contact_without_prefix: 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): return f'@{contact_without_prefix}' diff --git a/app/database/crud/user_promo_group.py b/app/database/crud/user_promo_group.py index ed0b56d5..dc545b6c 100644 --- a/app/database/crud/user_promo_group.py +++ b/app/database/crud/user_promo_group.py @@ -187,7 +187,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG 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: logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error) diff --git a/app/database/models.py b/app/database/models.py index 40188931..09e2b713 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -8,7 +8,7 @@ def _aware(dt: datetime | None) -> datetime | None: return dt -from enum import Enum +from enum import Enum, StrEnum from sqlalchemy import ( JSON, @@ -3124,7 +3124,7 @@ class LandingPage(Base): return f"" -class GuestPurchaseStatus(str, Enum): +class GuestPurchaseStatus(StrEnum): PENDING = 'pending' PAID = 'paid' DELIVERED = 'delivered' diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py index f3724f43..ab0fdd5f 100644 --- a/app/handlers/admin/blacklist.py +++ b/app/handlers/admin/blacklist.py @@ -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()) status_text = '✅ Включена' if is_enabled else '❌ Отключена' - url_text = github_url if github_url else 'Не задан' + url_text = github_url or 'Не задан' text = f""" 🔐 Настройки черного списка diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 7940e1a6..3390643a 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery): await callback.answer('🔄 Анализирую логи платежей...', show_alert=False) # Путь к файлу логов платежей (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 current_dir = log_dir / 'current' payments_log = current_dir / settings.LOG_PAYMENTS_FILE - if not payments_log.exists(): + if not await asyncio.to_thread(payments_log.exists): try: await callback.message.edit_text( '❌ Файл логов не найден\n\n' @@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery): await callback.answer('🔄 Загружаю детали...', show_alert=False) # Путь к логам (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 current_dir = log_dir / 'current' 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) return diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index 338a4962..f9ad1400 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -188,7 +188,7 @@ def _is_checkable(record: PendingPayment) -> bool: if record.method == PaymentMethod.YOOKASSA: return status in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status in {'active'} + return status == 'active' if record.method == PaymentMethod.FREEKASSA: return status in {'pending', 'created', ''} 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}' created = format_datetime(record.created_at) 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 '—' lines = [ texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 Payment details'), diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index 965af25f..94c179cb 100644 --- a/app/handlers/admin/referrals.py +++ b/app/handlers/admin/referrals.py @@ -1,3 +1,4 @@ +import asyncio import json 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_exists = log_path.exists() - log_size = log_path.stat().st_size if log_exists else 0 + log_exists = await asyncio.to_thread(log_path.exists) + log_size = (await asyncio.to_thread(log_path.stat)).st_size if log_exists else 0 text += f'\n📂 {log_path.name}' if log_exists: @@ -1434,9 +1435,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi 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: - Path(temp_file_path).unlink() + await asyncio.to_thread(Path(temp_file_path).unlink) logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path) except Exception as e: logger.error('Ошибка удаления временного файла', error=e) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index ffd06d11..ac4c8a2e 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -81,7 +81,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int if current.strip(): 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): diff --git a/app/handlers/balance/freekassa.py b/app/handlers/balance/freekassa.py index f9d380d4..161cb568 100644 --- a/app/handlers/balance/freekassa.py +++ b/app/handlers/balance/freekassa.py @@ -21,8 +21,8 @@ logger = structlog.get_logger(__name__) FREEKASSA_SUB_METHODS = { - 'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()}, - 'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()}, + 'freekassa_sbp': {'payment_system_id': 44, 'get_name': settings.get_freekassa_sbp_display_name}, + 'freekassa_card': {'payment_system_id': 36, 'get_name': settings.get_freekassa_card_display_name}, } diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index 07026a4c..a2e551f8 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -169,7 +169,7 @@ def _split_into_pages( pages.append((current_online, current_offline)) - return pages if pages else [([], [])] + return pages or [([], [])] def _format_server_lines( diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 7d1ba4e8..0644ceb8 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -970,7 +970,7 @@ async def handle_simple_subscription_payment_method( from aiogram.types import BufferedInputFile # Используем 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 = qrcode.QRCode(version=1, box_size=10, border=5) diff --git a/app/handlers/start.py b/app/handlers/start.py index 430a4836..13dfec4d 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -565,7 +565,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if 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: try: diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index ae1a2d09..b5b18e7f 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -277,11 +277,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d # Используем max_device_limit из тарифа если есть, иначе глобальную настройку tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None - effective_max = ( - tariff_max_devices - if tariff_max_devices - else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) - ) + effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) if effective_max and new_devices_count > effective_max: await callback.answer( 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 if new_devices_count > current_devices: tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None - max_devices = ( - tariff_max_recheck - if tariff_max_recheck - else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) - ) + max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) if max_devices and new_devices_count > max_devices: if price > 0: user_refund = await db.execute( @@ -1139,11 +1131,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: # Используем max_device_limit из тарифа если есть, иначе глобальную настройку tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None - effective_max = ( - tariff_max_devices - if tariff_max_devices - else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) - ) + effective_max = tariff_max_devices or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) if effective_max and new_total_devices > effective_max: await callback.answer( 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_new = actual_current + devices_count tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None - max_devices = ( - tariff_max_recheck - if tariff_max_recheck - else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) - ) + max_devices = tariff_max_recheck or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None) if max_devices and actual_new > max_devices: # Concurrent purchase exceeded limit — refund user_refund = await db.execute( diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 48d11002..7f45be4b 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -313,7 +313,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d devices_used_str = str(devices_used) 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 = '' diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index c772f79d..ff3b922f 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -504,7 +504,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int if current.strip(): 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): diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 1547a07f..4de39205 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -398,7 +398,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): # Notify user about deactivation try: 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( 'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE', '🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n' @@ -468,7 +468,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): # Notify user about reactivation try: - texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) + texts = get_texts(user.language or DEFAULT_LANGUAGE) notification_text = texts.t( 'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE', '✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.', diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 8f332961..c8f379c5 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -347,7 +347,7 @@ class BackupService: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) 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.setdefault('tables_count', overview.get('tables_count', 0)) @@ -376,10 +376,10 @@ class BackupService: mode = 'w:gz' if compress else 'w' 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) - file_size = backup_path.stat().st_size + file_size = (await asyncio.to_thread(backup_path.stat)).st_size await self._cleanup_old_backups() @@ -415,7 +415,7 @@ class BackupService: logger.info('📄 Начинаем восстановление из', backup_file_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}' if self._is_archive_backup(backup_path): @@ -473,7 +473,11 @@ class BackupService: if pg_dump_path: dump_path = staging_dir / 'database.sql' 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 { 'type': 'postgresql', 'path': dump_path.name, @@ -488,7 +492,7 @@ class BackupService: dump_path = staging_dir / 'database.sqlite' 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 { 'type': 'sqlite', 'path': dump_path.name, @@ -516,9 +520,9 @@ class BackupService: ] 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( *command, stdout=dump_file, @@ -558,7 +562,7 @@ class BackupService: 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)) - 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) @@ -575,10 +579,10 @@ class BackupService: async def _dump_sqlite(self, dump_path: 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}') - 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) 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]]: files_info: list[dict[str, Any]] = [] 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: log_path = Path(settings.LOG_FILE) - if log_path.exists(): + if await asyncio.to_thread(log_path.exists): dest = files_dir / log_path.name await asyncio.to_thread(shutil.copy2, log_path, dest) files_info.append( @@ -676,8 +680,8 @@ class BackupService: } ) - if not files_info and files_dir.exists(): - files_dir.rmdir() + if not files_info and await asyncio.to_thread(files_dir.exists): + await asyncio.to_thread(files_dir.rmdir) return files_info @@ -688,7 +692,7 @@ class BackupService: 'items': 0, } - if not self.data_dir.exists(): + if not await asyncio.to_thread(self.data_dir.exists): return snapshot_info counter = {'items': 0} @@ -732,7 +736,7 @@ class BackupService: tar.extractall(temp_path, filter='data') metadata_path = temp_path / 'metadata.json' - if not metadata_path.exists(): + if not await asyncio.to_thread(metadata_path.exists): return False, '❌ Метаданные бекапа отсутствуют' 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) 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) if files_info: @@ -775,7 +779,7 @@ class BackupService: return True, message 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}') psql_path = self._resolve_command_path('psql', 'PSQL_PATH') @@ -833,7 +837,7 @@ class BackupService: logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path) 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}') 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) 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}') 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(): - target_path.unlink() + if clear_existing and await asyncio.to_thread(target_path.exists): + await asyncio.to_thread(target_path.unlink) await asyncio.to_thread(shutil.copy2, dump_path, target_path) logger.info('✅ SQLite база восстановлена', target_path=target_path) 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 def _restore(): @@ -891,7 +895,7 @@ class BackupService: logger.info('📁 Снимок директории data восстановлен') 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: relative_path = file_info.get('relative_path') @@ -899,21 +903,22 @@ class BackupService: if not relative_path or not target_path: 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: logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path) continue - source_file = (temp_path / relative_path).resolve() - if not str(source_file).startswith(str(temp_path.resolve()) + os.sep): + source_file = await asyncio.to_thread((temp_path / relative_path).resolve) + 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) continue - if not source_file.exists(): + if not await asyncio.to_thread(source_file.exists): logger.warning('Файл отсутствует в архиве', relative_path=relative_path) 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) logger.info('📁 Файл восстановлен', target_resolved=target_resolved) @@ -1573,8 +1578,10 @@ class BackupService: backups = [] try: - for backup_file in sorted(self.backup_dir.glob('backup_*'), reverse=True): - if not backup_file.is_file(): + for backup_file in sorted( + await asyncio.to_thread(lambda: list(self.backup_dir.glob('backup_*'))), reverse=True + ): + if not await asyncio.to_thread(backup_file.is_file): continue try: @@ -1598,7 +1605,7 @@ class BackupService: backup_structure = json_lib.load(f) metadata = backup_structure.get('metadata', {}) - file_stats = backup_file.stat() + file_stats = await asyncio.to_thread(backup_file.stat) backup_info = { 'filename': backup_file.name, @@ -1626,7 +1633,7 @@ class BackupService: except Exception as 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( { 'filename': backup_file.name, @@ -1651,14 +1658,15 @@ class BackupService: async def delete_backup(self, backup_filename: str) -> tuple[bool, str]: try: - backup_path = (self.backup_dir / backup_filename).resolve() - if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep): + backup_path = await asyncio.to_thread((self.backup_dir / backup_filename).resolve) + 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, '❌ Недопустимое имя файла бекапа' - if not backup_path.is_file(): + if not await asyncio.to_thread(backup_path.is_file): return False, f'❌ Файл бекапа не найден: {backup_filename}' - backup_path.unlink() + await asyncio.to_thread(backup_path.unlink) message = f'✅ Бекап {backup_filename} удален' logger.info(message) @@ -1826,9 +1834,9 @@ class BackupService: await self.bot.send_document(**send_kwargs) 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: - Path(temp_zip_path).unlink() + await asyncio.to_thread(Path(temp_zip_path).unlink) except Exception as 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: try: 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) return None diff --git a/app/services/contests/enums.py b/app/services/contests/enums.py index 4054e630..57471de9 100644 --- a/app/services/contests/enums.py +++ b/app/services/contests/enums.py @@ -1,9 +1,9 @@ """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.""" QUEST_BUTTONS = 'quest_buttons' @@ -30,14 +30,14 @@ class GameType(str, Enum): } -class RoundStatus(str, Enum): +class RoundStatus(StrEnum): """Contest round status.""" ACTIVE = 'active' FINISHED = 'finished' -class PrizeType(str, Enum): +class PrizeType(StrEnum): """Types of prizes for contests.""" DAYS = 'days' diff --git a/app/services/log_rotation_service.py b/app/services/log_rotation_service.py index c956be42..9d8d7746 100644 --- a/app/services/log_rotation_service.py +++ b/app/services/log_rotation_service.py @@ -78,8 +78,8 @@ class LogRotationService: async def initialize(self) -> None: """Создать необходимые директории.""" - self.current_dir.mkdir(parents=True, exist_ok=True) - self.archive_dir.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(self.current_dir.mkdir, True, True) + await asyncio.to_thread(self.archive_dir.mkdir, True, True) async def start(self) -> None: """Запустить сервис ротации.""" @@ -170,7 +170,7 @@ class LogRotationService: # Собираем файлы для архивации files_to_archive: list[tuple[Path, str]] = [] 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')) if not files_to_archive: @@ -184,7 +184,7 @@ class LogRotationService: if archive_path: # Очищаем текущие лог-файлы for log_path, _ in files_to_archive: - log_path.write_text('') + await asyncio.to_thread(log_path.write_text, '') # Очистка старых архивов await self._cleanup_old_archives() @@ -247,12 +247,12 @@ class LogRotationService: keep_days = settings.LOG_ROTATION_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 # Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar - for archive_file in self.archive_dir.iterdir(): - if not archive_file.is_file(): + for archive_file in await asyncio.to_thread(lambda: list(self.archive_dir.iterdir())): + if not await asyncio.to_thread(archive_file.is_file): continue # Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz @@ -267,7 +267,7 @@ class LogRotationService: file_date = file_date.replace(tzinfo=get_local_timezone()) if file_date < cutoff_date: - archive_file.unlink() + await asyncio.to_thread(archive_file.unlink) logger.info('Удален старый архив логов', archive_file_name=archive_file.name) except ValueError: # Пропускаем файлы с некорректным форматом имени @@ -287,7 +287,7 @@ class LogRotationService: topic_id = settings.get_log_rotation_topic_id() try: - file_size_kb = archive_path.stat().st_size / 1024 + file_size_kb = (await asyncio.to_thread(archive_path.stat)).st_size / 1024 caption = ( f'Логи бота\n' f'Дата: {date_str}\n' diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index f2bb911c..73b5351a 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -111,7 +111,11 @@ class MonitoringService: logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status) 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: from app.utils.message_patch import _cache_logo_file_id, get_logo_media diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 4f107484..3ee32afd 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -767,7 +767,7 @@ class YooKassaPaymentMixin: ) # Используем 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) topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 4282b408..b4d79cb4 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -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}', } ) - return options if options else None + return options or None except Exception: return None diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index e4f89862..08a2ee6e 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -527,7 +527,7 @@ class MiniAppSubscriptionPurchaseService: value = int(package.get('gb') or 0) price_per_month = int(package.get('price') or 0) 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( PurchaseTrafficOption( value=value, @@ -587,7 +587,7 @@ class MiniAppSubscriptionPurchaseService: options=options, min_selectable=1 if options else 0, 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, ) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 3cdcc74e..d5b0a7e5 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -1536,7 +1536,7 @@ class SubscriptionService: 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) # Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather diff --git a/app/webapi/routes/pages.py b/app/webapi/routes/pages.py index b30581d0..47985019 100644 --- a/app/webapi/routes/pages.py +++ b/app/webapi/routes/pages.py @@ -429,7 +429,7 @@ async def get_service_rules( language: str = Query('ru', min_length=2, max_length=10), fallback: bool = Query(True), ) -> ServiceRulesResponse: - requested_lang = language.split('-')[0].lower() + requested_lang = language.split('-', maxsplit=1)[0].lower() rules = await get_rules_by_language(db, requested_lang) if not rules and fallback: @@ -467,7 +467,7 @@ async def clear_service_rules( db: AsyncSession = Depends(get_db_session), language: str = Query('ru', min_length=2, max_length=10), ) -> Response: - lang = language.split('-')[0].lower() + lang = language.split('-', maxsplit=1)[0].lower() await clear_all_rules(db, lang) 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), limit: int = Query(10, ge=1, le=100), ) -> ServiceRulesHistoryResponse: - lang = language.split('-')[0].lower() + lang = language.split('-', maxsplit=1)[0].lower() history = await get_all_rules_versions(db, lang, limit=limit) items = [_serialize_rules(item) for item in history] return ServiceRulesHistoryResponse( @@ -500,7 +500,7 @@ async def restore_service_rules_version( db: AsyncSession = Depends(get_db_session), language: str = Query('ru', min_length=2, max_length=10), ) -> ServiceRulesResponse: - lang = language.split('-')[0].lower() + lang = language.split('-', maxsplit=1)[0].lower() restored = await restore_rules_version(db, rule_id, language=lang) if not restored: raise HTTPException(status.HTTP_404_NOT_FOUND, 'Rules version not found') diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index 4a42844a..01f756fd 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -3,13 +3,13 @@ from __future__ import annotations from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import Any from pydantic import BaseModel, ConfigDict, Field -class ButtonType(str, Enum): +class ButtonType(StrEnum): """Тип кнопки меню.""" BUILTIN = 'builtin' # Встроенная кнопка с callback_data @@ -18,7 +18,7 @@ class ButtonType(str, Enum): CALLBACK = 'callback' # Кастомная кнопка с любым callback_data -class ButtonVisibility(str, Enum): +class ButtonVisibility(StrEnum): """Видимость кнопки.""" ALL = 'all' # Видна всем @@ -27,7 +27,7 @@ class ButtonVisibility(str, Enum): SUBSCRIBERS = 'subscribers' # Только подписчикам -class ButtonOpenMode(str, Enum): +class ButtonOpenMode(StrEnum): """Режим открытия кнопки.""" CALLBACK = 'callback' # Отправляет callback_data боту (по умолчанию) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 5ff43448..2762ad02 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import Any from urllib.parse import urlparse @@ -386,7 +386,7 @@ class MiniAppPaymentMethodsRequest(BaseModel): init_data: str = Field(..., alias='initData') -class MiniAppPaymentIntegrationType(str, Enum): +class MiniAppPaymentIntegrationType(StrEnum): IFRAME = 'iframe' REDIRECT = 'redirect' diff --git a/main.py b/main.py index 91a48078..f132d268 100644 --- a/main.py +++ b/main.py @@ -723,7 +723,7 @@ async def main(): timeline.log_section( 'Активные webhook endpoints', - webhook_lines if webhook_lines else ['Нет активных endpoints'], + webhook_lines or ['Нет активных endpoints'], icon='🎯', ) diff --git a/migrations/alembic/versions/0032_add_riopay_payments.py b/migrations/alembic/versions/0036_add_riopay_payments.py similarity index 93% rename from migrations/alembic/versions/0032_add_riopay_payments.py rename to migrations/alembic/versions/0036_add_riopay_payments.py index 0cf86508..c46ef3ce 100644 --- a/migrations/alembic/versions/0032_add_riopay_payments.py +++ b/migrations/alembic/versions/0036_add_riopay_payments.py @@ -1,7 +1,7 @@ """add riopay_payments table -Revision ID: 0032 -Revises: 0031 +Revision ID: 0036 +Revises: 0035 Create Date: 2026-03-08 """ @@ -11,8 +11,8 @@ from typing import Sequence, Union import sqlalchemy as sa from alembic import op -revision: str = '0032' -down_revision: Union[str, None] = '0031' +revision: str = '0036' +down_revision: Union[str, None] = '0035' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -42,4 +42,4 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_table('riopay_payments') + op.drop_table('riopay_payments') \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0c1791a7..89859e25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,6 +166,7 @@ ignore = [ 'TC001', 'TC002', 'TC003', + 'A003', 'N805', 'N815', 'RET504', diff --git a/tests/middlewares/test_channel_checker_payload.py b/tests/middlewares/test_channel_checker_payload.py index d78569d8..3308f66f 100644 --- a/tests/middlewares/test_channel_checker_payload.py +++ b/tests/middlewares/test_channel_checker_payload.py @@ -246,7 +246,7 @@ class TestPayloadIntegration: mock_state = AsyncMock() 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) diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index e44d0f0f..ced3d3e8 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -341,7 +341,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch monkeypatch.setitem( sys.modules, 'app.services.admin_notification_service', - SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)), + SimpleNamespace(AdminNotificationService=DummyAdminService2), ) 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 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(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) 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( sys.modules, 'app.services.admin_notification_service', - SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + SimpleNamespace(AdminNotificationService=DummyAdminService), ) 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( sys.modules, 'app.services.admin_notification_service', - SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + SimpleNamespace(AdminNotificationService=DummyAdminService), ) 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( sys.modules, 'app.services.admin_notification_service', - SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + SimpleNamespace(AdminNotificationService=DummyAdminService), ) service.build_topup_success_keyboard = AsyncMock(return_value=None) @@ -895,7 +895,7 @@ async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch.setitem( sys.modules, 'app.services.admin_notification_service', - SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + SimpleNamespace(AdminNotificationService=DummyAdminService), ) 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( sys.modules, '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))) @@ -1215,7 +1215,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey monkeypatch.setitem( sys.modules, '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))) diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index eec1e01b..3436ae2b 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -141,7 +141,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): monkeypatch.setattr( 'app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService', - lambda: DummyMiniAppService(), + DummyMiniAppService, ) monkeypatch.setattr( 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py index fea2f57c..470a41d9 100644 --- a/tests/services/test_system_settings_env_priority.py +++ b/tests/services/test_system_settings_env_priority.py @@ -107,7 +107,7 @@ async def test_initialize_skips_db_value_for_env_override(monkeypatch): monkeypatch.setattr( 'app.services.system_settings_service.AsyncSessionLocal', - lambda: DummySession(), + DummySession, ) async def fake_sync(): diff --git a/tests/services/test_yookassa_service_adapter.py b/tests/services/test_yookassa_service_adapter.py index e683ece5..221ca11d 100644 --- a/tests/services/test_yookassa_service_adapter.py +++ b/tests/services/test_yookassa_service_adapter.py @@ -52,7 +52,7 @@ def test_init_without_credentials(monkeypatch: pytest.MonkeyPatch) -> None: async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: _prepare_config(monkeypatch) 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]] = {} @@ -106,7 +106,7 @@ async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch) _prepare_config(monkeypatch) monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 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( YooKassaPayment, 'create', @@ -142,7 +142,7 @@ async def test_create_payment_returns_none_when_not_configured(monkeypatch: pyte @pytest.mark.anyio('asyncio') async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: _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(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 'fallback@example.com', raising=False) diff --git a/tests/test_miniapp_payments.py b/tests/test_miniapp_payments.py index 49ff7607..e914cc15 100644 --- a/tests/test_miniapp_payments.py +++ b/tests/test_miniapp_payments.py @@ -1032,7 +1032,7 @@ async def test_create_payment_link_stars_normalizes_amount(monkeypatch): async def fake_resolve_user(db, init_data): 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, '_resolve_user_from_init_data', fake_resolve_user) diff --git a/tests/utils/test_remnawave_auto_sync.py b/tests/utils/test_remnawave_auto_sync.py index 0448eda8..8e514abf 100644 --- a/tests/utils/test_remnawave_auto_sync.py +++ b/tests/utils/test_remnawave_auto_sync.py @@ -103,7 +103,7 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch): monkeypatch.setattr( 'app.services.remnawave_sync_service.AsyncSessionLocal', - lambda: DummySession(), + DummySession, ) monkeypatch.setattr( 'app.services.remnawave_sync_service.sync_with_remnawave', diff --git a/uv.lock b/uv.lock index 78430440..daacdebc 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,7 +1115,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.25.0" +version = "3.28.1" source = { virtual = "." } dependencies = [ { name = "aiogram" },