add newsletter
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import asyncio
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.filters import Command, CommandObject, CommandStart
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message
|
||||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, InputMediaPhoto, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.bot.ui.panel import (
|
||||
@@ -50,6 +51,7 @@ from app.services.sync_service import (
|
||||
AdminTelegramUserProfile,
|
||||
AdminTelegramUsersPage,
|
||||
CachedUserView,
|
||||
GuideView,
|
||||
PromoCodeView,
|
||||
SyncService,
|
||||
)
|
||||
@@ -85,6 +87,10 @@ class AdminLookupStates(StatesGroup):
|
||||
waiting_for_identifier = State()
|
||||
|
||||
|
||||
class AdminBroadcastStates(StatesGroup):
|
||||
waiting_for_text = State()
|
||||
|
||||
|
||||
class AdminConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
@@ -96,6 +102,13 @@ class AdminPromoStates(StatesGroup):
|
||||
waiting_for_expires = State()
|
||||
|
||||
|
||||
class AdminGuideStates(StatesGroup):
|
||||
waiting_for_title = State()
|
||||
waiting_for_description = State()
|
||||
waiting_for_body = State()
|
||||
waiting_for_photos = State()
|
||||
|
||||
|
||||
def build_router(
|
||||
*,
|
||||
settings: Settings,
|
||||
@@ -130,6 +143,9 @@ def build_router(
|
||||
def payment_callback(action: str, value: str | None = None) -> str:
|
||||
return f"payment:{action}:{value}" if value else f"payment:{action}"
|
||||
|
||||
def guides_callback(action: str, value: str | None = None) -> str:
|
||||
return f"guides:{action}:{value}" if value else f"guides:{action}"
|
||||
|
||||
async def send_branded(
|
||||
bot_instance: Bot,
|
||||
*,
|
||||
@@ -219,11 +235,16 @@ def build_router(
|
||||
ReferralCodeStates.waiting_for_code.state,
|
||||
ManualPaymentStates.waiting_for_receipt.state,
|
||||
AdminLookupStates.waiting_for_identifier.state,
|
||||
AdminBroadcastStates.waiting_for_text.state,
|
||||
AdminConfigStates.waiting_for_value.state,
|
||||
AdminPromoStates.waiting_for_code.state,
|
||||
AdminPromoStates.waiting_for_plan.state,
|
||||
AdminPromoStates.waiting_for_discount.state,
|
||||
AdminPromoStates.waiting_for_expires.state,
|
||||
AdminGuideStates.waiting_for_title.state,
|
||||
AdminGuideStates.waiting_for_description.state,
|
||||
AdminGuideStates.waiting_for_body.state,
|
||||
AdminGuideStates.waiting_for_photos.state,
|
||||
}:
|
||||
await state.clear()
|
||||
|
||||
@@ -494,6 +515,73 @@ def build_router(
|
||||
)
|
||||
return True
|
||||
|
||||
def build_admin_broadcast_preview_keyboard():
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="✅ Отправить всем",
|
||||
callback_data="admin:broadcast_confirm",
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Отменить",
|
||||
callback_data="admin:broadcast_cancel",
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def get_message_html_text(message: Message) -> tuple[str, str]:
|
||||
raw_text = (message.text or message.caption or "").strip()
|
||||
formatted_text = (
|
||||
(getattr(message, "html_text", None) or getattr(message, "html_caption", None) or "").strip()
|
||||
)
|
||||
if not formatted_text and raw_text:
|
||||
formatted_text = html.escape(raw_text)
|
||||
return raw_text, formatted_text
|
||||
|
||||
async def show_admin_broadcast_preview(message: Message, *, text_html: str, state: FSMContext) -> None:
|
||||
recipient_count = len(await sync_service.get_broadcast_telegram_ids())
|
||||
await state.update_data(broadcast_text_html=text_html)
|
||||
await message.answer(
|
||||
"📣 Предпросмотр рассылки.\n\n"
|
||||
f"Получателей: <b>{recipient_count}</b>\n"
|
||||
"Если всё верно, нажмите кнопку подтверждения ниже."
|
||||
)
|
||||
await message.answer(
|
||||
text_html,
|
||||
reply_markup=build_admin_broadcast_preview_keyboard(),
|
||||
)
|
||||
|
||||
async def send_admin_broadcast(bot_instance: Bot, *, text_html: str) -> tuple[int, int, int]:
|
||||
telegram_ids = await sync_service.get_broadcast_telegram_ids()
|
||||
sent_count = 0
|
||||
blocked_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for telegram_id in telegram_ids:
|
||||
try:
|
||||
await bot_instance.send_message(chat_id=telegram_id, text=text_html)
|
||||
sent_count += 1
|
||||
except TelegramRetryAfter as exc:
|
||||
await asyncio.sleep(exc.retry_after + 1)
|
||||
try:
|
||||
await bot_instance.send_message(chat_id=telegram_id, text=text_html)
|
||||
sent_count += 1
|
||||
except TelegramForbiddenError:
|
||||
blocked_count += 1
|
||||
except TelegramBadRequest:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
except TelegramForbiddenError:
|
||||
blocked_count += 1
|
||||
except TelegramBadRequest:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
|
||||
await asyncio.sleep(0.035)
|
||||
|
||||
return sent_count, blocked_count, failed_count
|
||||
|
||||
def admin_settings_callback(action: str, value: str | None = None) -> str:
|
||||
return f"admin:{action}:{value}" if value else f"admin:{action}"
|
||||
|
||||
@@ -688,6 +776,203 @@ def build_router(
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def render_guides_menu_text(*, guides: list[GuideView]) -> str:
|
||||
lines = [
|
||||
"<b>📚 Гайды</b>",
|
||||
"",
|
||||
]
|
||||
if not guides:
|
||||
lines.append("<blockquote>Гайды пока не добавлены.</blockquote>")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("<blockquote>Выберите инструкцию ниже.</blockquote>")
|
||||
for index, guide in enumerate(guides[:10], start=1):
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"<b>{index}. {html.escape(guide.title)}</b>",
|
||||
html.escape(guide.short_description or "Без описания"),
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_guides_menu_keyboard(*, guides: list[GuideView]):
|
||||
builder = InlineKeyboardBuilder()
|
||||
for guide in guides[:10]:
|
||||
builder.button(
|
||||
text=guide.title,
|
||||
callback_data=guides_callback("view", str(guide.id)),
|
||||
)
|
||||
builder.button(
|
||||
text="🏠 Главное меню",
|
||||
callback_data=f"panel:{PANEL_SECTION_HOME}",
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def render_guide_text(*, guide: GuideView) -> str:
|
||||
lines = [
|
||||
f"<b>{html.escape(guide.title)}</b>",
|
||||
]
|
||||
if guide.short_description:
|
||||
lines.extend(["", html.escape(guide.short_description)])
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"<blockquote>{html.escape(guide.body_text)}</blockquote>",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_guide_view_keyboard():
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="⬅️ К гайдам", callback_data=guides_callback("menu"))
|
||||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
async def user_can_open_guides(telegram_id: int) -> bool:
|
||||
if settings.is_admin(telegram_id):
|
||||
return True
|
||||
accesses = await sync_service.get_cached_users_for_telegram(telegram_id)
|
||||
return any(access.record.status.upper() == "ACTIVE" for access in accesses)
|
||||
|
||||
async def send_guide_view(bot: Bot, *, chat_id: int, guide: GuideView) -> None:
|
||||
text = render_guide_text(guide=guide)
|
||||
reply_markup = build_guide_view_keyboard()
|
||||
photo_file_ids = guide.photo_file_ids[:10]
|
||||
|
||||
if not photo_file_ids:
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
return
|
||||
|
||||
if len(photo_file_ids) == 1:
|
||||
if len(text) <= 1024:
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=photo_file_ids[0],
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
return
|
||||
|
||||
await bot.send_photo(chat_id=chat_id, photo=photo_file_ids[0])
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
return
|
||||
|
||||
media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids]
|
||||
if len(text) <= 1024:
|
||||
media[0].caption = text
|
||||
await bot.send_media_group(chat_id=chat_id, media=media)
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="Навигация",
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
return
|
||||
|
||||
await bot.send_media_group(chat_id=chat_id, media=media)
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
|
||||
def render_admin_guides_text(*, guides: list[GuideView]) -> str:
|
||||
lines = [
|
||||
"<b>📚 Гайды</b>",
|
||||
"",
|
||||
"<blockquote>Здесь создаются инструкции, которые увидят пользователи с активной подпиской.</blockquote>",
|
||||
]
|
||||
if not guides:
|
||||
lines.extend(["", "<i>Гайды ещё не созданы.</i>"])
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.extend(["", "<b>Последние гайды</b>"])
|
||||
for guide in guides[:10]:
|
||||
photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото"
|
||||
status_text = "активен" if guide.is_active else "скрыт"
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"<b>{html.escape(guide.title)}</b>\n"
|
||||
f"{html.escape(guide.short_description or 'Без описания')}\n"
|
||||
f"Медиа: <b>{photo_text}</b>\n"
|
||||
f"Статус: <b>{status_text}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def render_admin_guide_delete_confirm_text(*, guide: GuideView) -> str:
|
||||
photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото"
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>🗑 Удалить гайд?</b>",
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"<b>{html.escape(guide.title)}</b>\n"
|
||||
f"{html.escape(guide.short_description or 'Без описания')}\n"
|
||||
f"Медиа: <b>{photo_text}</b>"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>После удаления гайд пропадёт у пользователей, а его фото-ссылки будут удалены из базы.</i>",
|
||||
]
|
||||
)
|
||||
|
||||
def build_admin_guides_keyboard(*, guides: list[GuideView]):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="➕ Создать гайд",
|
||||
callback_data=admin_settings_callback("guide_create"),
|
||||
)
|
||||
for guide in guides[:10]:
|
||||
builder.button(
|
||||
text=f"🗑 {compact_inline_label(guide.title, limit=24)}",
|
||||
callback_data=admin_settings_callback("guide_delete", str(guide.id)),
|
||||
)
|
||||
builder.button(
|
||||
text="↻ Обновить",
|
||||
callback_data=admin_settings_callback("guides"),
|
||||
)
|
||||
builder.button(
|
||||
text="🛠 Админ-панель",
|
||||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def build_admin_guide_delete_confirm_keyboard(*, guide_id: int):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🗑 Да, удалить",
|
||||
callback_data=admin_settings_callback("guide_delete_confirm", str(guide_id)),
|
||||
)
|
||||
builder.button(
|
||||
text="⬅️ К гайдам",
|
||||
callback_data=admin_settings_callback("guides"),
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def build_admin_guide_photos_keyboard(*, photo_count: int):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="✅ Сохранить гайд",
|
||||
callback_data=admin_settings_callback("guide_done"),
|
||||
)
|
||||
builder.button(
|
||||
text=f"Фото добавлено: {photo_count}",
|
||||
callback_data=admin_settings_callback("guide_photo_count"),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Отмена",
|
||||
callback_data=admin_settings_callback("guides"),
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str:
|
||||
lines = [
|
||||
"<b>⚙️ Настройки бота</b>",
|
||||
@@ -1982,6 +2267,88 @@ def build_router(
|
||||
support_text = config.payment_support_text.strip() or "По вопросам оплаты напишите в поддержку."
|
||||
await message.answer(html.escape(support_text))
|
||||
|
||||
@router.message(Command("broadcast"))
|
||||
async def cmd_broadcast(message: Message, command: CommandObject, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await message.answer("Эта команда доступна только администраторам.")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
text = (command.args or "").strip()
|
||||
if not text:
|
||||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||||
await message.answer(
|
||||
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
|
||||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||||
)
|
||||
return
|
||||
|
||||
text_html = html.escape(text)
|
||||
if len(text_html) > 4000:
|
||||
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
|
||||
return
|
||||
|
||||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||||
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
|
||||
|
||||
@router.callback_query(F.data == "guides:menu")
|
||||
async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await clear_interactive_states(state)
|
||||
if not await user_can_open_guides(callback.from_user.id):
|
||||
await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
|
||||
return
|
||||
|
||||
guides = await sync_service.get_guides(active_only=True)
|
||||
await show_branded_view(
|
||||
callback,
|
||||
text=render_guides_menu_text(guides=guides),
|
||||
reply_markup=build_guides_menu_keyboard(guides=guides),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("guides:view:"))
|
||||
async def on_guide_view(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await clear_interactive_states(state)
|
||||
if not await user_can_open_guides(callback.from_user.id):
|
||||
await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
|
||||
return
|
||||
|
||||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||||
try:
|
||||
guide_id = int(raw_guide_id)
|
||||
except ValueError:
|
||||
await callback.answer("Гайд не найден", show_alert=True)
|
||||
return
|
||||
|
||||
guide = await sync_service.get_guide(guide_id, active_only=True)
|
||||
if guide is None:
|
||||
await callback.answer("Гайд не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await send_guide_view(
|
||||
callback.bot,
|
||||
chat_id=callback.from_user.id,
|
||||
guide=guide,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.from_user.id,
|
||||
text=render_guide_text(guide=guide),
|
||||
reply_markup=build_guide_view_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data == "payment:buy")
|
||||
@router.callback_query(F.data == "payment:menu")
|
||||
async def on_payment_menu(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
@@ -2101,11 +2468,16 @@ def build_router(
|
||||
@router.message(SupportTicketStates.waiting_for_note, Command("cancel"))
|
||||
@router.message(SupportTicketAdminStates.waiting_for_note, Command("cancel"))
|
||||
@router.message(ReferralCodeStates.waiting_for_code, Command("cancel"))
|
||||
@router.message(AdminBroadcastStates.waiting_for_text, Command("cancel"))
|
||||
@router.message(AdminConfigStates.waiting_for_value, Command("cancel"))
|
||||
@router.message(AdminPromoStates.waiting_for_code, Command("cancel"))
|
||||
@router.message(AdminPromoStates.waiting_for_plan, Command("cancel"))
|
||||
@router.message(AdminPromoStates.waiting_for_discount, Command("cancel"))
|
||||
@router.message(AdminPromoStates.waiting_for_expires, Command("cancel"))
|
||||
@router.message(AdminGuideStates.waiting_for_title, Command("cancel"))
|
||||
@router.message(AdminGuideStates.waiting_for_description, Command("cancel"))
|
||||
@router.message(AdminGuideStates.waiting_for_body, Command("cancel"))
|
||||
@router.message(AdminGuideStates.waiting_for_photos, Command("cancel"))
|
||||
async def cancel_interactive_flow(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await message.answer("Действие отменено.")
|
||||
@@ -2469,6 +2841,71 @@ def build_router(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data == "admin:broadcast")
|
||||
async def on_admin_broadcast(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None or callback.message is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||||
await callback.message.answer(
|
||||
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
|
||||
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
|
||||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data == "admin:broadcast_cancel")
|
||||
async def on_admin_broadcast_cancel(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await callback.answer("Рассылка отменена")
|
||||
if callback.message is not None:
|
||||
await callback.message.answer("Действие отменено.")
|
||||
|
||||
@router.callback_query(F.data == "admin:broadcast_confirm")
|
||||
async def on_admin_broadcast_confirm(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
text_html = str(state_data.get("broadcast_text_html") or "").strip()
|
||||
if not text_html:
|
||||
await state.clear()
|
||||
await callback.answer("Текст рассылки не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await callback.answer("Рассылка запущена")
|
||||
if callback.message is not None:
|
||||
await callback.message.answer("📣 Рассылка запущена. Пришлю итог после отправки.")
|
||||
|
||||
sent_count, blocked_count, failed_count = await send_admin_broadcast(
|
||||
callback.bot,
|
||||
text_html=text_html,
|
||||
)
|
||||
if callback.message is not None:
|
||||
await callback.message.answer(
|
||||
"✅ Рассылка завершена.\n\n"
|
||||
f"Отправлено: <b>{sent_count}</b>\n"
|
||||
f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n"
|
||||
f"Ошибок отправки: <b>{failed_count}</b>"
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "admin:sync_all")
|
||||
async def on_admin_sync_all(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None or callback.message is None:
|
||||
@@ -2492,6 +2929,141 @@ def build_router(
|
||||
)
|
||||
await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN)
|
||||
|
||||
@router.callback_query(F.data == "admin:guides")
|
||||
async def on_admin_guides(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await clear_interactive_states(state)
|
||||
guides = await sync_service.get_guides(active_only=False)
|
||||
await show_branded_view(
|
||||
callback,
|
||||
text=render_admin_guides_text(guides=guides),
|
||||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data == "admin:guide_create")
|
||||
async def on_admin_guide_create(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None or callback.message is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await state.set_state(AdminGuideStates.waiting_for_title)
|
||||
await callback.message.answer(
|
||||
"📚 Отправьте название гайда одним сообщением.\n\n"
|
||||
"Чтобы отменить создание, отправьте <code>/cancel</code>."
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data == "admin:guide_photo_count")
|
||||
async def on_admin_guide_photo_count(callback: CallbackQuery) -> None:
|
||||
await callback.answer("Отправьте ещё фото или сохраните гайд.", show_alert=True)
|
||||
|
||||
@router.callback_query(F.data == "admin:guide_done")
|
||||
async def on_admin_guide_done(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None or callback.message is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
try:
|
||||
guide = await sync_service.create_guide(
|
||||
title=str(state_data.get("guide_title") or ""),
|
||||
short_description=str(state_data.get("guide_description") or ""),
|
||||
body_text=str(state_data.get("guide_body") or ""),
|
||||
photo_file_ids=list(state_data.get("guide_photos") or []),
|
||||
created_by_telegram_id=callback.from_user.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
guides = await sync_service.get_guides(active_only=False)
|
||||
await show_branded_view(
|
||||
callback,
|
||||
text=(
|
||||
f"✅ Гайд <b>{html.escape(guide.title)}</b> создан.\n\n"
|
||||
+ render_admin_guides_text(guides=guides)
|
||||
),
|
||||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||||
)
|
||||
await callback.answer("Гайд сохранён")
|
||||
|
||||
@router.callback_query(F.data.startswith("admin:guide_delete:"))
|
||||
async def on_admin_guide_delete(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await clear_interactive_states(state)
|
||||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||||
try:
|
||||
guide_id = int(raw_guide_id)
|
||||
except ValueError:
|
||||
await callback.answer("Гайд не найден", show_alert=True)
|
||||
return
|
||||
|
||||
guide = await sync_service.get_guide(guide_id, active_only=False)
|
||||
if guide is None:
|
||||
await callback.answer("Гайд не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await show_branded_view(
|
||||
callback,
|
||||
text=render_admin_guide_delete_confirm_text(guide=guide),
|
||||
reply_markup=build_admin_guide_delete_confirm_keyboard(guide_id=guide.id),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("admin:guide_delete_confirm:"))
|
||||
async def on_admin_guide_delete_confirm(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
await callback.answer()
|
||||
return
|
||||
if not settings.is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
await clear_interactive_states(state)
|
||||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||||
try:
|
||||
guide_id = int(raw_guide_id)
|
||||
except ValueError:
|
||||
await callback.answer("Гайд не найден", show_alert=True)
|
||||
return
|
||||
|
||||
deleted_guide = await sync_service.delete_guide(guide_id)
|
||||
if deleted_guide is None:
|
||||
await callback.answer("Гайд уже удалён", show_alert=True)
|
||||
return
|
||||
|
||||
guides = await sync_service.get_guides(active_only=False)
|
||||
await show_branded_view(
|
||||
callback,
|
||||
text=(
|
||||
f"✅ Гайд <b>{html.escape(deleted_guide.title)}</b> удалён.\n\n"
|
||||
+ render_admin_guides_text(guides=guides)
|
||||
),
|
||||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||||
)
|
||||
await callback.answer("Гайд удалён")
|
||||
|
||||
@router.callback_query(F.data == "admin:promos")
|
||||
async def on_admin_promos(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if callback.from_user is None:
|
||||
@@ -3219,6 +3791,141 @@ def build_router(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.message(AdminGuideStates.waiting_for_title)
|
||||
async def on_admin_guide_title_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
title = (message.text or message.caption or "").strip()
|
||||
if not title:
|
||||
await message.answer("Отправьте название гайда текстом.")
|
||||
return
|
||||
if len(title) > 255:
|
||||
await message.answer("Название должно быть не длиннее 255 символов.")
|
||||
return
|
||||
|
||||
await state.set_state(AdminGuideStates.waiting_for_description)
|
||||
await state.update_data(guide_title=title)
|
||||
await message.answer(
|
||||
"Теперь отправьте короткое описание гайда.\n\n"
|
||||
"Оно будет видно в списке гайдов."
|
||||
)
|
||||
|
||||
@router.message(AdminGuideStates.waiting_for_description)
|
||||
async def on_admin_guide_description_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
description = (message.text or message.caption or "").strip()
|
||||
if not description:
|
||||
await message.answer("Отправьте короткое описание текстом.")
|
||||
return
|
||||
if len(description) > 512:
|
||||
await message.answer("Описание должно быть не длиннее 512 символов.")
|
||||
return
|
||||
|
||||
await state.set_state(AdminGuideStates.waiting_for_body)
|
||||
await state.update_data(guide_description=description)
|
||||
await message.answer(
|
||||
"Теперь отправьте сам гайд одним сообщением.\n\n"
|
||||
"Бот покажет этот текст пользователю как цитату."
|
||||
)
|
||||
|
||||
@router.message(AdminGuideStates.waiting_for_body)
|
||||
async def on_admin_guide_body_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
body_text = (message.text or message.caption or "").strip()
|
||||
if not body_text:
|
||||
await message.answer("Отправьте текст гайда.")
|
||||
return
|
||||
|
||||
await state.set_state(AdminGuideStates.waiting_for_photos)
|
||||
await state.update_data(guide_body=body_text, guide_photos=[])
|
||||
await message.answer(
|
||||
"Теперь отправьте фото для гайда.\n\n"
|
||||
"Можно отправить несколько фото одним альбомом или по одному. "
|
||||
"Когда закончите, нажмите кнопку сохранения или отправьте <code>/done</code>.",
|
||||
reply_markup=build_admin_guide_photos_keyboard(photo_count=0),
|
||||
)
|
||||
|
||||
@router.message(AdminGuideStates.waiting_for_photos, Command("done"))
|
||||
async def on_admin_guide_done_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
try:
|
||||
guide = await sync_service.create_guide(
|
||||
title=str(state_data.get("guide_title") or ""),
|
||||
short_description=str(state_data.get("guide_description") or ""),
|
||||
body_text=str(state_data.get("guide_body") or ""),
|
||||
photo_file_ids=list(state_data.get("guide_photos") or []),
|
||||
created_by_telegram_id=message.from_user.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(html.escape(str(exc)))
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
guides = await sync_service.get_guides(active_only=False)
|
||||
await message.answer(f"✅ Гайд <b>{html.escape(guide.title)}</b> создан.")
|
||||
await message.answer(
|
||||
render_admin_guides_text(guides=guides),
|
||||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||||
)
|
||||
|
||||
@router.message(AdminGuideStates.waiting_for_photos)
|
||||
async def on_admin_guide_photo_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
if not message.photo:
|
||||
await message.answer(
|
||||
"Отправьте фото или завершите создание командой <code>/done</code>.",
|
||||
reply_markup=build_admin_guide_photos_keyboard(
|
||||
photo_count=len((await state.get_data()).get("guide_photos") or []),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
photo_file_ids = list(state_data.get("guide_photos") or [])
|
||||
file_id = message.photo[-1].file_id
|
||||
if file_id not in photo_file_ids:
|
||||
photo_file_ids.append(file_id)
|
||||
await state.update_data(guide_photos=photo_file_ids)
|
||||
await message.answer(
|
||||
f"Фото добавлено: <b>{len(photo_file_ids)}</b>.",
|
||||
reply_markup=build_admin_guide_photos_keyboard(photo_count=len(photo_file_ids)),
|
||||
)
|
||||
|
||||
@router.message(AdminPromoStates.waiting_for_code)
|
||||
async def on_admin_promo_code_message(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
@@ -3362,6 +4069,26 @@ def build_router(
|
||||
if found:
|
||||
await state.clear()
|
||||
|
||||
@router.message(AdminBroadcastStates.waiting_for_text)
|
||||
async def on_admin_broadcast_text(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
await state.clear()
|
||||
return
|
||||
if not settings.is_admin(message.from_user.id):
|
||||
await state.clear()
|
||||
await message.answer("Недостаточно прав.")
|
||||
return
|
||||
|
||||
raw_text, text_html = get_message_html_text(message)
|
||||
if not raw_text:
|
||||
await message.answer("Отправьте текст рассылки одним сообщением.")
|
||||
return
|
||||
if len(text_html) > 4000:
|
||||
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
|
||||
return
|
||||
|
||||
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
|
||||
|
||||
@router.message(AdminConfigStates.waiting_for_value)
|
||||
async def on_admin_setting_value(message: Message, state: FSMContext) -> None:
|
||||
if not await ensure_sender(message):
|
||||
|
||||
@@ -145,6 +145,11 @@ def build_panel_keyboard(
|
||||
text="💳 Подписка",
|
||||
callback_data=_payment_callback("menu"),
|
||||
)
|
||||
if _active_users_count(context.users) > 0:
|
||||
builder.button(
|
||||
text="📚 Гайды",
|
||||
callback_data="guides:menu",
|
||||
)
|
||||
if context.is_admin:
|
||||
builder.button(
|
||||
text="🛠 Админ-панель",
|
||||
@@ -210,6 +215,14 @@ def build_panel_keyboard(
|
||||
text="🎟 Промокоды",
|
||||
callback_data=_admin_callback("promos"),
|
||||
)
|
||||
builder.button(
|
||||
text="📚 Гайды",
|
||||
callback_data=_admin_callback("guides"),
|
||||
)
|
||||
builder.button(
|
||||
text="📣 Рассылка",
|
||||
callback_data=_admin_callback("broadcast"),
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Полная синхронизация",
|
||||
callback_data=_admin_callback("sync_all"),
|
||||
@@ -227,7 +240,7 @@ def build_panel_keyboard(
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(2, 2, 2, 2, 1)
|
||||
builder.adjust(2, 2, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
|
||||
@@ -397,6 +397,7 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -406,7 +407,7 @@ class Settings(BaseSettings):
|
||||
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,
|
||||
|
||||
@@ -108,6 +108,33 @@ class SubscriptionRequestLog(Base):
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class Guide(Base):
|
||||
__tablename__ = "guides"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
short_description: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
body_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
created_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class GuidePhoto(Base):
|
||||
__tablename__ = "guide_photos"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guide_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("guides.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
file_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
BIN
assets/main.png
BIN
assets/main.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 126 KiB |
@@ -95,6 +95,32 @@ CREATE TABLE IF NOT EXISTS subscription_request_logs (
|
||||
INDEX ix_subscription_request_logs_request_at (request_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
short_description VARCHAR(512) NOT NULL DEFAULT '',
|
||||
body_text TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by_telegram_id BIGINT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX ix_guides_is_active (is_active),
|
||||
INDEX ix_guides_created_by_telegram_id (created_by_telegram_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guide_photos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
guide_id INT NOT NULL,
|
||||
file_id VARCHAR(512) NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_guide_photos_guide
|
||||
FOREIGN KEY (guide_id)
|
||||
REFERENCES guides (id)
|
||||
ON DELETE CASCADE,
|
||||
INDEX ix_guide_photos_guide_id (guide_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
public_id VARCHAR(32) NOT NULL UNIQUE,
|
||||
|
||||
Reference in New Issue
Block a user