diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index c0bc9800..d1add91e 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -1,11 +1,17 @@ +from __future__ import annotations + +import logging from datetime import datetime, timedelta from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import DiscountOffer +logger = logging.getLogger(__name__) + async def upsert_discount_offer( db: AsyncSession, @@ -67,11 +73,35 @@ async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountO return result.scalar_one_or_none() -async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: +async def mark_offer_claimed( + db: AsyncSession, + offer: DiscountOffer, + *, + details: Optional[dict] = None, +) -> DiscountOffer: offer.claimed_at = datetime.utcnow() offer.is_active = False await db.commit() await db.refresh(offer) + + try: + await log_promo_offer_action( + db, + user_id=offer.user_id, + offer_id=offer.id, + action="claimed", + source=offer.notification_type, + percent=offer.discount_percent, + effect_type=offer.effect_type, + details=details, + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.warning( + "Failed to record promo offer claim log for offer %s: %s", + offer.id, + exc, + ) + return offer @@ -88,9 +118,62 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: return 0 count = 0 + log_payloads = [] for offer in offers: offer.is_active = False count += 1 + log_payloads.append( + { + "user_id": offer.user_id, + "offer_id": offer.id, + "source": offer.notification_type, + "percent": offer.discount_percent, + "effect_type": offer.effect_type, + } + ) await db.commit() + + for payload in log_payloads: + if not payload.get("user_id"): + continue + try: + await log_promo_offer_action( + db, + user_id=payload["user_id"], + offer_id=payload["offer_id"], + action="disabled", + source=payload.get("source"), + percent=payload.get("percent"), + effect_type=payload.get("effect_type"), + details={"reason": "offer_expired"}, + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.warning( + "Failed to record promo offer disable log for offer %s: %s", + payload.get("offer_id"), + exc, + ) + return count + + +async def get_latest_claimed_offer_for_user( + db: AsyncSession, + user_id: int, + source: Optional[str] = None, +) -> Optional[DiscountOffer]: + stmt = ( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.claimed_at.isnot(None), + ) + .order_by(DiscountOffer.claimed_at.desc()) + ) + + if source: + stmt = stmt.where(DiscountOffer.notification_type == source) + + result = await db.execute(stmt) + return result.scalars().first() diff --git a/app/database/crud/promo_offer_log.py b/app/database/crud/promo_offer_log.py new file mode 100644 index 00000000..42d17b01 --- /dev/null +++ b/app/database/crud/promo_offer_log.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from typing import Dict, List, Optional, Tuple + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import PromoOfferLog + +logger = logging.getLogger(__name__) + + +async def log_promo_offer_action( + db: AsyncSession, + *, + user_id: Optional[int], + offer_id: Optional[int], + action: str, + source: Optional[str] = None, + percent: Optional[int] = None, + effect_type: Optional[str] = None, + details: Optional[Dict[str, object]] = None, + commit: bool = True, +) -> PromoOfferLog: + """Persist a promo offer log entry.""" + + entry = PromoOfferLog( + user_id=user_id, + offer_id=offer_id, + action=action, + source=source, + percent=percent, + effect_type=effect_type, + details=(details or {}).copy(), + ) + db.add(entry) + + if commit: + try: + await db.commit() + await db.refresh(entry) + except Exception: + logger.exception("Failed to commit promo offer log entry") + raise + + return entry + + +async def list_promo_offer_logs( + db: AsyncSession, + offset: int = 0, + limit: int = 20, +) -> Tuple[List[PromoOfferLog], int]: + stmt = ( + select(PromoOfferLog) + .options( + selectinload(PromoOfferLog.user), + selectinload(PromoOfferLog.offer), + ) + .order_by(PromoOfferLog.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(stmt) + logs = result.scalars().all() + + count_stmt = select(func.count(PromoOfferLog.id)) + total = (await db.execute(count_stmt)).scalar() or 0 + + return logs, total diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 9dacb13b..3bd0b7d9 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -20,6 +20,8 @@ from app.database.models import ( ) from app.config import settings from app.database.crud.promo_group import get_default_promo_group +from app.database.crud.discount_offer import get_latest_claimed_offer_for_user +from app.database.crud.promo_offer_log import log_promo_offer_action from app.utils.validators import sanitize_telegram_name logger = logging.getLogger(__name__) @@ -282,10 +284,46 @@ async def subtract_user_balance( logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек") logger.error(f" 📝 Описание: {description}") + log_context: Optional[Dict[str, object]] = None + if consume_promo_offer: + try: + current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + except (TypeError, ValueError): + current_percent = 0 + + if current_percent > 0: + source = getattr(user, "promo_offer_discount_source", None) + log_context = { + "offer_id": None, + "percent": current_percent, + "source": source, + "effect_type": None, + "details": { + "reason": "manual_charge", + "description": description, + "amount_kopeks": amount_kopeks, + }, + } + try: + offer = await get_latest_claimed_offer_for_user(db, user.id, source) + except Exception as lookup_error: # pragma: no cover - defensive logging + logger.warning( + "Failed to fetch latest claimed promo offer for user %s: %s", + user.id, + lookup_error, + ) + offer = None + + if offer: + log_context["offer_id"] = offer.id + log_context["effect_type"] = offer.effect_type + if not log_context["percent"] and offer.discount_percent: + log_context["percent"] = offer.discount_percent + if user.balance_kopeks < amount_kopeks: logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!") return False - + try: old_balance = user.balance_kopeks user.balance_kopeks -= amount_kopeks @@ -295,7 +333,7 @@ async def subtract_user_balance( user.promo_offer_discount_source = None user.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(user) @@ -313,6 +351,25 @@ async def subtract_user_balance( payment_method=payment_method, ) + if consume_promo_offer and log_context: + try: + await log_promo_offer_action( + db, + user_id=user.id, + offer_id=log_context.get("offer_id"), + action="consumed", + source=log_context.get("source"), + percent=log_context.get("percent"), + effect_type=log_context.get("effect_type"), + details=log_context.get("details"), + ) + except Exception as log_error: # pragma: no cover - defensive logging + logger.warning( + "Failed to record promo offer consumption log for user %s: %s", + user.id, + log_error, + ) + logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}") return True diff --git a/app/database/models.py b/app/database/models.py index e6dd34c2..affb5dd2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -386,6 +386,7 @@ class User(Base): transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") discount_offers = relationship("DiscountOffer", back_populates="user") + promo_offer_logs = relationship("PromoOfferLog", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) @@ -821,6 +822,7 @@ class DiscountOffer(Base): user = relationship("User", back_populates="discount_offers") subscription = relationship("Subscription", back_populates="discount_offers") + logs = relationship("PromoOfferLog", back_populates="offer") class PromoOfferTemplate(Base): @@ -863,6 +865,23 @@ class SubscriptionTemporaryAccess(Base): subscription = relationship("Subscription", back_populates="temporary_accesses") offer = relationship("DiscountOffer") + +class PromoOfferLog(Base): + __tablename__ = "promo_offer_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True) + action = Column(String(50), nullable=False) + source = Column(String(100), nullable=True) + percent = Column(Integer, nullable=True) + effect_type = Column(String(50), nullable=True) + details = Column(JSON, nullable=True) + created_at = Column(DateTime, default=func.now()) + + user = relationship("User", back_populates="promo_offer_logs") + offer = relationship("DiscountOffer", back_populates="logs") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index fd1db22e..ba2506a3 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1009,6 +1009,79 @@ async def create_promo_offer_templates_table(): return False +async def create_promo_offer_logs_table() -> bool: + table_exists = await check_table_exists('promo_offer_logs') + if table_exists: + logger.info("Таблица promo_offer_logs уже существует") + return True + + try: + db_type = await get_database_type() + async with engine.begin() as conn: + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS promo_offer_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + offer_id INTEGER NULL REFERENCES discount_offers(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + source VARCHAR(100) NULL, + percent INTEGER NULL, + effect_type VARCHAR(50) NULL, + details JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); + CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); + """)) + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS promo_offer_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + offer_id INTEGER REFERENCES discount_offers(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + source VARCHAR(100), + percent INTEGER, + effect_type VARCHAR(50), + details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); + CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); + """)) + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS promo_offer_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + offer_id INT NULL, + action VARCHAR(50) NOT NULL, + source VARCHAR(100) NULL, + percent INT NULL, + effect_type VARCHAR(50) NULL, + details JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_promo_offer_logs_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_promo_offer_logs_offers FOREIGN KEY (offer_id) REFERENCES discount_offers(id) ON DELETE SET NULL + ); + + CREATE INDEX ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); + CREATE INDEX ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); + """)) + else: + logger.warning("Неизвестный тип БД для создания promo_offer_logs: %s", db_type) + return False + + logger.info("✅ Таблица promo_offer_logs успешно создана") + return True + except Exception as e: + logger.error(f"Ошибка создания таблицы promo_offer_logs: {e}") + return False + + async def create_subscription_temporary_access_table(): table_exists = await check_table_exists('subscription_temporary_access') if table_exists: @@ -2528,6 +2601,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей promo_offer_templates") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_LOGS ===") + promo_logs_created = await create_promo_offer_logs_table() + if promo_logs_created: + logger.info("✅ Таблица promo_offer_logs готова") + else: + logger.warning("⚠️ Проблемы с таблицей promo_offer_logs") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===") temp_access_created = await create_subscription_temporary_access_table() if temp_access_created: @@ -2742,6 +2822,7 @@ async def check_migration_status(): "discount_offers_effect_column": False, "discount_offers_extra_column": False, "promo_offer_templates_table": False, + "promo_offer_logs_table": False, "subscription_temporary_access_table": False, } @@ -2758,6 +2839,7 @@ async def check_migration_status(): status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type') status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data') status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates') + status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs') status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access') status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') @@ -2815,6 +2897,7 @@ async def check_migration_status(): "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", "promo_offer_templates_table": "Таблица promo_offer_templates", + "promo_offer_logs_table": "Таблица promo_offer_logs", "subscription_temporary_access_table": "Таблица subscription_temporary_access", } diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 85b1be48..4e25152e 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -1,8 +1,9 @@ from __future__ import annotations +import html import logging import re -from typing import List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError @@ -23,8 +24,9 @@ from app.database.crud.promo_offer_template import ( list_promo_offer_templates, update_promo_offer_template, ) +from app.database.crud.promo_offer_log import list_promo_offer_logs from app.database.crud.user import get_users_for_promo_segment -from app.database.models import PromoOfferTemplate, User +from app.database.models import PromoOfferLog, PromoOfferTemplate, User from app.keyboards.inline import get_happ_download_button_row from app.localization.texts import get_texts from app.states import AdminStates @@ -35,6 +37,22 @@ logger = logging.getLogger(__name__) SQUADS_PAGE_LIMIT = 10 +PROMO_OFFER_LOGS_PAGE_LIMIT = 10 + + +ACTION_LABEL_KEYS = { + "claimed": "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED", + "consumed": "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED", + "disabled": "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED", +} + + +REASON_LABEL_KEYS = { + "manual_charge": "ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL", + "autopay_consumed": "ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY", + "offer_expired": "ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED", + "test_access_expired": "ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED", +} OFFER_TYPE_CONFIG = { @@ -123,6 +141,12 @@ def _build_templates_keyboard(templates: Sequence[PromoOfferTemplate], language: callback_data=f"promo_offer_{template.id}", ) ]) + rows.append([ + InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_OFFER_LOGS", "📜 Лог операций"), + callback_data="promo_offer_logs_page_1", + ) + ]) rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")]) return InlineKeyboardMarkup(inline_keyboard=rows) @@ -157,6 +181,128 @@ def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) -> return InlineKeyboardMarkup(inline_keyboard=rows) +def _format_promo_offer_log_entry( + entry: PromoOfferLog, + index: int, + texts, +) -> str: + timestamp = entry.created_at.strftime("%d.%m.%Y %H:%M") if entry.created_at else "-" + action_key = ACTION_LABEL_KEYS.get(entry.action, "") + action_label = texts.get(action_key, entry.action.title()) + lines = [f"{index}. {timestamp} — {action_label}"] + + user = entry.user + if user: + username = f"@{user.username}" if user.username else f"ID{user.telegram_id}" + label = f"{username} (#{user.id})" + elif entry.user_id: + label = f"ID{entry.user_id}" + else: + label = texts.get("ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER", "Неизвестный пользователь") + + lines.append(texts.get("ADMIN_PROMO_OFFER_LOGS_USER", "👤 {user}").format(user=html.escape(label))) + + if entry.percent: + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_PERCENT", "📉 Скидка: {percent}%").format( + percent=entry.percent + ) + ) + + effect_type = (entry.effect_type or "").lower() + if effect_type: + if effect_type == "test_access": + effect_label = texts.get("ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST", "🧪 Тестовый доступ") + else: + effect_label = texts.get("ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT", "💸 Скидка") + lines.append(effect_label) + + if entry.source: + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_SOURCE", "🏷 Источник: {source}").format( + source=html.escape(entry.source) + ) + ) + + details: Dict[str, object] = entry.details if isinstance(entry.details, dict) else {} + reason_key = details.get("reason") + if reason_key: + reason_label = texts.get(REASON_LABEL_KEYS.get(reason_key, ""), "") + if not reason_label: + reason_label = texts.get( + "ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC", + "ℹ️ Действие: {reason}", + ).format(reason=html.escape(str(reason_key))) + lines.append(reason_label) + + description = details.get("description") + if description: + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_DESCRIPTION", "📝 {description}").format( + description=html.escape(str(description)) + ) + ) + + amount = details.get("amount_kopeks") + if isinstance(amount, int): + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_AMOUNT", "💰 Сумма: {amount}").format( + amount=texts.format_price(amount) + ) + ) + + squad_uuid = details.get("squad_uuid") + if squad_uuid: + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_SQUAD", "🌍 Сквад: {squad}").format( + squad=html.escape(str(squad_uuid)) + ) + ) + + new_squads = details.get("new_squads") + if isinstance(new_squads, (list, tuple)): + filtered = [html.escape(str(item)) for item in new_squads if item] + if filtered: + lines.append( + texts.get("ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS", "🌍 Новые сквады: {squads}").format( + squads=", ".join(filtered) + ) + ) + + return "\n".join(lines) + + +def _build_logs_keyboard(page: int, total_pages: int, language: str) -> InlineKeyboardMarkup: + texts = get_texts(language) + rows: List[List[InlineKeyboardButton]] = [] + if total_pages > 1: + nav_row: List[InlineKeyboardButton] = [] + if page > 1: + nav_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"promo_offer_logs_page_{page - 1}", + ) + ) + nav_row.append( + InlineKeyboardButton( + text=f"{page}/{total_pages}", + callback_data=f"promo_offer_logs_page_{page}", + ) + ) + if page < total_pages: + nav_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"promo_offer_logs_page_{page + 1}", + ) + ) + rows.append(nav_row) + + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_offers")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + def _build_send_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup: config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) segments = config.get("allowed_segments", []) @@ -289,6 +435,70 @@ async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: A await callback.answer() +@admin_required +@error_handler +async def show_promo_offer_logs(callback: CallbackQuery, db_user: User, db: AsyncSession): + try: + if "_page_" in callback.data: + page = int(callback.data.split("_page_")[-1]) + else: + page = 1 + except (ValueError, AttributeError): + page = 1 + + if page < 1: + page = 1 + + limit = PROMO_OFFER_LOGS_PAGE_LIMIT + offset = (page - 1) * limit + logs, total = await list_promo_offer_logs(db, offset=offset, limit=limit) + total_pages = max(1, (total + limit - 1) // limit) + + if page > total_pages and total > 0: + page = total_pages + offset = (page - 1) * limit + logs, _ = await list_promo_offer_logs(db, offset=offset, limit=limit) + + texts = get_texts(db_user.language) + header = texts.t( + "ADMIN_PROMO_OFFER_LOGS_TITLE", + "📜 Лог операций промо-предложений", + ) + + if logs: + message_lines = [ + header, + texts.get( + "ADMIN_PROMO_OFFER_LOGS_PAGINATION", + "Страница {page}/{total}", + ).format(page=page, total=total_pages), + "", + ] + for index, entry in enumerate(logs, start=offset + 1): + message_lines.append(_format_promo_offer_log_entry(entry, index, texts)) + message_lines.append("") + message_text = "\n".join(message_lines).rstrip() + else: + message_text = "\n".join( + [ + header, + "", + texts.get( + "ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY", + "Записей пока нет.", + ), + ] + ) + + keyboard = _build_logs_keyboard(page, total_pages, db_user.language) + await callback.message.edit_text( + message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + async def _prompt_edit(callback: CallbackQuery, state: FSMContext, template_id: int, prompt: str, new_state): await state.update_data( selected_promo_offer=template_id, @@ -965,6 +1175,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(back_to_offer_from_squads, F.data.startswith("promo_offer_squad_back_")) dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_")) dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_")) + dp.callback_query.register(show_promo_offer_logs, F.data.regexp(r"^promo_offer_logs_page_\d+$")) dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_")) dp.message.register(process_edit_message_text, AdminStates.editing_promo_offer_message) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index d4bea16f..39a14be9 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -5202,7 +5202,15 @@ async def claim_discount_offer( await callback.answer(error_message, show_alert=True) return - await mark_offer_claimed(db, offer) + await mark_offer_claimed( + db, + offer, + details={ + "context": "test_access_claim", + "new_squads": newly_added, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else "" success_message = texts.get( @@ -5237,7 +5245,14 @@ async def claim_discount_offer( db_user.promo_offer_discount_source = offer.notification_type db_user.updated_at = now - await mark_offer_claimed(db, offer) + await mark_offer_claimed( + db, + offer, + details={ + "context": "discount_claim", + "discount_percent": discount_percent, + }, + ) await db.refresh(db_user) success_message = texts.get( diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index e0cc2968..d7eb678b 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -15,8 +15,10 @@ from app.config import settings from app.database.database import get_db from app.database.crud.discount_offer import ( deactivate_expired_offers, + get_latest_claimed_offer_for_user, upsert_discount_offer, ) +from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.crud.notification import ( clear_notification_by_type, notification_sent, @@ -795,9 +797,34 @@ class MonitoringService: @staticmethod async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None: - if MonitoringService._get_user_promo_offer_discount_percent(user) <= 0: + percent = MonitoringService._get_user_promo_offer_discount_percent(user) + if percent <= 0: return + source = getattr(user, "promo_offer_discount_source", None) + log_payload = { + "offer_id": None, + "percent": percent, + "source": source, + "effect_type": None, + } + + try: + offer = await get_latest_claimed_offer_for_user(db, user.id, source) + except Exception as lookup_error: # pragma: no cover - defensive logging + logger.warning( + "Failed to resolve latest claimed promo offer for user %s: %s", + user.id, + lookup_error, + ) + offer = None + + if offer: + log_payload["offer_id"] = offer.id + log_payload["effect_type"] = offer.effect_type + if not log_payload["percent"] and offer.discount_percent: + log_payload["percent"] = offer.discount_percent + user.promo_offer_discount_percent = 0 user.promo_offer_discount_source = None user.updated_at = datetime.utcnow() @@ -805,6 +832,24 @@ class MonitoringService: await db.commit() await db.refresh(user) + try: + await log_promo_offer_action( + db, + user_id=user.id, + offer_id=log_payload.get("offer_id"), + action="consumed", + source=log_payload.get("source"), + percent=log_payload.get("percent"), + effect_type=log_payload.get("effect_type"), + details={"reason": "autopay_consumed"}, + ) + except Exception as log_error: # pragma: no cover - defensive logging + logger.warning( + "Failed to record promo offer autopay log for user %s: %s", + user.id, + log_error, + ) + async def _process_autopayments(self, db: AsyncSession): try: current_time = datetime.utcnow() diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py index a5a24416..de893572 100644 --- a/app/services/promo_offer_service.py +++ b/app/services/promo_offer_service.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from datetime import datetime, timedelta -from typing import List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +15,7 @@ from app.database.models import ( User, ) from app.services.subscription_service import SubscriptionService +from app.database.crud.promo_offer_log import log_promo_offer_action logger = logging.getLogger(__name__) @@ -134,7 +135,10 @@ class PromoOfferService: now = datetime.utcnow() result = await db.execute( select(SubscriptionTemporaryAccess) - .options(selectinload(SubscriptionTemporaryAccess.subscription)) + .options( + selectinload(SubscriptionTemporaryAccess.subscription), + selectinload(SubscriptionTemporaryAccess.offer), + ) .where( SubscriptionTemporaryAccess.is_active == True, # noqa: E712 SubscriptionTemporaryAccess.expires_at <= now, @@ -145,6 +149,7 @@ class PromoOfferService: return 0 subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {} + log_payloads: List[Dict[str, object]] = [] for entry in entries: entry.is_active = False @@ -157,6 +162,23 @@ class PromoOfferService: if not entry.was_already_connected: bucket[1].add(entry.squad_uuid) + user_id = subscription.user_id + if user_id: + offer = entry.offer + log_payloads.append( + { + "user_id": user_id, + "offer_id": entry.offer_id, + "source": getattr(offer, "notification_type", None), + "percent": getattr(offer, "discount_percent", None), + "effect_type": getattr(offer, "effect_type", "test_access"), + "details": { + "reason": "test_access_expired", + "squad_uuid": entry.squad_uuid, + }, + } + ) + for subscription, squads_to_remove in subscriptions_updates.values(): if not squads_to_remove: continue @@ -175,6 +197,24 @@ class PromoOfferService: ) await db.commit() + for payload in log_payloads: + try: + await log_promo_offer_action( + db, + user_id=payload["user_id"], + offer_id=payload.get("offer_id"), + action="disabled", + source=payload.get("source"), + percent=payload.get("percent"), + effect_type=payload.get("effect_type"), + details=payload.get("details"), + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.warning( + "Failed to record promo offer test access disable log for user %s: %s", + payload.get("user_id"), + exc, + ) return len(entries) diff --git a/locales/en.json b/locales/en.json index e55f314e..aa465cdf 100644 --- a/locales/en.json +++ b/locales/en.json @@ -607,6 +607,28 @@ "ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription", "ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer", "ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers", + "ADMIN_PROMO_OFFER_LOGS": "📜 Activity log", + "ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 Promo offer activity log", + "ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Page {page}/{total}", + "ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "No activity yet.", + "ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Unknown user", + "ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}", + "ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Discount: {percent}%", + "ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Test access", + "ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Discount", + "ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Source: {source}", + "ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Action: {reason}", + "ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Applied during manual payment", + "ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Used in autopay renewal", + "ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Offer expired", + "ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Test access removed", + "ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}", + "ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Amount: {amount}", + "ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Squad: {squad}", + "ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 New squads: {squads}", + "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed", + "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used", + "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled", "ADMIN_SUPPORT_TICKETS": "🎫 Support tickets", "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit", "ADMIN_SUPPORT_SETTINGS": "🛟 Support settings", diff --git a/locales/ru.json b/locales/ru.json index 4a91528e..ba73f5f4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -607,6 +607,28 @@ "ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку", "ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению", "ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям", + "ADMIN_PROMO_OFFER_LOGS": "📜 Лог операций", + "ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 Лог операций промо-предложений", + "ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Страница {page}/{total}", + "ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "Записей пока нет.", + "ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Неизвестный пользователь", + "ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}", + "ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Скидка: {percent}%", + "ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Тестовый доступ", + "ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Скидка", + "ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Источник: {source}", + "ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Действие: {reason}", + "ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Списано при ручной оплате", + "ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Применено при автопродлении", + "ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Предложение истекло", + "ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Тестовый доступ отключён", + "ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}", + "ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Сумма: {amount}", + "ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Сквад: {squad}", + "ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 Новые сквады: {squads}", + "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято", + "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано", + "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено", "ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки", "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов", "ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",