Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers

Revert "Revert "feat: add promo offer operation logs""
This commit is contained in:
Egor
2025-10-05 03:46:36 +03:00
committed by GitHub
11 changed files with 679 additions and 10 deletions
+84 -1
View File
@@ -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()
+72
View File
@@ -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
+59 -2
View File
@@ -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
+19
View File
@@ -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"
+83
View File
@@ -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",
}
+213 -2
View File
@@ -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}. <b>{timestamp}</b> — {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",
"📜 <b>Лог операций промо-предложений</b>",
)
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)
+17 -2
View File
@@ -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(
+46 -1
View File
@@ -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()
+42 -2
View File
@@ -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)
+22
View File
@@ -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": "📜 <b>Promo offer activity log</b>",
"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",
+22
View File
@@ -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": "📜 <b>Лог операций промо-предложений</b>",
"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": "🛟 Настройки поддержки",