1f0fef114b
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding via structlog contextvars (aiogram) and http_method/http_path (FastAPI) - Use bound_contextvars() context manager instead of clear_contextvars() to safely restore previous state instead of wiping all context - Register ContextVarsMiddleware as outermost middleware (before GlobalError) so all error logs include user context - Replace structlog.get_logger() with structlog.get_logger(__name__) across 270 calls in 265 files for meaningful logger names - Switch wrapper_class from BoundLogger to make_filtering_bound_logger() for pre-processor level filtering (performance optimization) - Migrate 1411 %-style positional arg logger calls to structlog kwargs style across 161 files via AST script - Migrate log_rotation_service.py from stdlib logging to structlog - Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking to Telegram notifications and general log files - Fix LoggingMiddleware: add from_user null-safety for channel posts, switch time.time() to time.monotonic() for duration measurement - Remove duplicate logger assignments in purchase.py, config.py, inline.py, and admin/payments.py
525 lines
15 KiB
Python
525 lines
15 KiB
Python
import html
|
||
from datetime import datetime
|
||
|
||
import structlog
|
||
from aiogram import Dispatcher, F, types
|
||
from aiogram.fsm.context import FSMContext
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database.models import User
|
||
from app.localization.texts import get_texts
|
||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||
from app.states import AdminStates
|
||
from app.utils.decorators import admin_required, error_handler
|
||
from app.utils.validators import get_html_help_text, validate_html_tags
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
def _format_timestamp(value: datetime | None) -> str:
|
||
if not value:
|
||
return ''
|
||
try:
|
||
return value.strftime('%d.%m.%Y %H:%M')
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
async def _build_overview(
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
policy = await PrivacyPolicyService.get_policy(
|
||
db,
|
||
db_user.language,
|
||
fallback=False,
|
||
)
|
||
|
||
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
|
||
has_content = bool(policy and policy.content and policy.content.strip())
|
||
|
||
description = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_DESCRIPTION',
|
||
'Политика конфиденциальности отображается в разделе «Инфо».',
|
||
)
|
||
|
||
status_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_STATUS_DISABLED',
|
||
'⚠️ Показ политики выключен или текст отсутствует.',
|
||
)
|
||
if policy and policy.is_enabled and has_content:
|
||
status_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_STATUS_ENABLED',
|
||
'✅ Политика активна и показывается пользователям.',
|
||
)
|
||
elif policy and policy.is_enabled:
|
||
status_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY',
|
||
'⚠️ Политика включена, но текст пуст — пользователи её не увидят.',
|
||
)
|
||
|
||
updated_at = _format_timestamp(getattr(policy, 'updated_at', None))
|
||
updated_block = ''
|
||
if updated_at:
|
||
updated_block = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_UPDATED_AT',
|
||
'Последнее обновление: {timestamp}',
|
||
).format(timestamp=updated_at)
|
||
|
||
preview_block = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY',
|
||
'Текст ещё не задан.',
|
||
)
|
||
if has_content:
|
||
preview_title = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_PREVIEW_TITLE',
|
||
'<b>Превью текста:</b>',
|
||
)
|
||
preview_raw = policy.content.strip()
|
||
preview_trimmed = preview_raw[:400]
|
||
if len(preview_raw) > 400:
|
||
preview_trimmed += '...'
|
||
preview_block = f'{preview_title}\n<code>{html.escape(preview_trimmed)}</code>'
|
||
|
||
language_block = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_LANGUAGE',
|
||
'Язык: <code>{lang}</code>',
|
||
).format(lang=normalized_language)
|
||
|
||
header = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_HEADER',
|
||
'🛡️ <b>Политика конфиденциальности</b>',
|
||
)
|
||
actions_prompt = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_ACTION_PROMPT',
|
||
'Выберите действие:',
|
||
)
|
||
|
||
message_parts = [
|
||
header,
|
||
description,
|
||
language_block,
|
||
status_text,
|
||
]
|
||
|
||
if updated_block:
|
||
message_parts.append(updated_block)
|
||
|
||
message_parts.append(preview_block)
|
||
message_parts.append(actions_prompt)
|
||
|
||
overview_text = '\n\n'.join(part for part in message_parts if part)
|
||
|
||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_EDIT_BUTTON',
|
||
'✏️ Изменить текст',
|
||
),
|
||
callback_data='admin_privacy_policy_edit',
|
||
)
|
||
]
|
||
)
|
||
|
||
if has_content:
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_VIEW_BUTTON',
|
||
'👀 Просмотреть текущий текст',
|
||
),
|
||
callback_data='admin_privacy_policy_view',
|
||
)
|
||
]
|
||
)
|
||
|
||
toggle_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_ENABLE_BUTTON',
|
||
'✅ Включить показ',
|
||
)
|
||
if policy and policy.is_enabled:
|
||
toggle_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_DISABLE_BUTTON',
|
||
'🚫 Отключить показ',
|
||
)
|
||
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=toggle_text,
|
||
callback_data='admin_privacy_policy_toggle',
|
||
)
|
||
]
|
||
)
|
||
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_HTML_HELP',
|
||
'ℹ️ HTML помощь',
|
||
),
|
||
callback_data='admin_privacy_policy_help',
|
||
)
|
||
]
|
||
)
|
||
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data='admin_submenu_settings',
|
||
)
|
||
]
|
||
)
|
||
|
||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_privacy_policy_management(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||
|
||
await callback.message.edit_text(
|
||
overview_text,
|
||
reply_markup=markup,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_privacy_policy(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
|
||
logger.info(
|
||
'Админ %s переключил показ политики конфиденциальности: %s',
|
||
db_user.telegram_id,
|
||
'enabled' if updated_policy.is_enabled else 'disabled',
|
||
)
|
||
status_message = (
|
||
texts.t('ADMIN_PRIVACY_POLICY_ENABLED', '✅ Политика включена')
|
||
if updated_policy.is_enabled
|
||
else texts.t('ADMIN_PRIVACY_POLICY_DISABLED', '🚫 Политика отключена')
|
||
)
|
||
|
||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||
await callback.message.edit_text(
|
||
overview_text,
|
||
reply_markup=markup,
|
||
)
|
||
await callback.answer(status_message)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def start_edit_privacy_policy(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
state: FSMContext,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
|
||
policy = await PrivacyPolicyService.get_policy(
|
||
db,
|
||
db_user.language,
|
||
fallback=False,
|
||
)
|
||
|
||
current_preview = ''
|
||
if policy and policy.content:
|
||
preview = policy.content.strip()[:400]
|
||
if len(policy.content.strip()) > 400:
|
||
preview += '...'
|
||
current_preview = (
|
||
texts.t(
|
||
'ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW',
|
||
'Текущий текст (превью):',
|
||
)
|
||
+ f'\n<code>{html.escape(preview)}</code>\n\n'
|
||
)
|
||
|
||
prompt = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_EDIT_PROMPT',
|
||
'Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.',
|
||
)
|
||
|
||
hint = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_EDIT_HINT',
|
||
'Используйте /html_help для справки по тегам.',
|
||
)
|
||
|
||
message_text = (
|
||
f'📝 <b>{texts.t("ADMIN_PRIVACY_POLICY_EDIT_TITLE", "Редактирование политики")}</b>\n\n'
|
||
f'{current_preview}{prompt}\n\n{hint}'
|
||
)
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_HTML_HELP',
|
||
'ℹ️ HTML помощь',
|
||
),
|
||
callback_data='admin_privacy_policy_help',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t('ADMIN_PRIVACY_POLICY_CANCEL', '❌ Отмена'),
|
||
callback_data='admin_privacy_policy_cancel',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||
await state.set_state(AdminStates.editing_privacy_policy)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def cancel_edit_privacy_policy(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
state: FSMContext,
|
||
db: AsyncSession,
|
||
):
|
||
await state.clear()
|
||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||
await callback.message.edit_text(
|
||
overview_text,
|
||
reply_markup=markup,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_privacy_policy_edit(
|
||
message: types.Message,
|
||
db_user: User,
|
||
state: FSMContext,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
new_text = message.text or ''
|
||
|
||
if len(new_text) > 4000:
|
||
await message.answer(
|
||
texts.t(
|
||
'ADMIN_PRIVACY_POLICY_TOO_LONG',
|
||
'❌ Текст политики слишком длинный. Максимум 4000 символов.',
|
||
)
|
||
)
|
||
return
|
||
|
||
is_valid, error_message = validate_html_tags(new_text)
|
||
if not is_valid:
|
||
await message.answer(
|
||
texts.t(
|
||
'ADMIN_PRIVACY_POLICY_HTML_ERROR',
|
||
'❌ Ошибка в HTML: {error}',
|
||
).format(error=error_message)
|
||
)
|
||
return
|
||
|
||
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
|
||
logger.info(
|
||
'Админ обновил текст политики конфиденциальности (символов)',
|
||
telegram_id=db_user.telegram_id,
|
||
new_text_count=len(new_text),
|
||
)
|
||
await state.clear()
|
||
|
||
success_text = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_SAVED',
|
||
'✅ Политика конфиденциальности обновлена.',
|
||
)
|
||
|
||
reply_markup = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_BACK_BUTTON',
|
||
'⬅️ К настройкам политики',
|
||
),
|
||
callback_data='admin_privacy_policy',
|
||
)
|
||
]
|
||
]
|
||
)
|
||
|
||
await message.answer(success_text, reply_markup=reply_markup)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def view_privacy_policy(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
policy = await PrivacyPolicyService.get_policy(
|
||
db,
|
||
db_user.language,
|
||
fallback=False,
|
||
)
|
||
|
||
if not policy or not policy.content or not policy.content.strip():
|
||
await callback.answer(
|
||
texts.t(
|
||
'ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT',
|
||
'Текст политики пока не задан.',
|
||
),
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
content = policy.content.strip()
|
||
truncated = False
|
||
max_length = 3800
|
||
if len(content) > max_length:
|
||
content = content[: max_length - 3] + '...'
|
||
truncated = True
|
||
|
||
header = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_VIEW_TITLE',
|
||
'👀 <b>Текущий текст политики</b>',
|
||
)
|
||
|
||
note = ''
|
||
if truncated:
|
||
note = texts.t(
|
||
'ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED',
|
||
'\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.',
|
||
)
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_BACK_BUTTON',
|
||
'⬅️ К настройкам политики',
|
||
),
|
||
callback_data='admin_privacy_policy',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_EDIT_BUTTON',
|
||
'✏️ Изменить текст',
|
||
),
|
||
callback_data='admin_privacy_policy_edit',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
f'{header}\n\n{content}{note}',
|
||
reply_markup=keyboard,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_privacy_policy_html_help(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
state: FSMContext,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
help_text = get_html_help_text()
|
||
|
||
current_state = await state.get_state()
|
||
|
||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||
|
||
if current_state == AdminStates.editing_privacy_policy.state:
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT',
|
||
'⬅️ Назад к редактированию',
|
||
),
|
||
callback_data='admin_privacy_policy_edit',
|
||
)
|
||
]
|
||
)
|
||
|
||
buttons.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
'ADMIN_PRIVACY_POLICY_BACK_BUTTON',
|
||
'⬅️ К настройкам политики',
|
||
),
|
||
callback_data='admin_privacy_policy',
|
||
)
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
help_text,
|
||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
def register_handlers(dp: Dispatcher) -> None:
|
||
dp.callback_query.register(
|
||
show_privacy_policy_management,
|
||
F.data == 'admin_privacy_policy',
|
||
)
|
||
dp.callback_query.register(
|
||
toggle_privacy_policy,
|
||
F.data == 'admin_privacy_policy_toggle',
|
||
)
|
||
dp.callback_query.register(
|
||
start_edit_privacy_policy,
|
||
F.data == 'admin_privacy_policy_edit',
|
||
)
|
||
dp.callback_query.register(
|
||
cancel_edit_privacy_policy,
|
||
F.data == 'admin_privacy_policy_cancel',
|
||
)
|
||
dp.callback_query.register(
|
||
view_privacy_policy,
|
||
F.data == 'admin_privacy_policy_view',
|
||
)
|
||
dp.callback_query.register(
|
||
show_privacy_policy_html_help,
|
||
F.data == 'admin_privacy_policy_help',
|
||
)
|
||
|
||
dp.message.register(
|
||
process_privacy_policy_edit,
|
||
AdminStates.editing_privacy_policy,
|
||
)
|