diff --git a/app/config.py b/app/config.py
index 6da1388c..025e4719 100644
--- a/app/config.py
+++ b/app/config.py
@@ -7,6 +7,7 @@ import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
+from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
@@ -60,6 +61,8 @@ class Settings(BaseSettings):
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
+
+ TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
DATABASE_MODE: str = "auto"
@@ -1424,11 +1427,23 @@ class Settings(BaseSettings):
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
- "extra": "ignore"
+ "extra": "ignore"
}
+ @field_validator("TIMEZONE")
+ @classmethod
+ def validate_timezone(cls, value: str) -> str:
+ try:
+ ZoneInfo(value)
+ except Exception as exc: # pragma: no cover - defensive validation
+ raise ValueError(
+ f"Некорректный идентификатор часового пояса: {value}"
+ ) from exc
+ return value
+
settings = Settings()
+ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index 7421ff72..6f5691fe 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -15,6 +15,7 @@ from app.database.models import (
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
+from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -1131,7 +1132,13 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
- logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
+ logger.info(
+ "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
+ subscription.id,
+ subscription.status,
+ format_local_datetime(subscription.end_date),
+ format_local_datetime(current_time),
+ )
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py
index 8898c03c..3d4bebfc 100644
--- a/app/handlers/admin/bot_configuration.py
+++ b/app/handlers/admin/bot_configuration.py
@@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "🤖 Основные",
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
"icon": "🤖",
- "categories": ("CORE", "CHANNEL"),
+ "categories": (
+ "CORE",
+ "CHANNEL",
+ "TIMEZONE",
+ "DATABASE",
+ "POSTGRES",
+ "SQLITE",
+ "REDIS",
+ "REMNAWAVE",
+ ),
},
"support": {
"title": "💬 Поддержка",
@@ -56,6 +65,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"icon": "💳",
"categories": (
"PAYMENT",
+ "PAYMENT_VERIFICATION",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
@@ -114,18 +124,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"ADDITIONAL",
),
},
- "database": {
- "title": "💾 База данных",
- "description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
- "icon": "💾",
- "categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
- },
- "remnawave": {
- "title": "🌐 RemnaWave API",
- "description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
- "icon": "🌐",
- "categories": ("REMNAWAVE",),
- },
"server": {
"title": "📊 Статус серверов",
"description": "Мониторинг серверов, SLA и внешние метрики.",
@@ -142,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "⚡",
- "categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
- },
- "external_admin": {
- "title": "🛡️ Внешняя админка",
- "description": "Токен, по которому внешняя админка проверяет запросы.",
- "icon": "🛡️",
- "categories": ("EXTERNAL_ADMIN",),
+ "categories": (
+ "WEB_API",
+ "WEBHOOK",
+ "LOG",
+ "MODERATION",
+ "DEBUG",
+ "EXTERNAL_ADMIN",
+ ),
},
}
@@ -161,12 +160,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
"referral",
"notifications",
"interface",
- "database",
- "remnawave",
"server",
"maintenance",
"advanced",
- "external_admin",
)
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index 6b791efe..cf2ceab1 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -38,6 +38,7 @@ from app.utils.promo_offer import (
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
+from app.utils.timezone import format_local_datetime
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str:
current_time = datetime.utcnow()
actual_status = (subscription.actual_status or "").lower()
- end_date_text = subscription.end_date.strftime("%d.%m.%Y")
+ end_date = getattr(subscription, "end_date", None)
+ end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
days_left = 0
if subscription.end_date > current_time:
@@ -968,10 +970,10 @@ def _get_subscription_status(user: User, texts) -> str:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
- ).format(end_date=end_date_text)
+ ).format(end_date=end_date_text or "—")
if actual_status == "trial":
- if days_left > 1:
+ if days_left > 1 and end_date_text:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
@@ -990,7 +992,7 @@ def _get_subscription_status(user: User, texts) -> str:
)
if actual_status == "active":
- if days_left > 7:
+ if days_left > 7 and end_date_text:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
diff --git a/app/handlers/start.py b/app/handlers/start.py
index 087f560f..abeeb10a 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -37,6 +37,7 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
+from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
@@ -1268,6 +1269,7 @@ def _get_subscription_status(user, texts):
from datetime import datetime
end_date = getattr(subscription, "end_date", None)
+ end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
current_time = datetime.utcnow()
if actual_status == "disabled":
@@ -1277,11 +1279,11 @@ def _get_subscription_status(user, texts):
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
if actual_status == "expired" or (end_date and end_date <= current_time):
- if end_date:
+ if end_date_display:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
- ).format(end_date=end_date.strftime('%d.%m.%Y'))
+ ).format(end_date=end_date_display)
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
if not end_date:
@@ -1294,11 +1296,11 @@ def _get_subscription_status(user, texts):
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
if is_trial:
- if days_left > 1:
+ if days_left > 1 and end_date_display:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
- ).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
+ ).format(end_date=end_date_display, days=days_left)
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
@@ -1309,11 +1311,11 @@ def _get_subscription_status(user, texts):
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
- if days_left > 7:
+ if days_left > 7 and end_date_display:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
- ).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
+ ).format(end_date=end_date_display, days=days_left)
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py
index 61d98c1d..bd04165e 100644
--- a/app/handlers/subscription/pricing.py
+++ b/app/handlers/subscription/pricing.py
@@ -77,6 +77,7 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
+from app.utils.timezone import format_local_datetime
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
@@ -491,7 +492,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
info_text = info_template.format(
status=status_text,
type=type_text,
- end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index f85536ab..06df0798 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -77,6 +77,7 @@ from app.utils.subscription_utils import (
get_happ_cryptolink_redirect_link,
resolve_simple_subscription_device_limit,
)
+from app.utils.timezone import format_local_datetime
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
@@ -301,7 +302,7 @@ async def show_subscription_info(
status_display=status_display,
warning=warning_text,
subscription_type=subscription_type,
- end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
time_left=time_left_text,
traffic=traffic_used_display,
servers=servers_display,
@@ -1333,7 +1334,7 @@ async def confirm_extend_subscription(
success_message = (
"✅ Подписка успешно продлена!\n\n"
f"⏰ Добавлено: {days} дней\n"
- f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
+ f"Действует до: {format_local_datetime(refreshed_end_date, '%d.%m.%Y %H:%M')}\n\n"
f"💰 Списано: {texts.format_price(price)}"
)
@@ -2983,7 +2984,7 @@ async def _extend_existing_subscription(
success_message = (
"✅ Подписка успешно продлена!\n\n"
f"⏰ Добавлено: {period_days} дней\n"
- f"Действует до: {new_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
+ f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n"
f"💰 Списано: {texts.format_price(price_kopeks)}"
)
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index de7407b9..3f757d6e 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -19,6 +19,7 @@ from app.database.models import (
TransactionType,
User,
)
+from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -224,10 +225,10 @@ class AdminNotificationService:
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
-📆 Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
+📆 Действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
🔗 Реферер: {referrer_info}
-⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"""
return await self._send_message(message)
@@ -288,11 +289,11 @@ class AdminNotificationService:
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
-📆 Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
+📆 Действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
💰 Баланс после покупки: {settings.format_price(user.balance_kopeks)}
🔗 Реферер: {referrer_info}
-⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"""
return await self._send_message(message)
@@ -339,7 +340,7 @@ class AdminNotificationService:
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
- ⚙️ Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+ ⚙️ Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"""
return await self._send_message(message)
@@ -364,7 +365,7 @@ class AdminNotificationService:
🔄 Следующая попытка через час.
⚙️ Проверьте доступность GitHub API и настройки сети.
- ⚙️ Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+ ⚙️ Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"""
return await self._send_message(message)
@@ -387,7 +388,7 @@ class AdminNotificationService:
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
- timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
+ timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
user_display = self._get_user_display(user)
return f"""💰 ПОПОЛНЕНИЕ БАЛАНСА
@@ -585,8 +586,8 @@ class AdminNotificationService:
📅 Продление:
➕ Добавлено дней: {extended_days}
-📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
-📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
+📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
+📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
📱 Текущие параметры:
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
@@ -595,7 +596,7 @@ class AdminNotificationService:
💰 Баланс после операции: {settings.format_price(current_balance)}
-⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"""
+⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"""
return await self._send_message(message)
@@ -648,7 +649,7 @@ class AdminNotificationService:
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
- f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
+ f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
@@ -659,7 +660,7 @@ class AdminNotificationService:
"📝 Эффект:",
effect_description.strip() or "✅ Промокод активирован",
"",
- f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}",
]
)
@@ -713,7 +714,7 @@ class AdminNotificationService:
message_lines.extend(
[
"",
- f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}",
]
)
@@ -778,7 +779,7 @@ class AdminNotificationService:
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
- f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}",
]
)
@@ -856,9 +857,9 @@ class AdminNotificationService:
return "❌ Нет подписки"
if subscription.is_trial:
- return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
+ return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
elif subscription.is_active:
- return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
+ return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
else:
return "❌ Неактивна"
@@ -929,7 +930,9 @@ class AdminNotificationService:
if isinstance(enabled_at, str):
from datetime import datetime
enabled_at = datetime.fromisoformat(enabled_at)
- message_parts.append(f"🕐 Время включения: {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
+ message_parts.append(
+ f"🕐 Время включения: {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
+ )
message_parts.append(f"🤖 Автоматически: {'Да' if details.get('auto_enabled', False) else 'Нет'}")
message_parts.append("")
@@ -941,7 +944,9 @@ class AdminNotificationService:
if isinstance(disabled_at, str):
from datetime import datetime
disabled_at = datetime.fromisoformat(disabled_at)
- message_parts.append(f"🕐 Время отключения: {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
+ message_parts.append(
+ f"🕐 Время отключения: {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
+ )
if details.get("duration"):
duration = details["duration"]
@@ -1000,9 +1005,10 @@ class AdminNotificationService:
else:
message_parts.append("Автоматический мониторинг API остановлен.")
- from datetime import datetime
message_parts.append("")
- message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
+ message_parts.append(
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"
+ )
message = "\n".join(message_parts)
@@ -1048,7 +1054,9 @@ class AdminNotificationService:
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
- message_parts.append(f"🕐 Последняя проверка: {last_check.strftime('%H:%M:%S')}")
+ message_parts.append(
+ f"🕐 Последняя проверка: {format_local_datetime(last_check, '%H:%M:%S')}"
+ )
if status == "online":
if details.get("uptime"):
@@ -1094,9 +1102,10 @@ class AdminNotificationService:
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
- from datetime import datetime
message_parts.append("")
- message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
+ message_parts.append(
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}"
+ )
message = "\n".join(message_parts)
@@ -1171,11 +1180,11 @@ class AdminNotificationService:
message_lines.extend(
[
"",
- f"📅 Подписка действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
+ f"📅 Подписка действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
f"💰 Баланс после операции: {settings.format_price(user.balance_kopeks)}",
f"🔗 Рефер: {referrer_info}",
"",
- f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
+ f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}",
]
)
diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py
index df183d0e..7180a2d1 100644
--- a/app/services/maintenance_service.py
+++ b/app/services/maintenance_service.py
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from app.config import settings
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.cache import cache
+from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -45,6 +46,9 @@ class MaintenanceService:
def get_maintenance_message(self) -> str:
if self._status.auto_enabled:
+ last_check_display = format_local_datetime(
+ self._status.last_check, "%H:%M:%S", "неизвестно"
+ )
return f"""
🔧 Технические работы!
@@ -52,7 +56,7 @@ class MaintenanceService:
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
-🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
+🔄 Последняя проверка: {last_check_display}
"""
else:
return settings.get_maintenance_message()
@@ -79,7 +83,12 @@ class MaintenanceService:
}
emoji = emoji_map.get(alert_type, "ℹ️")
- formatted_message = f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"
+ timestamp = format_local_datetime(
+ datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
+ )
+ formatted_message = (
+ f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}"
+ )
return await notification_service._send_message(formatted_message)
@@ -152,11 +161,14 @@ class MaintenanceService:
await self._save_status_to_cache()
+ enabled_time = format_local_datetime(
+ self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
+ )
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 Причина: {self._status.reason}
🤖 Автоматически: {'Да' if auto else 'Нет'}
-🕐 Время: {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
+🕐 Время: {enabled_time}
Обычные пользователи временно не смогут использовать бота."""
@@ -197,10 +209,13 @@ class MaintenanceService:
else:
duration_str = f"\n⏱️ Длительность: {minutes}мин"
+ notification_time = format_local_datetime(
+ datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
+ )
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 Автоматически: {'Да' if was_auto else 'Нет'}
-🕐 Время: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
+🕐 Время: {notification_time}
{duration_str}
Сервис снова доступен для пользователей."""
@@ -229,14 +244,17 @@ class MaintenanceService:
settings.get_maintenance_retry_attempts(),
)
- await self._notify_admins(f"""Мониторинг технических работ запущен
+ await self._notify_admins(
+ f"""Мониторинг технических работ запущен
🔄 Интервал проверки: {settings.get_maintenance_check_interval()} секунд
🤖 Автовключение: {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 Порог ошибок: {self._max_consecutive_failures}
🔁 Повторных попыток: {settings.get_maintenance_retry_attempts()}
-Система будет следить за доступностью API.""", "info")
+Система будет следить за доступностью API.""",
+ "info",
+ )
return True
@@ -291,13 +309,19 @@ class MaintenanceService:
)
if not self._status.api_status:
- await self._notify_admins(f"""API Remnawave восстановлено!
+ recovery_time = format_local_datetime(
+ self._status.last_check, "%H:%M:%S %Z"
+ )
+ await self._notify_admins(
+ f"""API Remnawave восстановлено!
✅ Статус: Доступно
-🕐 Время восстановления: {self._status.last_check.strftime('%H:%M:%S')}
+🕐 Время восстановления: {recovery_time}
🔄 Неудачных попыток было: {self._status.consecutive_failures}
-API снова отвечает на запросы.""", "success")
+API снова отвечает на запросы.""",
+ "success",
+ )
self._status.api_status = True
self._status.consecutive_failures = 0
@@ -321,13 +345,19 @@ API снова отвечает на запросы.""", "success")
self._status.consecutive_failures += 1
if was_available:
- await self._notify_admins(f"""API Remnawave недоступно!
+ detection_time = format_local_datetime(
+ self._status.last_check, "%H:%M:%S %Z"
+ )
+ await self._notify_admins(
+ f"""API Remnawave недоступно!
❌ Статус: Недоступно
-🕐 Время обнаружения: {self._status.last_check.strftime('%H:%M:%S')}
+🕐 Время обнаружения: {detection_time}
🔄 Попытка: {self._status.consecutive_failures}
-Началась серия неудачных проверок API.""", "error")
+Началась серия неудачных проверок API.""",
+ "error",
+ )
if (
self._status.consecutive_failures >= self._max_consecutive_failures
@@ -349,12 +379,16 @@ API снова отвечает на запросы.""", "success")
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
- await self._notify_admins(f"""Ошибка при проверке API Remnawave
+ error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
+ await self._notify_admins(
+ f"""Ошибка при проверке API Remnawave
❌ Ошибка: {str(e)}
-🕐 Время: {datetime.utcnow().strftime('%H:%M:%S')}
+🕐 Время: {error_time}
-Не удалось выполнить проверку доступности API.""", "error")
+Не удалось выполнить проверку доступности API.""",
+ "error",
+ )
self._status.api_status = False
self._status.consecutive_failures += 1
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 10e4cfb5..f1787eb0 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -38,6 +38,7 @@ from app.database.crud.user import (
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
+from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
@@ -1035,7 +1036,7 @@ class MonitoringService:
message = f"""
⚠️ Подписка истекает через {days_text}!
-Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
+Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
💳 Автоплатеж: {autopay_status}
@@ -1151,7 +1152,7 @@ class MonitoringService:
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
- end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -1267,7 +1268,7 @@ class MonitoringService:
),
)
message = template.format(
- end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
price=settings.format_price(settings.PRICE_30_DAYS),
)
@@ -1344,7 +1345,7 @@ class MonitoringService:
message = template.format(
percent=percent,
- expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
+ expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py
index 72607382..e1bf5802 100644
--- a/app/services/remnawave_service.py
+++ b/app/services/remnawave_service.py
@@ -1,6 +1,5 @@
import asyncio
import logging
-import os
import re
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
@@ -42,6 +41,7 @@ from app.database.models import (
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
+from app.utils.timezone import get_local_timezone
logger = logging.getLogger(__name__)
@@ -59,15 +59,7 @@ class RemnaWaveService:
self._config_error: Optional[str] = None
- tz_name = os.getenv("TZ", "UTC")
- try:
- self._panel_timezone = ZoneInfo(tz_name)
- except Exception:
- logger.warning(
- "⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
- tz_name,
- )
- self._panel_timezone = ZoneInfo("UTC")
+ self._panel_timezone = get_local_timezone()
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 71b53fd5..d546f15e 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -29,6 +29,7 @@ from app.services.subscription_purchase_service import (
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pricing_utils import format_period_description
+from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -333,7 +334,7 @@ async def _auto_extend_subscription(
getattr(user, "language", "ru"),
)
new_end_date = updated_subscription.end_date
- end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
+ end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")
if bot:
try:
diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py
index 34a74bae..8e8f5e42 100644
--- a/app/services/system_settings_service.py
+++ b/app/services/system_settings_service.py
@@ -9,7 +9,13 @@ from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
+from app.config import (
+ Settings,
+ settings,
+ refresh_period_prices,
+ refresh_traffic_prices,
+ ENV_OVERRIDE_KEYS,
+)
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
@@ -71,6 +77,7 @@ class BotConfigurationService:
"SUPPORT": "💬 Поддержка и тикеты",
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
+ "TIMEZONE": "🗂 Timezone",
"PAYMENT": "💳 Общие платежные настройки",
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
"TELEGRAM": "⭐ Telegram Stars",
@@ -124,6 +131,7 @@ class BotConfigurationService:
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
+ "TIMEZONE": "Часовой пояс панели и отображение времени.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
@@ -631,6 +639,10 @@ class BotConfigurationService:
def is_read_only(cls, key: str) -> bool:
return key in cls.READ_ONLY_KEYS
+ @classmethod
+ def _is_env_override(cls, key: str) -> bool:
+ return key in cls._env_override_keys
+
@classmethod
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
if isinstance(value, bool):
@@ -744,6 +756,7 @@ class BotConfigurationService:
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
+ _env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS)
_callback_tokens: Dict[str, str] = {}
_token_to_key: Dict[str, str] = {}
_choice_tokens: Dict[str, Dict[Any, str]] = {}
@@ -867,6 +880,8 @@ class BotConfigurationService:
@classmethod
def has_override(cls, key: str) -> bool:
+ if cls._is_env_override(key):
+ return False
return key in cls._overrides_raw
@classmethod
@@ -1162,6 +1177,12 @@ class BotConfigurationService:
overrides[row.key] = row.value
for key, raw_value in overrides.items():
+ if cls._is_env_override(key):
+ logger.debug(
+ "Пропускаем настройку %s из БД: используется значение из окружения",
+ key,
+ )
+ continue
try:
parsed_value = cls.deserialize_value(key, raw_value)
except Exception as error:
@@ -1281,8 +1302,15 @@ class BotConfigurationService:
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
- cls._overrides_raw[key] = raw_value
- cls._apply_to_settings(key, value)
+ if cls._is_env_override(key):
+ logger.info(
+ "Настройка %s сохранена в БД, но не применена: значение задаётся через окружение",
+ key,
+ )
+ cls._overrides_raw.pop(key, None)
+ else:
+ cls._overrides_raw[key] = raw_value
+ cls._apply_to_settings(key, value)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@@ -1300,14 +1328,26 @@ class BotConfigurationService:
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
- original = cls.get_original_value(key)
- cls._apply_to_settings(key, original)
+ if cls._is_env_override(key):
+ logger.info(
+ "Настройка %s сброшена в БД, используется значение из окружения",
+ key,
+ )
+ else:
+ original = cls.get_original_value(key)
+ cls._apply_to_settings(key, original)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
def _apply_to_settings(cls, key: str, value: Any) -> None:
+ if cls._is_env_override(key):
+ logger.debug(
+ "Пропуск применения настройки %s: значение задано через окружение",
+ key,
+ )
+ return
try:
setattr(settings, key, value)
if key in {
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 9d551fec..0a28c428 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -21,7 +21,7 @@ from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
- SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
+ SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
)
@@ -779,6 +779,29 @@ class UserService:
except Exception as e:
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
+ try:
+ heleket_result = await db.execute(
+ select(HeleketPayment).where(HeleketPayment.user_id == user_id)
+ )
+ heleket_payments = heleket_result.scalars().all()
+
+ if heleket_payments:
+ logger.info(
+ f"🔄 Удаляем {len(heleket_payments)} Heleket платежей"
+ )
+ await db.execute(
+ update(HeleketPayment)
+ .where(HeleketPayment.user_id == user_id)
+ .values(transaction_id=None)
+ )
+ await db.flush()
+ await db.execute(
+ delete(HeleketPayment).where(HeleketPayment.user_id == user_id)
+ )
+ await db.flush()
+ except Exception as e:
+ logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
+
try:
transactions_result = await db.execute(
select(Transaction).where(Transaction.user_id == user_id)
diff --git a/app/utils/timezone.py b/app/utils/timezone.py
new file mode 100644
index 00000000..83af1aa1
--- /dev/null
+++ b/app/utils/timezone.py
@@ -0,0 +1,82 @@
+"""Timezone utilities for consistent local time handling."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone as dt_timezone
+from functools import lru_cache
+from typing import Optional
+from zoneinfo import ZoneInfo
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+@lru_cache(maxsize=1)
+def get_local_timezone() -> ZoneInfo:
+ """Return the configured local timezone.
+
+ Falls back to UTC if the configured timezone cannot be loaded. The
+ fallback is logged once and cached for subsequent calls.
+ """
+
+ tz_name = settings.TIMEZONE
+
+ try:
+ return ZoneInfo(tz_name)
+ except Exception as exc: # pragma: no cover - defensive branch
+ logger.warning(
+ "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
+ tz_name,
+ exc,
+ )
+ return ZoneInfo("UTC")
+
+
+def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]:
+ """Convert a datetime value to the configured local timezone."""
+
+ if dt is None:
+ return None
+
+ aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc)
+ return aware_dt.astimezone(get_local_timezone())
+
+
+def format_local_datetime(
+ dt: Optional[datetime],
+ fmt: str = "%Y-%m-%d %H:%M:%S %Z",
+ na_placeholder: str = "N/A",
+) -> str:
+ """Format a datetime value in the configured local timezone."""
+
+ localized = to_local_datetime(dt)
+ if localized is None:
+ return na_placeholder
+ return localized.strftime(fmt)
+
+
+class TimezoneAwareFormatter(logging.Formatter):
+ """Logging formatter that renders timestamps in the configured timezone."""
+
+ def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs):
+ super().__init__(*args, **kwargs)
+ if timezone_name:
+ try:
+ self._timezone = ZoneInfo(timezone_name)
+ except Exception as exc: # pragma: no cover - defensive branch
+ logger.warning(
+ "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
+ timezone_name,
+ exc,
+ )
+ self._timezone = ZoneInfo("UTC")
+ else:
+ self._timezone = get_local_timezone()
+
+ def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name
+ dt = datetime.fromtimestamp(record.created, tz=self._timezone)
+ if datefmt:
+ return dt.strftime(datefmt)
+ return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index 36c3e89c..e089968d 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -58,6 +58,7 @@ from app.services.admin_notification_service import AdminNotificationService
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
+from app.utils.timezone import format_local_datetime
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
@@ -4428,7 +4429,7 @@ async def submit_subscription_renewal_endpoint(
language_code = _normalize_language_code(user)
amount_label = settings.format_price(final_total)
date_label = (
- subscription.end_date.strftime("%d.%m.%Y %H:%M")
+ format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
if subscription.end_date
else ""
)
diff --git a/main.py b/main.py
index d50e4689..30ec2c5d 100644
--- a/main.py
+++ b/main.py
@@ -36,6 +36,7 @@ from app.services.system_settings_service import bot_configuration_service
from app.services.external_admin_service import ensure_external_admin_token
from app.services.broadcast_service import broadcast_service
from app.utils.startup_timeline import StartupTimeline
+from app.utils.timezone import TimezoneAwareFormatter
class GracefulExit:
@@ -49,13 +50,20 @@ class GracefulExit:
async def main():
+ formatter = TimezoneAwareFormatter(
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ timezone_name=settings.TIMEZONE,
+ )
+
+ file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8')
+ file_handler.setFormatter(formatter)
+
+ stream_handler = logging.StreamHandler(sys.stdout)
+ stream_handler.setFormatter(formatter)
+
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
- handlers=[
- logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
- logging.StreamHandler(sys.stdout)
- ]
+ handlers=[file_handler, stream_handler],
)
logger = logging.getLogger(__name__)
diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py
new file mode 100644
index 00000000..81ed9104
--- /dev/null
+++ b/tests/services/test_system_settings_env_priority.py
@@ -0,0 +1,163 @@
+from types import SimpleNamespace
+from pathlib import Path
+import sys
+
+import pytest
+
+ROOT_DIR = Path(__file__).resolve().parents[2]
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+
+from app.config import settings
+from app.services.system_settings_service import bot_configuration_service
+
+
+@pytest.mark.asyncio
+async def test_env_override_prevents_set_value(monkeypatch):
+ bot_configuration_service.initialize_definitions()
+
+ env_value = "env_support"
+ monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
+ original_values = dict(bot_configuration_service._original_values)
+ original_values["SUPPORT_USERNAME"] = env_value
+ monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
+
+ env_keys = set(bot_configuration_service._env_override_keys)
+ env_keys.add("SUPPORT_USERNAME")
+ monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
+ monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
+
+ async def fake_upsert(db, key, value, description=None): # noqa: ANN001
+ return None
+
+ monkeypatch.setattr(
+ "app.services.system_settings_service.upsert_system_setting",
+ fake_upsert,
+ )
+
+ await bot_configuration_service.set_value(
+ object(),
+ "SUPPORT_USERNAME",
+ "db_support",
+ )
+
+ assert settings.SUPPORT_USERNAME == env_value
+ assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
+
+
+@pytest.mark.asyncio
+async def test_env_override_prevents_reset_value(monkeypatch):
+ bot_configuration_service.initialize_definitions()
+
+ env_value = "env_support"
+ monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
+ original_values = dict(bot_configuration_service._original_values)
+ original_values["SUPPORT_USERNAME"] = env_value
+ monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
+
+ env_keys = set(bot_configuration_service._env_override_keys)
+ env_keys.add("SUPPORT_USERNAME")
+ monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
+ monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {"SUPPORT_USERNAME": "db"})
+
+ async def fake_delete(db, key): # noqa: ANN001
+ return None
+
+ monkeypatch.setattr(
+ "app.services.system_settings_service.delete_system_setting",
+ fake_delete,
+ )
+
+ await bot_configuration_service.reset_value(
+ object(),
+ "SUPPORT_USERNAME",
+ )
+
+ assert settings.SUPPORT_USERNAME == env_value
+ assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
+
+
+@pytest.mark.asyncio
+async def test_initialize_skips_db_value_for_env_override(monkeypatch):
+ bot_configuration_service.initialize_definitions()
+
+ env_value = "env_support"
+ monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
+ original_values = dict(bot_configuration_service._original_values)
+ original_values["SUPPORT_USERNAME"] = env_value
+ monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
+
+ env_keys = set(bot_configuration_service._env_override_keys)
+ env_keys.add("SUPPORT_USERNAME")
+ monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
+ monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
+
+ class DummyResult:
+ def scalars(self):
+ return self
+
+ def all(self):
+ return [SimpleNamespace(key="SUPPORT_USERNAME", value="db_support")]
+
+ class DummySession:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
+ return False
+
+ async def execute(self, query): # noqa: ANN001
+ return DummyResult()
+
+ monkeypatch.setattr(
+ "app.services.system_settings_service.AsyncSessionLocal",
+ lambda: DummySession(),
+ )
+
+ async def fake_sync():
+ return True
+
+ monkeypatch.setattr(
+ "app.services.system_settings_service.ensure_default_web_api_token",
+ fake_sync,
+ raising=False,
+ )
+
+ await bot_configuration_service.initialize()
+
+ assert settings.SUPPORT_USERNAME == env_value
+ assert "SUPPORT_USERNAME" not in bot_configuration_service._overrides_raw
+ assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
+
+
+@pytest.mark.asyncio
+async def test_set_value_applies_without_env_override(monkeypatch):
+ bot_configuration_service.initialize_definitions()
+
+ monkeypatch.setattr(bot_configuration_service, "_env_override_keys", set())
+ monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
+
+ initial_value = True
+ target_value = False
+
+ monkeypatch.setattr(settings, "SUPPORT_MENU_ENABLED", initial_value)
+ original_values = dict(bot_configuration_service._original_values)
+ original_values["SUPPORT_MENU_ENABLED"] = initial_value
+ monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
+
+ async def fake_upsert(db, key, value, description=None): # noqa: ANN001
+ return None
+
+ monkeypatch.setattr(
+ "app.services.system_settings_service.upsert_system_setting",
+ fake_upsert,
+ )
+
+ await bot_configuration_service.set_value(
+ object(),
+ "SUPPORT_MENU_ENABLED",
+ target_value,
+ )
+
+ assert settings.SUPPORT_MENU_ENABLED is target_value
+ assert bot_configuration_service.has_override("SUPPORT_MENU_ENABLED")