Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c24c668299 | |||
| 41df40eed3 | |||
| b1975cc3ff | |||
| 369e2c3c36 | |||
| e2ec4b05db | |||
| 7493b13348 | |||
| 0a65aa16cd | |||
| 64a4ece0fe | |||
| 71664cca3e | |||
| b21177f685 | |||
| cde6aa4c26 | |||
| 5264fc0d90 | |||
| 7aa2004f27 | |||
| 40a34d1e55 | |||
| 4204b459e2 | |||
| cbe80e9447 | |||
| 0cfd8d3c7b | |||
| ddfa61c56f | |||
| 7e340bc13a | |||
| f7dd3be27c | |||
| 3ff06ed9d1 | |||
| 08d6a26b23 | |||
| 37e472d78a | |||
| 8cc04e01e9 | |||
| c199d04b91 | |||
| 102d5d0a9c | |||
| 8e7b44811e | |||
| ab1be9943b | |||
| 61b702272b | |||
| 84bce0043d | |||
| f54528f2d5 | |||
| b66911f4f7 | |||
| 352c14331d | |||
| 7ea91f344c | |||
| d2d5b6c823 | |||
| 4405c2287a | |||
| 288a72f739 | |||
| 60391a8b89 | |||
| f2d4da9514 | |||
| 648ad24e6d | |||
| 5d28cd9930 | |||
| 43974d1685 | |||
| 39ccc7fb4a | |||
| d391a46b0e | |||
| 59dd638dd7 | |||
| 3062061270 | |||
| 5b0a64964e | |||
| 70e3517d3a | |||
| 08b64abb03 | |||
| 84a3179974 | |||
| fa56ad8791 | |||
| f584cb78f1 | |||
| ff3ac2b9f3 | |||
| deabcfba28 | |||
| 85775604a8 | |||
| bf9b99818d | |||
| 93a8c182bc | |||
| 7febaf9db2 | |||
| 21583279c6 | |||
| df11e09d3a | |||
| 0b2ebbcdd8 | |||
| 28be0954f9 | |||
| 44164b2948 | |||
| d9d4f171f6 | |||
| 4c73d1d0b6 | |||
| 0bf71971f9 | |||
| ff19fb8950 | |||
| 68a37fcf72 | |||
| 2caf16ccc6 | |||
| b38ee36698 | |||
| 3ee9c3de7b | |||
| 0fce289926 | |||
| b1e7f83d11 | |||
| ff28f82240 | |||
| d5082be405 | |||
| 3e830a32ea | |||
| c8a99ceed7 | |||
| 602ddd4013 | |||
| 33443955c7 | |||
| 8f8b6ac1fe | |||
| 8bb0795b1e | |||
| 0055560d3e | |||
| dcc209deea | |||
| a2cb7527f8 | |||
| 523c6ab69c | |||
| 91220b0b5f | |||
| f87dac8efa | |||
| 44b75be97a |
@@ -161,6 +161,7 @@ TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -211,6 +212,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.4-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.4-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.4-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.4"
|
||||
ARG VERSION="v2.4.5"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -710,6 +710,7 @@ TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -760,6 +761,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ from app.handlers.admin import (
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
pricing as admin_pricing,
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -149,6 +152,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+3
-1
@@ -149,6 +149,7 @@ class Settings(BaseSettings):
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
YOOKASSA_ENABLED: bool = False
|
||||
@@ -158,9 +159,10 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_SBP_ENABLED: bool = False
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
@@ -68,11 +69,67 @@ async def upsert_discount_offer(
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(DiscountOffer.id == offer_id)
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> List[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> int:
|
||||
stmt = select(func.count(DiscountOffer.id))
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
|
||||
result = await db.execute(
|
||||
select(FaqSetting).where(FaqSetting.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
setting = await get_faq_setting(db, language)
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус FAQ для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if setting.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
return await set_faq_enabled(db, language, enabled)
|
||||
|
||||
|
||||
async def get_faq_pages(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> list[FaqPage]:
|
||||
query = select(FaqPage).where(FaqPage.language == language)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(FaqPage.is_active.is_(True))
|
||||
|
||||
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
pages = list(result.scalars().all())
|
||||
return pages
|
||||
|
||||
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
|
||||
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_faq_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
) -> FaqPage:
|
||||
if display_order is None:
|
||||
result = await db.execute(
|
||||
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
display_order = max_order + 1
|
||||
|
||||
page = FaqPage(
|
||||
language=language,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def update_faq_page(
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if display_order is not None:
|
||||
page.display_order = display_order
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
|
||||
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
|
||||
await db.commit()
|
||||
logger.info("🗑️ Страница FAQ %s удалена", page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
db: AsyncSession,
|
||||
pages: Iterable[tuple[int, int]],
|
||||
) -> None:
|
||||
for page_id, order in pages:
|
||||
await db.execute(
|
||||
update(FaqPage)
|
||||
.where(FaqPage.id == page_id)
|
||||
.values(display_order=order, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
|
||||
result = await db.execute(
|
||||
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_privacy_policy(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.content = content or ""
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
async def set_privacy_policy_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if policy.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return policy
|
||||
@@ -52,6 +52,11 @@ async def list_promo_offer_logs(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
offer_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[List[PromoOfferLog], int]:
|
||||
stmt = (
|
||||
select(PromoOfferLog)
|
||||
@@ -63,10 +68,29 @@ async def list_promo_offer_logs(
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
stmt = stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
stmt = stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(PromoOfferLog.id))
|
||||
if user_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return logs, total
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
|
||||
result = await db.execute(
|
||||
select(PublicOffer).where(PublicOffer.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_public_offer(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.content = content or ""
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def set_public_offer_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус публичной оферты для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if offer.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return offer
|
||||
+48
-3
@@ -744,9 +744,9 @@ class Squad(Base):
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
@@ -760,9 +760,54 @@ class ServiceRule(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PrivacyPolicy(Base):
|
||||
__tablename__ = "privacy_policies"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PublicOffer(Base):
|
||||
__tablename__ = "public_offers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqSetting(Base):
|
||||
__tablename__ = "faq_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqPage(Base):
|
||||
__tablename__ = "faq_pages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
display_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
|
||||
@@ -2500,6 +2500,226 @@ async def create_web_api_tokens_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_privacy_policies_table() -> bool:
|
||||
table_exists = await check_table_exists("privacy_policies")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица privacy_policies уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица privacy_policies создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_public_offers_table() -> bool:
|
||||
table_exists = await check_table_exists("public_offers")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица public_offers уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица public_offers создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы public_offers: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_faq_settings_table() -> bool:
|
||||
table_exists = await check_table_exists("faq_settings")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица faq_settings уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица faq_settings создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы faq_settings: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_faq_pages_table() -> bool:
|
||||
table_exists = await check_table_exists("faq_pages")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица faq_pages уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица faq_pages создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы faq_pages: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
@@ -2582,6 +2802,34 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
|
||||
privacy_policies_ready = await create_privacy_policies_table()
|
||||
if privacy_policies_ready:
|
||||
logger.info("✅ Таблица privacy_policies готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей privacy_policies")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===")
|
||||
public_offers_ready = await create_public_offers_table()
|
||||
if public_offers_ready:
|
||||
logger.info("✅ Таблица public_offers готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей public_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===")
|
||||
faq_settings_ready = await create_faq_settings_table()
|
||||
if faq_settings_ready:
|
||||
logger.info("✅ Таблица faq_settings готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей faq_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===")
|
||||
faq_pages_ready = await create_faq_pages_table()
|
||||
if faq_pages_ready:
|
||||
logger.info("✅ Таблица faq_pages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей faq_pages")
|
||||
|
||||
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
|
||||
default_token_ready = await ensure_default_web_api_token()
|
||||
if default_token_ready:
|
||||
@@ -2868,6 +3116,8 @@ async def check_migration_status():
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"server_promo_groups_table": False,
|
||||
"privacy_policies_table": False,
|
||||
"public_offers_table": False,
|
||||
"users_promo_group_column": False,
|
||||
"promo_groups_period_discounts_column": False,
|
||||
"promo_groups_auto_assign_column": False,
|
||||
@@ -2892,6 +3142,8 @@ async def check_migration_status():
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
status["public_offers_table"] = await check_table_exists('public_offers')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
@@ -2941,6 +3193,8 @@ async def check_migration_status():
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"privacy_policies_table": "Таблица политик конфиденциальности",
|
||||
"public_offers_table": "Таблица публичных оферт",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
|
||||
Vendored
+24
-7
@@ -66,20 +66,37 @@ class WebhookServer:
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
host=settings.TRIBUTE_WEBHOOK_HOST,
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
logger.info(
|
||||
"Webhook сервер запущен на %s:%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Tribute webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(
|
||||
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"CryptoBot webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.CRYPTOBOT_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
|
||||
Vendored
+13
-4
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"🎯 YooKassa webhook URL: http://%s:%s%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
|
||||
has_content = bool(policy and policy.content and policy.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
|
||||
"Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
|
||||
"⚠️ Показ политики выключен или текст отсутствует.",
|
||||
)
|
||||
if policy and policy.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
|
||||
"✅ Политика активна и показывается пользователям.",
|
||||
)
|
||||
elif policy and policy.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = policy.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if policy and policy.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_privacy_policy_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ политики конфиденциальности: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_policy.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
|
||||
if updated_policy.is_enabled
|
||||
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if policy and policy.content:
|
||||
preview = policy.content.strip()[:400]
|
||||
if len(policy.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
|
||||
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_privacy_policy_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_privacy_policy)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_privacy_policy_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG",
|
||||
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст политики конфиденциальности (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_SAVED",
|
||||
"✅ Политика конфиденциальности обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not policy or not policy.content or not policy.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
|
||||
"Текст политики пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = policy.content.strip()
|
||||
truncated = False
|
||||
max_length = 3800
|
||||
if len(content) > max_length:
|
||||
content = content[: max_length - 3] + "..."
|
||||
truncated = True
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст политики</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{content}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_privacy_policy.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_management,
|
||||
F.data == "admin_privacy_policy",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_privacy_policy,
|
||||
F.data == "admin_privacy_policy_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_privacy_policy,
|
||||
F.data == "admin_privacy_policy_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_html_help,
|
||||
F.data == "admin_privacy_policy_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_privacy_policy_edit,
|
||||
AdminStates.editing_privacy_policy,
|
||||
)
|
||||
+1177
-65
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PublicOfferService.normalize_language(db_user.language)
|
||||
has_content = bool(offer and offer.content and offer.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
|
||||
"Публичная оферта отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
|
||||
"⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
)
|
||||
if offer and offer.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
|
||||
"✅ Оферта активна и показывается пользователям.",
|
||||
)
|
||||
elif offer and offer.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = offer.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_public_offer_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if offer and offer.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_public_offer_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ публичной оферты: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_offer.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
|
||||
if updated_offer.is_enabled
|
||||
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if offer and offer.content:
|
||||
preview = offer.content.strip()[:400]
|
||||
if len(offer.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
|
||||
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_public_offer_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_public_offer)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
|
||||
"Редактирование оферты отменено.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_public_offer_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG",
|
||||
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PublicOfferService.save_offer(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст публичной оферты (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_SAVED",
|
||||
"✅ Публичная оферта обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not offer or not offer.content or not offer.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = offer.content.strip()
|
||||
max_length = 3800
|
||||
pages = PublicOfferService.split_content_into_pages(
|
||||
content,
|
||||
max_length=max_length,
|
||||
)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
preview = pages[0]
|
||||
truncated = len(pages) > 1
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст оферты</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{preview}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_public_offer.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_public_offer_management,
|
||||
F.data == "admin_public_offer",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_public_offer,
|
||||
F.data == "admin_public_offer_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_public_offer,
|
||||
F.data == "admin_public_offer_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_public_offer,
|
||||
F.data == "admin_public_offer_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_public_offer,
|
||||
F.data == "admin_public_offer_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_public_offer_html_help,
|
||||
F.data == "admin_public_offer_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_public_offer_edit,
|
||||
AdminStates.editing_public_offer,
|
||||
)
|
||||
+454
-1
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
@@ -7,7 +8,11 @@ from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
@@ -21,6 +26,9 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,6 +103,416 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_info_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
|
||||
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
|
||||
faq_enabled = await FaqService.is_enabled(db, db_user.language)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=caption,
|
||||
keyboard=get_info_menu_keyboard(
|
||||
language=db_user.language,
|
||||
show_privacy_policy=privacy_enabled,
|
||||
show_public_offer=public_offer_enabled,
|
||||
show_faq=faq_enabled,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_pages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for index, page in enumerate(pages, start=1):
|
||||
raw_title = (page.title or "").strip()
|
||||
if not raw_title:
|
||||
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
if len(raw_title) > 60:
|
||||
raw_title = f"{raw_title[:57]}..."
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{index}. {raw_title}",
|
||||
callback_data=f"menu_faq_page:{page.id}:1",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
parts = raw_data.split(":")
|
||||
|
||||
page_id = None
|
||||
requested_page = 1
|
||||
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
page_id = int(parts[1])
|
||||
except ValueError:
|
||||
page_id = None
|
||||
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
requested_page = int(parts[2])
|
||||
except ValueError:
|
||||
requested_page = 1
|
||||
|
||||
if not page_id:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
page = await FaqService.get_page(db, page_id, db_user.language)
|
||||
|
||||
if not page or not page.is_active:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content_pages = FaqService.split_content_into_pages(page.content)
|
||||
|
||||
if not content_pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(content_pages)
|
||||
current_page = max(1, min(requested_page, total_pages))
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
|
||||
page_title = (page.title or "").strip()
|
||||
if not page_title:
|
||||
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
title_block = title_template.format(title=html.escape(page_title))
|
||||
|
||||
body = content_pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"FAQ_PAGE_FOOTER",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
parts_to_join = [header, title_block]
|
||||
if body:
|
||||
parts_to_join.append(body)
|
||||
if footer:
|
||||
parts_to_join.append(f"<code>{footer}</code>")
|
||||
|
||||
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def show_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
|
||||
|
||||
if not policy:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE",
|
||||
"Политика конфиденциальности временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_EMPTY_ALERT",
|
||||
"Политика конфиденциальности ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PRIVACY_POLICY_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
offer = await PublicOfferService.get_active_offer(db, db_user.language)
|
||||
|
||||
if not offer:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE",
|
||||
"Публичная оферта временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PublicOfferService.split_content_into_pages(offer.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_EMPTY_ALERT",
|
||||
"Публичная оферта ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PUBLIC_OFFER_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_public_offer:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_public_offer:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -359,6 +777,41 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_info_menu,
|
||||
F.data == "menu_info",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_pages,
|
||||
F.data == "menu_faq",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_page,
|
||||
F.data.startswith("menu_faq_page:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data == "menu_privacy_policy",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data.startswith("menu_privacy_policy:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data == "menu_public_offer",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data.startswith("menu_public_offer:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
|
||||
@@ -82,6 +82,25 @@ logger = logging.getLogger(__name__)
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
|
||||
if not isinstance(template, str):
|
||||
return template
|
||||
|
||||
safe_values = _SafeFormatDict()
|
||||
safe_values.update(values)
|
||||
|
||||
try:
|
||||
return template.format_map(safe_values)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to format template '%s' with values %s", template, values)
|
||||
return template
|
||||
|
||||
|
||||
def _get_addon_discount_percent_for_user(
|
||||
user: Optional[User],
|
||||
category: str,
|
||||
@@ -5304,10 +5323,82 @@ async def claim_discount_offer(
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
success_message = texts.get(
|
||||
success_template = texts.get(
|
||||
"DISCOUNT_CLAIM_SUCCESS",
|
||||
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
|
||||
).format(percent=discount_percent)
|
||||
)
|
||||
|
||||
expires_text = (
|
||||
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
|
||||
)
|
||||
|
||||
format_values: Dict[str, Any] = {"percent": discount_percent}
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
format_values.setdefault("hours", duration_hours)
|
||||
format_values.setdefault("duration_hours", duration_hours)
|
||||
|
||||
if discount_expires_at:
|
||||
format_values.setdefault("expires_at", expires_text)
|
||||
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
|
||||
try:
|
||||
expires_timestamp = int(discount_expires_at.timestamp())
|
||||
except (OverflowError, OSError, ValueError):
|
||||
expires_timestamp = None
|
||||
if expires_timestamp:
|
||||
format_values.setdefault("expires_at_ts", expires_timestamp)
|
||||
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
|
||||
if remaining_hours > 0:
|
||||
format_values.setdefault("expires_in_hours", remaining_hours)
|
||||
|
||||
amount_text = ""
|
||||
if isinstance(extra_data, dict):
|
||||
raw_amount_text = (
|
||||
extra_data.get("amount_text")
|
||||
or extra_data.get("discount_amount_text")
|
||||
or extra_data.get("formatted_amount")
|
||||
)
|
||||
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
|
||||
amount_text = raw_amount_text.strip()
|
||||
else:
|
||||
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
|
||||
if isinstance(raw_amount, (int, float)):
|
||||
amount_text = settings.format_price(int(raw_amount))
|
||||
elif isinstance(raw_amount, str) and raw_amount.strip():
|
||||
amount_text = raw_amount.strip()
|
||||
|
||||
if not amount_text:
|
||||
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
|
||||
maybe_amount = extra_data.get(key)
|
||||
try:
|
||||
amount_value = int(maybe_amount)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if amount_value > 0:
|
||||
amount_text = settings.format_price(amount_value)
|
||||
break
|
||||
|
||||
for key, value in extra_data.items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.isidentifier()
|
||||
and key not in format_values
|
||||
and isinstance(value, (str, int, float))
|
||||
):
|
||||
format_values[key] = value
|
||||
|
||||
if not amount_text:
|
||||
try:
|
||||
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
|
||||
except (TypeError, ValueError):
|
||||
bonus_amount = 0
|
||||
if bonus_amount > 0:
|
||||
amount_text = settings.format_price(bonus_amount)
|
||||
|
||||
if amount_text:
|
||||
format_values.setdefault("amount", amount_text)
|
||||
|
||||
success_message = _format_text_with_placeholders(success_template, format_values)
|
||||
|
||||
await callback.answer("✅ Скидка активирована!", show_alert=True)
|
||||
|
||||
|
||||
@@ -178,6 +178,24 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
|
||||
callback_data="maintenance_panel"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"),
|
||||
callback_data="admin_faq",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
|
||||
]
|
||||
|
||||
+77
-28
@@ -237,41 +237,23 @@ def get_main_menu_keyboard(
|
||||
]
|
||||
])
|
||||
|
||||
# Server status button
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, url=status_url)
|
||||
])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
|
||||
])
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
support_row = []
|
||||
if support_enabled:
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
|
||||
keyboard.append(support_row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
@@ -298,6 +280,73 @@ def get_main_menu_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_info_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
show_privacy_policy: bool = False,
|
||||
show_public_offer: bool = False,
|
||||
show_faq: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if show_faq:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_FAQ", "❓ FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
|
||||
if show_privacy_policy:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
|
||||
callback_data="menu_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
if show_public_offer:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
|
||||
callback_data="menu_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
])
|
||||
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
callback_data="menu_server_status",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
|
||||
if not settings.is_happ_download_button_enabled():
|
||||
return None
|
||||
|
||||
@@ -302,6 +302,9 @@
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -717,5 +720,42 @@
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
|
||||
"MENU_PUBLIC_OFFER": "📄 Offer",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
|
||||
}
|
||||
|
||||
@@ -231,6 +231,19 @@
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -680,6 +693,69 @@
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import (
|
||||
bulk_update_order,
|
||||
create_faq_page,
|
||||
delete_faq_page,
|
||||
get_faq_page_by_id,
|
||||
get_faq_pages,
|
||||
get_faq_setting,
|
||||
set_faq_enabled,
|
||||
update_faq_page,
|
||||
)
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FaqService:
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return FaqService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
) -> Optional[FaqSetting]:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
|
||||
if setting or not fallback:
|
||||
return setting
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_faq_setting(db, default_lang)
|
||||
|
||||
return setting
|
||||
|
||||
@classmethod
|
||||
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
pages = await cls.get_pages(db, language)
|
||||
return bool(pages)
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_faq_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
new_status = True
|
||||
if setting:
|
||||
new_status = not setting.is_enabled
|
||||
return await set_faq_enabled(db, lang, new_status)
|
||||
|
||||
@classmethod
|
||||
async def get_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
fallback: bool = True,
|
||||
) -> List[FaqPage]:
|
||||
lang = cls._normalize_language(language)
|
||||
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
|
||||
|
||||
if pages:
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
return pages
|
||||
|
||||
if not fallback:
|
||||
return []
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang == default_lang:
|
||||
return []
|
||||
|
||||
fallback_pages = await get_faq_pages(
|
||||
db,
|
||||
default_lang,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
if not fallback_pages:
|
||||
return []
|
||||
|
||||
setting = await get_faq_setting(db, default_lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
|
||||
return fallback_pages
|
||||
|
||||
@classmethod
|
||||
async def get_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
include_inactive: bool = False,
|
||||
) -> Optional[FaqPage]:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
return None
|
||||
|
||||
lang = cls._normalize_language(language)
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
|
||||
if not include_inactive and not page.is_active:
|
||||
return None
|
||||
|
||||
if page.language == lang:
|
||||
return page
|
||||
|
||||
if fallback and page.language == default_lang:
|
||||
return page
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def create_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
lang = cls._normalize_language(language)
|
||||
is_active_value = True if is_active is None else bool(is_active)
|
||||
page = await create_faq_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active_value,
|
||||
)
|
||||
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if not setting:
|
||||
await set_faq_enabled(db, lang, True)
|
||||
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
async def update_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
return await update_faq_page(
|
||||
db,
|
||||
page,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
|
||||
await delete_faq_page(db, page_id)
|
||||
|
||||
@classmethod
|
||||
async def reorder_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
pages: List[FaqPage],
|
||||
) -> None:
|
||||
lang = cls._normalize_language(language)
|
||||
ordered = [page for page in pages if page.language == lang]
|
||||
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
|
||||
await bulk_update_order(db, payload)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
limit = max_length or FaqService.MAX_PAGE_LENGTH
|
||||
if len(normalized) <= limit:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= limit:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= limit:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while start < len(paragraph):
|
||||
chunk = paragraph[start : start + limit]
|
||||
pages.append(chunk.strip())
|
||||
start += limit
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:limit]]
|
||||
|
||||
return pages
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.privacy_policy import (
|
||||
get_privacy_policy,
|
||||
set_privacy_policy_enabled,
|
||||
upsert_privacy_policy,
|
||||
)
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivacyPolicyService:
|
||||
"""Utility helpers around privacy policy storage and presentation."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PrivacyPolicyService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy or not fallback:
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_privacy_policy(db, default_lang)
|
||||
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def get_active_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy and policy.is_enabled and policy.content.strip():
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_policy = await get_privacy_policy(db, default_lang)
|
||||
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
|
||||
return fallback_policy
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
policy = await cls.get_active_policy(db, language)
|
||||
return policy is not None
|
||||
|
||||
@classmethod
|
||||
async def save_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
policy = await upsert_privacy_policy(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_privacy_policy_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy:
|
||||
new_status = not policy.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_privacy_policy_enabled(db, lang, new_status)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= max_len:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start_index = 0
|
||||
while start_index < len(paragraph):
|
||||
chunk = paragraph[start_index:start_index + max_len]
|
||||
pages.append(chunk.strip())
|
||||
start_index += max_len
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -0,0 +1,359 @@
|
||||
import logging
|
||||
from html.parser import HTMLParser
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.public_offer import (
|
||||
get_public_offer,
|
||||
set_public_offer_enabled,
|
||||
upsert_public_offer,
|
||||
)
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PublicOfferService:
|
||||
"""Helpers for managing the public offer text and visibility."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PublicOfferService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer or not fallback:
|
||||
return offer
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_public_offer(db, default_lang)
|
||||
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def get_active_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
if offer.is_enabled and offer.content.strip():
|
||||
return offer
|
||||
|
||||
if not offer.is_enabled:
|
||||
return None
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_offer = await get_public_offer(db, default_lang)
|
||||
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
|
||||
return fallback_offer
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
offer = await cls.get_active_offer(db, language)
|
||||
return offer is not None
|
||||
|
||||
@classmethod
|
||||
async def save_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
offer = await upsert_public_offer(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_public_offer_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
new_status = not offer.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_public_offer_enabled(db, lang, new_status)
|
||||
|
||||
class _RichTextPaginator(HTMLParser):
|
||||
"""Split HTML-like text into Telegram-safe chunks."""
|
||||
|
||||
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
|
||||
|
||||
def __init__(self, max_len: int) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.max_len = max_len
|
||||
self.pages: List[str] = []
|
||||
self.current_parts: List[str] = []
|
||||
self.current_length = 0
|
||||
self.open_stack: List[Tuple[str, str, int]] = []
|
||||
self.closing_length = 0
|
||||
self.needs_prefix = False
|
||||
self.prefix_length = 0
|
||||
|
||||
def _closing_sequence(self) -> str:
|
||||
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
|
||||
|
||||
def _ensure_prefix(self) -> None:
|
||||
if not self.needs_prefix:
|
||||
return
|
||||
|
||||
prefix = "".join(token for _, token, _ in self.open_stack)
|
||||
if prefix:
|
||||
self.current_parts.append(prefix)
|
||||
self.current_length += len(prefix)
|
||||
self.needs_prefix = False
|
||||
|
||||
def _flush(self) -> None:
|
||||
if not self.current_parts and not self.closing_length:
|
||||
return
|
||||
|
||||
content = "".join(self.current_parts)
|
||||
closing_tags = self._closing_sequence()
|
||||
page = (content + closing_tags).strip()
|
||||
if page:
|
||||
self.pages.append(page)
|
||||
|
||||
self.current_parts = []
|
||||
self.current_length = 0
|
||||
if self.prefix_length + self.closing_length > self.max_len:
|
||||
self.open_stack = []
|
||||
self.closing_length = 0
|
||||
self.prefix_length = 0
|
||||
self.needs_prefix = False
|
||||
else:
|
||||
self.needs_prefix = bool(self.open_stack)
|
||||
|
||||
@staticmethod
|
||||
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
|
||||
parts = []
|
||||
for name, value in attrs:
|
||||
if value is None:
|
||||
parts.append(f" {name}")
|
||||
else:
|
||||
parts.append(f" {name}=\"{value}\"")
|
||||
return "".join(parts)
|
||||
|
||||
def _append_token(self, token: str) -> None:
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
if self.current_length + len(token) + self.closing_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not data:
|
||||
return
|
||||
|
||||
remaining = data
|
||||
while remaining:
|
||||
self._ensure_prefix()
|
||||
available = self.max_len - (self.current_length + self.closing_length)
|
||||
if available <= 0:
|
||||
self._flush()
|
||||
continue
|
||||
|
||||
piece = remaining[:available]
|
||||
self.current_parts.append(piece)
|
||||
self.current_length += len(piece)
|
||||
remaining = remaining[available:]
|
||||
|
||||
if remaining:
|
||||
self._flush()
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
self.handle_data(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
self.handle_data(f"&#{name};")
|
||||
|
||||
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
token = f"<{tag}{self._format_attrs(attrs)}/>"
|
||||
self._append_token(token)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
if tag in self.SELF_CLOSING_TAGS:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
return
|
||||
|
||||
token = f"<{tag}{self._format_attrs(attrs)}>"
|
||||
closing_token = f"</{tag}>"
|
||||
closing_len = len(closing_token)
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_length = self.current_length + len(token) + self.closing_length + closing_len
|
||||
if projected_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) + closing_len > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
token = f"</{tag}>"
|
||||
closing_len_reduction = 0
|
||||
index_to_remove = None
|
||||
for index in range(len(self.open_stack) - 1, -1, -1):
|
||||
if self.open_stack[index][0] == tag:
|
||||
closing_len_reduction = self.open_stack[index][2]
|
||||
index_to_remove = index
|
||||
break
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_closing_length = self.closing_length - closing_len_reduction
|
||||
if projected_closing_length < 0:
|
||||
projected_closing_length = 0
|
||||
|
||||
projected_total = self.current_length + len(token) + projected_closing_length
|
||||
if projected_total <= self.max_len or not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
if index_to_remove is not None:
|
||||
removed_tag = self.open_stack.pop(index_to_remove)
|
||||
self.closing_length -= closing_len_reduction
|
||||
self.prefix_length -= len(removed_tag[1])
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
|
||||
def finalize(self) -> List[str]:
|
||||
self._flush()
|
||||
return self.pages
|
||||
|
||||
@classmethod
|
||||
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
|
||||
if len(paragraph) <= max_len:
|
||||
return [paragraph]
|
||||
|
||||
paginator = cls._RichTextPaginator(max_len)
|
||||
paginator.feed(paragraph)
|
||||
paginator.close()
|
||||
pages = paginator.finalize()
|
||||
return pages or [paragraph]
|
||||
|
||||
@classmethod
|
||||
def split_content_into_pages(
|
||||
cls,
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or cls.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
segments = cls._split_rich_paragraph(paragraph, max_len)
|
||||
for segment in segments:
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
|
||||
candidate = f"{current}\n\n{segment}".strip() if current else segment
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
current = segment
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -87,6 +87,12 @@ class AdminStates(StatesGroup):
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
editing_public_offer = State()
|
||||
creating_faq_title = State()
|
||||
creating_faq_content = State()
|
||||
editing_faq_title = State()
|
||||
editing_faq_content = State()
|
||||
editing_notification_value = State()
|
||||
|
||||
confirming_sync = State()
|
||||
@@ -113,6 +119,8 @@ class AdminStates(StatesGroup):
|
||||
editing_promo_offer_discount = State()
|
||||
editing_promo_offer_test_duration = State()
|
||||
editing_promo_offer_squads = State()
|
||||
selecting_promo_offer_user = State()
|
||||
searching_promo_offer_user = State()
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
|
||||
@@ -15,6 +15,8 @@ from .routes import (
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
pages,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -58,6 +60,10 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
@@ -73,6 +79,10 @@ OPENAPI_TAGS = [
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -109,6 +119,8 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
app.include_router(backups.router, prefix="/backups", tags=["backups"])
|
||||
|
||||
@@ -2,6 +2,8 @@ from . import (
|
||||
config,
|
||||
health,
|
||||
miniapp,
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
@@ -16,6 +18,8 @@ __all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"miniapp",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
"remnawave",
|
||||
"stats",
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import get_faq_page_by_id
|
||||
from app.database.crud.rules import (
|
||||
clear_all_rules,
|
||||
create_or_update_rules,
|
||||
get_all_rules_versions,
|
||||
get_rules_by_language,
|
||||
restore_rules_version,
|
||||
)
|
||||
from app.database.models import ServiceRule
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.pages import (
|
||||
FaqPageCreateRequest,
|
||||
FaqPageListResponse,
|
||||
FaqPageResponse,
|
||||
FaqPageUpdateRequest,
|
||||
FaqReorderRequest,
|
||||
FaqStatusResponse,
|
||||
FaqStatusUpdateRequest,
|
||||
RichTextPageResponse,
|
||||
RichTextPageUpdateRequest,
|
||||
ServiceRulesHistoryResponse,
|
||||
ServiceRulesResponse,
|
||||
ServiceRulesUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_rich_page(
|
||||
*,
|
||||
requested_language: str,
|
||||
content: str,
|
||||
language: str,
|
||||
is_enabled: Optional[bool],
|
||||
created_at,
|
||||
updated_at,
|
||||
splitter,
|
||||
) -> RichTextPageResponse:
|
||||
pages = splitter(content or "")
|
||||
return RichTextPageResponse(
|
||||
requested_language=requested_language,
|
||||
language=language,
|
||||
is_enabled=is_enabled,
|
||||
content=content or "",
|
||||
content_pages=pages,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_faq_page(page) -> FaqPageResponse:
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
language=page.language,
|
||||
title=page.title,
|
||||
content=page.content,
|
||||
content_pages=FaqService.split_content_into_pages(page.content),
|
||||
display_order=page.display_order,
|
||||
is_active=page.is_active,
|
||||
created_at=page.created_at,
|
||||
updated_at=page.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse:
|
||||
return ServiceRulesResponse(
|
||||
id=rule.id,
|
||||
title=rule.title,
|
||||
content=rule.content,
|
||||
language=rule.language,
|
||||
is_active=rule.is_active,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public-offer", response_model=RichTextPageResponse)
|
||||
async def get_public_offer(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"),
|
||||
include_disabled: bool = Query(
|
||||
True,
|
||||
description="Возвращать контент даже если страница выключена",
|
||||
),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PublicOfferService.normalize_language(language)
|
||||
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found")
|
||||
|
||||
if not include_disabled and not offer.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/public-offer", response_model=RichTextPageResponse)
|
||||
async def update_public_offer(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PublicOfferService.normalize_language(payload.language)
|
||||
offer = await PublicOfferService.save_offer(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
offer = await PublicOfferService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PublicOfferService.get_offer(db, lang, fallback=False)
|
||||
offer = refreshed or offer
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def get_privacy_policy(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
include_disabled: bool = Query(True),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PrivacyPolicyService.normalize_language(language)
|
||||
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found")
|
||||
|
||||
if not include_disabled and not policy.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def update_privacy_policy(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PrivacyPolicyService.normalize_language(payload.language)
|
||||
policy = await PrivacyPolicyService.save_policy(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
policy = await PrivacyPolicyService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PrivacyPolicyService.get_policy(db, lang, fallback=False)
|
||||
policy = refreshed or policy
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq", response_model=FaqPageListResponse)
|
||||
async def list_faq_pages(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqPageListResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
pages = await FaqService.get_pages(
|
||||
db,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
resolved_language = requested_lang
|
||||
if pages:
|
||||
resolved_language = pages[0].language
|
||||
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
is_enabled = bool(setting.is_enabled) if setting else False
|
||||
if setting:
|
||||
resolved_language = setting.language
|
||||
|
||||
serialized = [_serialize_faq_page(page) for page in pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=requested_lang,
|
||||
language=resolved_language,
|
||||
is_enabled=is_enabled,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_faq_page(
|
||||
payload: FaqPageCreateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
is_active = True if payload.is_active is None else payload.is_active
|
||||
|
||||
page = await FaqService.create_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def get_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
) -> FaqPageResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
page = await FaqService.get_page(
|
||||
db,
|
||||
page_id,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.put("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def update_faq_page(
|
||||
page_id: int,
|
||||
payload: FaqPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
updated = await FaqService.update_page(
|
||||
db,
|
||||
page,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(updated)
|
||||
|
||||
|
||||
@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
await FaqService.delete_page(db, page_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/faq/reorder", response_model=FaqPageListResponse)
|
||||
async def reorder_faq_pages(
|
||||
payload: FaqReorderRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageListResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
|
||||
ordered_payload = sorted(payload.items, key=lambda item: item.display_order)
|
||||
|
||||
existing_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
pages_by_id = {page.id: page for page in existing_pages}
|
||||
|
||||
pages: List[Any] = []
|
||||
for item in ordered_payload:
|
||||
page = pages_by_id.get(item.id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
f"FAQ page {item.id} not found for language {lang}",
|
||||
)
|
||||
pages.append(page)
|
||||
|
||||
ordered_ids = {item.id for item in ordered_payload}
|
||||
remaining = [page for page in existing_pages if page.id not in ordered_ids]
|
||||
pages.extend(sorted(remaining, key=lambda page: (page.display_order, page.id)))
|
||||
|
||||
await FaqService.reorder_pages(db, lang, pages)
|
||||
|
||||
updated_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
setting = await FaqService.get_setting(db, lang, fallback=False)
|
||||
serialized = [_serialize_faq_page(page) for page in updated_pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=lang,
|
||||
language=lang,
|
||||
is_enabled=bool(setting.is_enabled) if setting else False,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq/status", response_model=FaqStatusResponse)
|
||||
async def get_faq_status(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqStatusResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not setting:
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=requested_lang,
|
||||
is_enabled=False,
|
||||
)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/faq/status", response_model=FaqStatusResponse)
|
||||
async def update_faq_status(
|
||||
payload: FaqStatusUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqStatusResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def get_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> ServiceRulesResponse:
|
||||
requested_lang = language.split("-")[0].lower()
|
||||
rules = await get_rules_by_language(db, requested_lang)
|
||||
|
||||
if not rules and fallback:
|
||||
default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
|
||||
if default_lang != requested_lang:
|
||||
rules = await get_rules_by_language(db, default_lang)
|
||||
|
||||
if not rules:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found")
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.put("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def update_service_rules(
|
||||
payload: ServiceRulesUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = payload.language.split("-")[0].lower()
|
||||
title = payload.title or "Правила сервиса"
|
||||
rules = await create_or_update_rules(
|
||||
db,
|
||||
content=payload.content,
|
||||
language=lang,
|
||||
title=title,
|
||||
)
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def clear_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> Response:
|
||||
lang = language.split("-")[0].lower()
|
||||
await clear_all_rules(db, lang)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse)
|
||||
async def get_service_rules_history(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
) -> ServiceRulesHistoryResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
history = await get_all_rules_versions(db, lang, limit=limit)
|
||||
items = [_serialize_rules(item) for item in history]
|
||||
return ServiceRulesHistoryResponse(
|
||||
language=lang,
|
||||
total=len(items),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/service-rules/history/{rule_id}/restore",
|
||||
response_model=ServiceRulesResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def restore_service_rules_version(
|
||||
rule_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
restored = await restore_rules_version(db, rule_id, language=lang)
|
||||
if not restored:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found")
|
||||
return _serialize_rules(restored)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
get_offer_by_id,
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_offers import (
|
||||
PromoOfferCreateRequest,
|
||||
PromoOfferListResponse,
|
||||
PromoOfferLogListResponse,
|
||||
PromoOfferLogOfferInfo,
|
||||
PromoOfferLogResponse,
|
||||
PromoOfferResponse,
|
||||
PromoOfferSubscriptionInfo,
|
||||
PromoOfferTemplateListResponse,
|
||||
PromoOfferTemplateResponse,
|
||||
PromoOfferTemplateUpdateRequest,
|
||||
PromoOfferUserInfo,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return PromoOfferUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=getattr(user, "full_name", None),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return PromoOfferSubscriptionInfo(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
||||
return PromoOfferResponse(
|
||||
id=offer.id,
|
||||
user_id=offer.user_id,
|
||||
subscription_id=offer.subscription_id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
effect_type=offer.effect_type,
|
||||
extra_data=offer.extra_data or {},
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
user=_serialize_user(getattr(offer, "user", None)),
|
||||
subscription=_serialize_subscription(getattr(offer, "subscription", None)),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
|
||||
return PromoOfferTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
offer_type=template.offer_type,
|
||||
message_text=template.message_text,
|
||||
button_text=template.button_text,
|
||||
valid_hours=template.valid_hours,
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
||||
active_discount_hours=template.active_discount_hours,
|
||||
test_duration_hours=template.test_duration_hours,
|
||||
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
|
||||
is_active=template.is_active,
|
||||
created_by=template.created_by,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
user_info = _serialize_user(getattr(entry, "user", None))
|
||||
|
||||
offer = getattr(entry, "offer", None)
|
||||
offer_info: Optional[PromoOfferLogOfferInfo] = None
|
||||
if offer:
|
||||
offer_info = PromoOfferLogOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
effect_type=offer.effect_type,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
)
|
||||
|
||||
return PromoOfferLogResponse(
|
||||
id=entry.id,
|
||||
user_id=entry.user_id,
|
||||
offer_id=entry.offer_id,
|
||||
action=entry.action,
|
||||
source=entry.source,
|
||||
percent=entry.percent,
|
||||
effect_type=entry.effect_type,
|
||||
details=entry.details or {},
|
||||
created_at=entry.created_at,
|
||||
user=user_info,
|
||||
offer=offer_info,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
async def list_promo_offers(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
notification_type: Optional[str] = Query(None, min_length=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return PromoOfferListResponse(
|
||||
items=[_serialize_offer(offer) for offer in offers],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_offer(
|
||||
payload: PromoOfferCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
if payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
if payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if not payload.notification_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
user = await db.get(User, payload.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if payload.subscription_id is not None:
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != payload.user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
|
||||
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
|
||||
await db.refresh(offer, attribute_names=["user", "subscription"])
|
||||
|
||||
return _serialize_offer(offer)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=PromoOfferLogListResponse)
|
||||
async def get_promo_offer_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
offer_id: Optional[int] = Query(None, ge=1),
|
||||
action: Optional[str] = Query(None, min_length=1),
|
||||
source: Optional[str] = Query(None, min_length=1),
|
||||
) -> PromoOfferLogListResponse:
|
||||
logs, total = await list_promo_offer_logs(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return PromoOfferLogListResponse(
|
||||
items=[_build_log_response(entry) for entry in logs],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PromoOfferTemplateListResponse)
|
||||
async def list_promo_offer_templates_endpoint(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
templates = await list_promo_offer_templates(db)
|
||||
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def get_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def update_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
|
||||
if payload.valid_hours is not None and payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if payload.active_discount_hours is not None and payload.active_discount_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive")
|
||||
if payload.test_duration_hours is not None and payload.test_duration_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive")
|
||||
if payload.discount_percent is not None and payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
|
||||
if payload.test_squad_uuids is not None:
|
||||
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
|
||||
else:
|
||||
normalized_squads = None
|
||||
|
||||
updated_template = await update_promo_offer_template(
|
||||
db,
|
||||
template,
|
||||
name=payload.name,
|
||||
message_text=payload.message_text,
|
||||
button_text=payload.button_text,
|
||||
valid_hours=payload.valid_hours,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
active_discount_hours=payload.active_discount_hours,
|
||||
test_duration_hours=payload.test_duration_hours,
|
||||
test_squad_uuids=normalized_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_template(updated_template)
|
||||
|
||||
|
||||
@router.get("/{offer_id}", response_model=PromoOfferResponse)
|
||||
async def get_promo_offer_endpoint(
|
||||
offer_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
offer = await get_offer_by_id(db, offer_id)
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found")
|
||||
|
||||
return _serialize_offer(offer)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RichTextPageResponse(BaseModel):
|
||||
"""Generic representation for rich text informational pages."""
|
||||
|
||||
requested_language: str = Field(..., description="Язык, запрошенный клиентом")
|
||||
language: str = Field(..., description="Фактический язык найденной записи")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Текущий статус публикации страницы (если применимо)",
|
||||
)
|
||||
content: str = Field(..., description="Полное содержимое страницы")
|
||||
content_pages: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Содержимое, разбитое на страницы фиксированной длины",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата создания записи",
|
||||
)
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата последнего обновления записи",
|
||||
)
|
||||
|
||||
|
||||
class RichTextPageUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого выполняется обновление",
|
||||
)
|
||||
content: str = Field(..., description="Новое содержимое страницы")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Если указано — обновить статус публикации",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
id: int
|
||||
language: str
|
||||
title: str
|
||||
content: str
|
||||
content_pages: List[str] = Field(default_factory=list)
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FaqPageListResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
total: int
|
||||
items: List[FaqPageResponse]
|
||||
|
||||
|
||||
class FaqPageCreateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык создаваемой страницы",
|
||||
)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(...)
|
||||
display_order: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Порядок отображения (если не указан — будет рассчитан автоматически)",
|
||||
)
|
||||
is_active: Optional[bool] = Field(
|
||||
default=True,
|
||||
description="Начальный статус активности страницы",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
display_order: Optional[int] = Field(default=None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FaqReorderItem(BaseModel):
|
||||
id: int = Field(..., ge=1)
|
||||
display_order: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class FaqReorderRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого применяется сортировка",
|
||||
)
|
||||
items: List[FaqReorderItem]
|
||||
|
||||
|
||||
class FaqStatusResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class FaqStatusUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
)
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class ServiceRulesResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
language: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceRulesUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого обновляются правила",
|
||||
)
|
||||
title: Optional[str] = Field(
|
||||
default="Правила сервиса",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
content: str = Field(...)
|
||||
|
||||
|
||||
class ServiceRulesHistoryResponse(BaseModel):
|
||||
language: str
|
||||
total: int
|
||||
items: List[ServiceRulesResponse]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferSubscriptionInfo(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
autopay_enabled: bool
|
||||
|
||||
|
||||
class PromoOfferResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
notification_type: str
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
expires_at: datetime
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
effect_type: str
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
subscription: Optional[PromoOfferSubscriptionInfo] = None
|
||||
|
||||
|
||||
class PromoOfferListResponse(BaseModel):
|
||||
items: List[PromoOfferResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoOfferCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
bonus_amount_kopeks: int = Field(0, ge=0)
|
||||
subscription_id: Optional[int] = None
|
||||
effect_type: str = Field("percent_discount", min_length=1)
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
offer_type: str
|
||||
message_text: str
|
||||
button_text: str
|
||||
valid_hours: int
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
active_discount_hours: Optional[int] = None
|
||||
test_duration_hours: Optional[int] = None
|
||||
test_squad_uuids: List[str]
|
||||
is_active: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoOfferTemplateListResponse(BaseModel):
|
||||
items: List[PromoOfferTemplateResponse]
|
||||
|
||||
|
||||
class PromoOfferTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
valid_hours: Optional[int] = Field(None, ge=1)
|
||||
discount_percent: Optional[int] = Field(None, ge=0)
|
||||
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
|
||||
active_discount_hours: Optional[int] = Field(None, ge=1)
|
||||
test_duration_hours: Optional[int] = Field(None, ge=1)
|
||||
test_squad_uuids: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
id: int
|
||||
notification_type: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
bonus_amount_kopeks: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
offer_id: Optional[int] = None
|
||||
action: str
|
||||
source: Optional[str] = None
|
||||
percent: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
offer: Optional[PromoOfferLogOfferInfo] = None
|
||||
|
||||
|
||||
class PromoOfferLogListResponse(BaseModel):
|
||||
items: List[PromoOfferLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -127,8 +127,18 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `POST` | `/promo-groups` | Создать промо-группу.
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
|
||||
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
|
||||
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
|
||||
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
|
||||
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
|
||||
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
|
||||
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
|
||||
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
+199
-1
@@ -330,6 +330,24 @@
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Select a topic:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ is temporarily unavailable.",
|
||||
"FAQ_PAGE_UNTITLED": "Untitled",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
|
||||
"FAQ_PAGE_EMPTY": "Content for this page has not been added yet.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Page {current} of {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ Back to FAQ list",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -635,6 +653,67 @@
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Send to user",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Send to a user</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Select a user to deliver the promo offer.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Enter name, username or user ID to search:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Search: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Clear search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ Back to segment selection",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "No matching users found. Adjust your query.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Balance: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Subscription</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Expires: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "not specified",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Traffic: {used}/{limit} GB",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Connected squads: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 No active subscription",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Active discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (until {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (until {date}, {time} left)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — source: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 No active discount",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Selected offer</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Valid for: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Test access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "Active after claim for {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Active offers:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Test access",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "no expiry",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Expires: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Not claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Time left: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "expired",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " After claim: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Total validity: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Offer stats",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Total sent: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Not claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Active now: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Active test accesses:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 No active offers",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Send offer",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ Back to users",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 Back to profile",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Sent to {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Skipped: {skipped} (already has access)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Delivery not performed",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
|
||||
@@ -689,6 +768,37 @@
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Current policy text</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",
|
||||
@@ -952,5 +1062,93 @@
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Important"
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Important",
|
||||
"MENU_PUBLIC_OFFER": "📄 Offer",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ is shown in the “Info” section.",
|
||||
"ADMIN_FAQ_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Total pages: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ FAQ display is turned off.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ is enabled. Active pages: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ is enabled but there are no active pages.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ has not been configured yet.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "No pages have been created yet.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Page list:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Add page",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Enter a title for the new FAQ page:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Cancel",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Title cannot be empty.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Title is too long. Maximum 255 characters.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Send the content of the FAQ page. HTML is allowed.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Text is too long. Maximum 6000 characters.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Text cannot be empty.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ FAQ page created.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ Back to FAQ settings",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ enabled.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ disabled.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Page not found.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>FAQ page</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Disabled",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Updated: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Content has not been provided yet.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Preview:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Title:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Status: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Edit title",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Edit text",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Enable page",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Disable page",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Up",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Down",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Title updated.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ State was reset.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Page text updated.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Page enabled.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Page disabled.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Page deleted.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Order updated.",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
|
||||
}
|
||||
|
||||
+163
-1
@@ -243,6 +243,19 @@
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ временно недоступен.",
|
||||
"FAQ_PAGE_UNTITLED": "Без названия",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
|
||||
"FAQ_PAGE_EMPTY": "Текст для этой страницы ещё не добавлен.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -635,6 +648,67 @@
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Отправка пользователю",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Отправка пользователю</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Выберите пользователя для отправки промопредложения.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Введите имя, username или ID пользователя для поиска:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Поиск: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Сбросить поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ К выбору категории",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "Подходящие пользователи не найдены. Измените запрос поиска.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Баланс: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Подписка</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Истекает: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "не указано",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Трафик: {used}/{limit} ГБ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Подключено сквадов: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 Подписка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Активная скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (до {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (до {date}, осталось {time})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — источник: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 Активная скидка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Выбранное предложение</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Действует: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Тестовый доступ: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "После активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Активные предложения:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Тестовый доступ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "без срока",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Истекает: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Не принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Осталось: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "истекло",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " После активации: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Всего действует: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Статистика предложений",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Всего отправлено: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Не принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Активно сейчас: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Активные тестовые доступы:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 Активных предложений нет",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Отправить предложение",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ К списку пользователей",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 К профилю пользователя",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Отправка пользователю {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Отправка не выполнена",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
|
||||
@@ -952,5 +1026,93 @@
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно"
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ отображается в разделе «Инфо».",
|
||||
"ADMIN_FAQ_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Всего страниц: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ Показ FAQ выключен.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ включён. Активных страниц: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ включён, но нет активных страниц.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ ещё не настроен.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "Страницы ещё не созданы.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Список страниц:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Добавить страницу",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Введите заголовок для новой страницы FAQ:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Отмена",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Заголовок не может быть пустым.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Заголовок слишком длинный. Максимум 255 символов.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Отправьте содержимое страницы FAQ. Допускается HTML.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Текст слишком длинный. Максимум 6000 символов.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Текст не может быть пустым.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ Страница FAQ создана.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ К настройкам FAQ",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ включён.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ отключён.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Страница не найдена.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>Страница FAQ</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Активна",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Выключена",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Обновлено: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Превью:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Заголовок:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Статус: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Изменить заголовок",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Изменить текст",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Включить страницу",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Выключить страницу",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Выше",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Ниже",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Заголовок обновлён.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ Состояние сброшено.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Текст страницы обновлён.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Страница включена.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Страница выключена.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Страница удалена.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Порядок обновлён.",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user