add newsletter

This commit is contained in:
2026-05-20 10:11:47 +03:00
parent 2cc2f12a21
commit 5c7a9710f3
9 changed files with 957 additions and 11 deletions

View File

@@ -182,6 +182,7 @@ class BotConfigSnapshot:
continue
title, description, squad_groups = spec
use_duration_suffix = len(pairs) > 1
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
@@ -191,7 +192,7 @@ class BotConfigSnapshot:
if days <= 0 or amount_rub <= 0:
continue
plan_code = f"{normalized_code}_{days}"
plan_code = f"{normalized_code}_{days}" if use_duration_suffix else normalized_code
values.append(
PaymentPlanSettings(
code=plan_code,
@@ -917,6 +918,7 @@ class BotConfigService(StaticBotConfigService):
raise ValueError("Коды подписок не должны повторяться.")
title, description, squad_groups = spec
use_duration_suffix = len(pairs) > 1
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
raise ValueError("Дни и цена должны быть целыми положительными числами.")
@@ -926,7 +928,7 @@ class BotConfigService(StaticBotConfigService):
if days <= 0 or amount_rub <= 0:
raise ValueError("У подписок дни и цена должны быть больше нуля.")
plan_code = f"{normalized_code}_{days}"
plan_code = f"{normalized_code}_{days}" if use_duration_suffix else normalized_code
values.append(
PaymentPlanSettings(
code=plan_code,

View File

@@ -39,15 +39,17 @@ class AppliedDiscount:
plan_code: str | None = None
expires_at: datetime | None = None
def applies_to_plan(self, plan_code: str) -> bool:
def applies_to_plan(self, plan_code: str, *, days: int | None = None) -> bool:
if not self.code:
return False
if self.plan_code is None:
return True
# "all_30" matches any product with 30 days (vpn_white_30, white_30, vpn_30)
# "all_30" matches any product with 30 days, including single-duration product codes.
if self.plan_code.startswith("all_"):
suffix = self.plan_code.removeprefix("all") # "_30"
return plan_code.endswith(suffix)
raw_days = self.plan_code.removeprefix("all_")
return raw_days.isdigit() and (
plan_code.endswith(f"_{raw_days}") or days == int(raw_days)
)
# Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
@@ -535,7 +537,10 @@ class PaymentService:
) -> PaymentPlan:
resolved_discount = (
applied_discount
if applied_discount.applies_to_plan(plan_settings.code)
if applied_discount.applies_to_plan(
plan_settings.code,
days=plan_settings.days,
)
else AppliedDiscount()
)
discount_percent = max(resolved_discount.discount_percent, 0)

View File

@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
Guide,
GuidePhoto,
InternalSquad,
PromoCode,
PromoCodeApplication,
@@ -65,6 +67,19 @@ class PromoCodeView:
return self.is_active and not self.is_expired and self.discount_percent > 0
@dataclass(slots=True)
class GuideView:
id: int
title: str
short_description: str
body_text: str
photo_file_ids: list[str] = field(default_factory=list)
is_active: bool = True
created_by_telegram_id: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
@@ -268,6 +283,101 @@ class SyncService:
promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
return self._promo_code_view(promo) if promo is not None else None
async def create_guide(
self,
*,
title: str,
short_description: str,
body_text: str,
photo_file_ids: list[str],
created_by_telegram_id: int,
) -> GuideView:
resolved_title = " ".join((title or "").split()).strip()
if not resolved_title:
raise ValueError("Введите название гайда.")
if len(resolved_title) > 255:
raise ValueError("Название гайда должно быть не длиннее 255 символов.")
resolved_description = " ".join((short_description or "").split()).strip()
if len(resolved_description) > 512:
raise ValueError("Короткое описание должно быть не длиннее 512 символов.")
resolved_body = (body_text or "").strip()
if not resolved_body:
raise ValueError("Введите текст гайда.")
resolved_photos: list[str] = []
for file_id in photo_file_ids:
normalized_file_id = str(file_id or "").strip()
if normalized_file_id and normalized_file_id not in resolved_photos:
resolved_photos.append(normalized_file_id)
async with self._session_factory() as session:
guide = Guide(
title=resolved_title,
short_description=resolved_description,
body_text=resolved_body,
is_active=True,
created_by_telegram_id=created_by_telegram_id,
)
session.add(guide)
await session.flush()
for position, file_id in enumerate(resolved_photos):
session.add(
GuidePhoto(
guide_id=guide.id,
file_id=file_id,
position=position,
)
)
await session.commit()
return self._guide_view(guide, resolved_photos)
async def get_guides(self, *, active_only: bool = True, limit: int = 50) -> list[GuideView]:
resolved_limit = max(1, min(limit, 100))
async with self._session_factory() as session:
query = (
select(Guide, GuidePhoto.file_id)
.outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
.order_by(Guide.created_at.desc(), Guide.id.desc(), GuidePhoto.position.asc())
.limit(resolved_limit * 10)
)
if active_only:
query = query.where(Guide.is_active == True) # noqa: E712
rows = (await session.execute(query)).all()
guides = self._build_guide_views(rows)
return guides[:resolved_limit]
async def get_guide(self, guide_id: int, *, active_only: bool = True) -> GuideView | None:
async with self._session_factory() as session:
query = (
select(Guide, GuidePhoto.file_id)
.outerjoin(GuidePhoto, GuidePhoto.guide_id == Guide.id)
.where(Guide.id == guide_id)
.order_by(GuidePhoto.position.asc())
)
if active_only:
query = query.where(Guide.is_active == True) # noqa: E712
rows = (await session.execute(query)).all()
guides = self._build_guide_views(rows)
return guides[0] if guides else None
async def delete_guide(self, guide_id: int) -> GuideView | None:
guide = await self.get_guide(guide_id, active_only=False)
if guide is None:
return None
async with self._session_factory() as session:
await session.execute(delete(GuidePhoto).where(GuidePhoto.guide_id == guide_id))
await session.execute(delete(Guide).where(Guide.id == guide_id))
await session.commit()
return guide
async def consume_discount_for_user(self, telegram_id: int) -> None:
"""Burn the applied promo / referral discount so it cannot be reused.
@@ -452,6 +562,15 @@ class SyncService:
active_cached_users=int(active_cached_users or 0),
)
async def get_broadcast_telegram_ids(self) -> list[int]:
async with self._session_factory() as session:
rows = await session.scalars(
select(TelegramUser.telegram_id)
.where(TelegramUser.is_blocked == False) # noqa: E712
.order_by(TelegramUser.id.asc())
)
return list(rows.all())
async def get_admin_telegram_users_page(
self,
*,
@@ -1073,6 +1192,32 @@ class SyncService:
updated_at=promo.updated_at,
)
@staticmethod
def _guide_view(guide: Guide, photo_file_ids: list[str]) -> GuideView:
return GuideView(
id=guide.id,
title=guide.title,
short_description=guide.short_description,
body_text=guide.body_text,
photo_file_ids=photo_file_ids,
is_active=bool(guide.is_active),
created_by_telegram_id=guide.created_by_telegram_id,
created_at=guide.created_at,
updated_at=guide.updated_at,
)
@classmethod
def _build_guide_views(cls, rows: list[tuple[Guide, str | None]]) -> list[GuideView]:
guide_map: dict[int, GuideView] = {}
for guide, file_id in rows:
view = guide_map.get(guide.id)
if view is None:
view = cls._guide_view(guide, [])
guide_map[guide.id] = view
if file_id and file_id not in view.photo_file_ids:
view.photo_file_ids.append(file_id)
return list(guide_map.values())
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}