Add notification settings
This commit is contained in:
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
.PHONY: help dev up down build build-no-cache logs logs-bot restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||||
|
|
||||||
DC = sudo docker-compose
|
DC = sudo docker-compose
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ help:
|
|||||||
@echo " make logs - Show logs (all services)"
|
@echo " make logs - Show logs (all services)"
|
||||||
@echo " make logs-b - Show backend logs"
|
@echo " make logs-b - Show backend logs"
|
||||||
@echo " make logs-f - Show frontend logs"
|
@echo " make logs-f - Show frontend logs"
|
||||||
|
@echo " make logs-bot - Show Telegram bot logs"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " Build:"
|
@echo " Build:"
|
||||||
@echo " make build - Build all containers (with cache)"
|
@echo " make build - Build all containers (with cache)"
|
||||||
@@ -63,6 +64,9 @@ logs-b:
|
|||||||
logs-f:
|
logs-f:
|
||||||
$(DC) logs -f frontend
|
$(DC) logs -f frontend
|
||||||
|
|
||||||
|
logs-bot:
|
||||||
|
$(DC) logs -f bot
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
build:
|
build:
|
||||||
$(DC) build
|
$(DC) build
|
||||||
|
|||||||
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add notification settings to users
|
||||||
|
|
||||||
|
Revision ID: 022_add_notification_settings
|
||||||
|
Revises: 021_add_bonus_disputes
|
||||||
|
Create Date: 2025-01-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '022_add_notification_settings'
|
||||||
|
down_revision: Union[str, None] = '021_add_bonus_disputes'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add notification settings (all enabled by default)
|
||||||
|
if not column_exists('users', 'notify_events'):
|
||||||
|
op.add_column('users', sa.Column('notify_events', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
if not column_exists('users', 'notify_disputes'):
|
||||||
|
op.add_column('users', sa.Column('notify_disputes', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
if not column_exists('users', 'notify_moderation'):
|
||||||
|
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'notify_moderation'):
|
||||||
|
op.drop_column('users', 'notify_moderation')
|
||||||
|
if column_exists('users', 'notify_disputes'):
|
||||||
|
op.drop_column('users', 'notify_disputes')
|
||||||
|
if column_exists('users', 'notify_events'):
|
||||||
|
op.drop_column('users', 'notify_events')
|
||||||
@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
|
|||||||
best_streak: int
|
best_streak: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationSettings(BaseModel):
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationSettingsUpdate(BaseModel):
|
||||||
|
notify_events: bool | None = None
|
||||||
|
notify_disputes: bool | None = None
|
||||||
|
notify_moderation: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||||
async def generate_link_token(current_user: CurrentUser):
|
async def generate_link_token(current_user: CurrentUser):
|
||||||
@@ -391,3 +406,46 @@ async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
|||||||
total_points=total_points,
|
total_points=total_points,
|
||||||
best_streak=best_streak
|
best_streak=best_streak
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||||
|
async def get_notification_settings(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get user's notification settings by Telegram ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TelegramNotificationSettings.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||||
|
async def update_notification_settings(
|
||||||
|
telegram_id: int,
|
||||||
|
data: TelegramNotificationSettingsUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
_: BotSecretDep
|
||||||
|
):
|
||||||
|
"""Update user's notification settings by Telegram ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.notify_events is not None:
|
||||||
|
user.notify_events = data.notify_events
|
||||||
|
if data.notify_disputes is not None:
|
||||||
|
user.notify_disputes = data.notify_disputes
|
||||||
|
if data.notify_moderation is not None:
|
||||||
|
user.notify_moderation = data.notify_moderation
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
return TelegramNotificationSettings.model_validate(user)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from app.models.assignment import AssignmentStatus
|
|||||||
from app.models.marathon import MarathonStatus
|
from app.models.marathon import MarathonStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||||
PasswordChange, UserStats, UserProfilePublic,
|
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
|
||||||
|
NotificationSettingsUpdate,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
@@ -189,6 +190,32 @@ async def change_password(
|
|||||||
return MessageResponse(message="Пароль успешно изменен")
|
return MessageResponse(message="Пароль успешно изменен")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/notifications", response_model=NotificationSettings)
|
||||||
|
async def get_notification_settings(current_user: CurrentUser):
|
||||||
|
"""Get current user's notification settings"""
|
||||||
|
return NotificationSettings.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/notifications", response_model=NotificationSettings)
|
||||||
|
async def update_notification_settings(
|
||||||
|
data: NotificationSettingsUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update current user's notification settings"""
|
||||||
|
if data.notify_events is not None:
|
||||||
|
current_user.notify_events = data.notify_events
|
||||||
|
if data.notify_disputes is not None:
|
||||||
|
current_user.notify_disputes = data.notify_disputes
|
||||||
|
if data.notify_moderation is not None:
|
||||||
|
current_user.notify_moderation = data.notify_moderation
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return NotificationSettings.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=UserStats)
|
@router.get("/me/stats", response_model=UserStats)
|
||||||
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
||||||
"""Получить свою статистику"""
|
"""Получить свою статистику"""
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ class User(Base):
|
|||||||
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Notification settings (all enabled by default)
|
||||||
|
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from app.schemas.user import (
|
|||||||
PasswordChange,
|
PasswordChange,
|
||||||
UserStats,
|
UserStats,
|
||||||
UserProfilePublic,
|
UserProfilePublic,
|
||||||
|
NotificationSettings,
|
||||||
|
NotificationSettingsUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.marathon import (
|
from app.schemas.marathon import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -115,6 +117,8 @@ __all__ = [
|
|||||||
"PasswordChange",
|
"PasswordChange",
|
||||||
"UserStats",
|
"UserStats",
|
||||||
"UserProfilePublic",
|
"UserProfilePublic",
|
||||||
|
"NotificationSettings",
|
||||||
|
"NotificationSettingsUpdate",
|
||||||
# Marathon
|
# Marathon
|
||||||
"MarathonCreate",
|
"MarathonCreate",
|
||||||
"MarathonUpdate",
|
"MarathonUpdate",
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ class UserPrivate(UserPublic):
|
|||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
telegram_first_name: str | None = None
|
telegram_first_name: str | None = None
|
||||||
telegram_last_name: str | None = None
|
telegram_last_name: str | None = None
|
||||||
|
# Notification settings
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
@@ -83,3 +87,20 @@ class UserProfilePublic(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettings(BaseModel):
|
||||||
|
"""Notification settings for Telegram bot"""
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsUpdate(BaseModel):
|
||||||
|
"""Update notification settings"""
|
||||||
|
notify_events: bool | None = None
|
||||||
|
notify_disputes: bool | None = None
|
||||||
|
notify_moderation: bool | None = None
|
||||||
|
|||||||
@@ -83,9 +83,15 @@ class TelegramNotifier:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
marathon_id: int,
|
marathon_id: int,
|
||||||
message: str,
|
message: str,
|
||||||
exclude_user_id: int | None = None
|
exclude_user_id: int | None = None,
|
||||||
|
check_setting: str | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Send notification to all marathon participants with linked Telegram."""
|
"""Send notification to all marathon participants with linked Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
check_setting: If provided, only send to users with this setting enabled.
|
||||||
|
Options: 'notify_events', 'notify_disputes', 'notify_moderation'
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User)
|
select(User)
|
||||||
.join(Participant, Participant.user_id == User.id)
|
.join(Participant, Participant.user_id == User.id)
|
||||||
@@ -100,6 +106,10 @@ class TelegramNotifier:
|
|||||||
for user in users:
|
for user in users:
|
||||||
if exclude_user_id and user.id == exclude_user_id:
|
if exclude_user_id and user.id == exclude_user_id:
|
||||||
continue
|
continue
|
||||||
|
# Check notification setting if specified
|
||||||
|
if check_setting and not getattr(user, check_setting, True):
|
||||||
|
logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled")
|
||||||
|
continue
|
||||||
if await self.send_message(user.telegram_id, message):
|
if await self.send_message(user.telegram_id, message):
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
||||||
@@ -113,7 +123,7 @@ class TelegramNotifier:
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
marathon_title: str
|
marathon_title: str
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify participants about event start."""
|
"""Notify participants about event start (respects notify_events setting)."""
|
||||||
event_messages = {
|
event_messages = {
|
||||||
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
||||||
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
||||||
@@ -128,7 +138,9 @@ class TelegramNotifier:
|
|||||||
f"📌 Новое событие в «{marathon_title}»!"
|
f"📌 Новое событие в «{marathon_title}»!"
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
return await self.notify_marathon_participants(
|
||||||
|
db, marathon_id, message, check_setting='notify_events'
|
||||||
|
)
|
||||||
|
|
||||||
async def notify_event_end(
|
async def notify_event_end(
|
||||||
self,
|
self,
|
||||||
@@ -137,7 +149,7 @@ class TelegramNotifier:
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
marathon_title: str
|
marathon_title: str
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify participants about event end."""
|
"""Notify participants about event end (respects notify_events setting)."""
|
||||||
event_names = {
|
event_names = {
|
||||||
"golden_hour": "Golden Hour",
|
"golden_hour": "Golden Hour",
|
||||||
"jackpot": "Jackpot",
|
"jackpot": "Jackpot",
|
||||||
@@ -150,7 +162,9 @@ class TelegramNotifier:
|
|||||||
event_name = event_names.get(event_type, "Событие")
|
event_name = event_names.get(event_type, "Событие")
|
||||||
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
||||||
|
|
||||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
return await self.notify_marathon_participants(
|
||||||
|
db, marathon_id, message, check_setting='notify_events'
|
||||||
|
)
|
||||||
|
|
||||||
async def notify_marathon_start(
|
async def notify_marathon_start(
|
||||||
self,
|
self,
|
||||||
@@ -186,7 +200,14 @@ class TelegramNotifier:
|
|||||||
challenge_title: str,
|
challenge_title: str,
|
||||||
assignment_id: int
|
assignment_id: int
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user about dispute raised on their assignment."""
|
"""Notify user about dispute raised on their assignment (respects notify_disputes setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_disputes:
|
||||||
|
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
||||||
|
|
||||||
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
||||||
@@ -227,7 +248,14 @@ class TelegramNotifier:
|
|||||||
challenge_title: str,
|
challenge_title: str,
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user about dispute resolution."""
|
"""Notify user about dispute resolution (respects notify_disputes setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_disputes:
|
||||||
|
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
||||||
@@ -251,7 +279,14 @@ class TelegramNotifier:
|
|||||||
marathon_title: str,
|
marathon_title: str,
|
||||||
game_title: str
|
game_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed game was approved."""
|
"""Notify user that their proposed game was approved (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -267,7 +302,14 @@ class TelegramNotifier:
|
|||||||
marathon_title: str,
|
marathon_title: str,
|
||||||
game_title: str
|
game_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed game was rejected."""
|
"""Notify user that their proposed game was rejected (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -284,7 +326,14 @@ class TelegramNotifier:
|
|||||||
game_title: str,
|
game_title: str,
|
||||||
challenge_title: str
|
challenge_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed challenge was approved."""
|
"""Notify user that their proposed challenge was approved (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -302,7 +351,14 @@ class TelegramNotifier:
|
|||||||
game_title: str,
|
game_title: str,
|
||||||
challenge_title: str
|
challenge_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed challenge was rejected."""
|
"""Notify user that their proposed challenge was rejected (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aiogram.filters import Command
|
|||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
from keyboards.main_menu import get_main_menu
|
from keyboards.main_menu import get_main_menu
|
||||||
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
|
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Get current notification settings
|
||||||
|
settings = await api_client.get_notification_settings(message.from_user.id)
|
||||||
|
if not settings:
|
||||||
|
settings = {"notify_events": True, "notify_disputes": True, "notify_moderation": True}
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"<b>⚙️ Настройки</b>\n\n"
|
"<b>⚙️ Настройки уведомлений</b>\n\n"
|
||||||
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
|
"Нажми на категорию, чтобы включить/выключить:\n\n"
|
||||||
"Сейчас ты получаешь все уведомления:\n"
|
"✅ — уведомления включены\n"
|
||||||
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
|
"❌ — уведомления выключены\n\n"
|
||||||
"• 🚀 Старт/финиш марафонов\n"
|
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
|
||||||
"• ⚠️ Споры по заданиям\n\n"
|
reply_markup=get_settings_keyboard(settings)
|
||||||
"Команды:\n"
|
)
|
||||||
"/unlink - Отвязать аккаунт\n"
|
|
||||||
"/status - Проверить привязку",
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle:"))
|
||||||
|
async def toggle_notification(callback: CallbackQuery):
|
||||||
|
"""Toggle notification setting."""
|
||||||
|
setting_name = callback.data.split(":")[1]
|
||||||
|
|
||||||
|
# Get current settings
|
||||||
|
current_settings = await api_client.get_notification_settings(callback.from_user.id)
|
||||||
|
if not current_settings:
|
||||||
|
await callback.answer("Не удалось загрузить настройки", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Toggle the setting
|
||||||
|
current_value = current_settings.get(setting_name, True)
|
||||||
|
new_value = not current_value
|
||||||
|
|
||||||
|
# Update on backend
|
||||||
|
result = await api_client.update_notification_settings(
|
||||||
|
callback.from_user.id,
|
||||||
|
{setting_name: new_value}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result or result.get("error"):
|
||||||
|
await callback.answer("Не удалось сохранить настройки", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update keyboard with new values
|
||||||
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=get_settings_keyboard(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "включены" if new_value else "выключены"
|
||||||
|
setting_names = {
|
||||||
|
"notify_events": "События",
|
||||||
|
"notify_disputes": "Споры",
|
||||||
|
"notify_moderation": "Модерация"
|
||||||
|
}
|
||||||
|
await callback.answer(f"{setting_names.get(setting_name, setting_name)}: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_menu")
|
||||||
|
async def back_to_menu(callback: CallbackQuery):
|
||||||
|
"""Go back to main menu from settings."""
|
||||||
|
await callback.message.delete()
|
||||||
|
await callback.message.answer(
|
||||||
|
"Главное меню",
|
||||||
reply_markup=get_main_menu()
|
reply_markup=get_main_menu()
|
||||||
)
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|||||||
@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
|
|||||||
]
|
]
|
||||||
|
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_keyboard(settings: dict) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for notification settings."""
|
||||||
|
# Get current values with defaults
|
||||||
|
notify_events = settings.get("notify_events", True)
|
||||||
|
notify_disputes = settings.get("notify_disputes", True)
|
||||||
|
notify_moderation = settings.get("notify_moderation", True)
|
||||||
|
|
||||||
|
# Status indicators
|
||||||
|
events_status = "✅" if notify_events else "❌"
|
||||||
|
disputes_status = "✅" if notify_disputes else "❌"
|
||||||
|
moderation_status = "✅" if notify_moderation else "❌"
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{events_status} События (Golden Hour, Jackpot...)",
|
||||||
|
callback_data="toggle:notify_events"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{disputes_status} Споры",
|
||||||
|
callback_data="toggle:notify_disputes"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{moderation_status} Модерация (игры/челленджи)",
|
||||||
|
callback_data="toggle:notify_moderation"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="◀️ Назад",
|
||||||
|
callback_data="back_to_menu"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ class APIClient:
|
|||||||
"""Get user's overall statistics."""
|
"""Get user's overall statistics."""
|
||||||
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
||||||
|
|
||||||
|
async def get_notification_settings(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user's notification settings."""
|
||||||
|
return await self._request("GET", f"/telegram/notifications/{telegram_id}")
|
||||||
|
|
||||||
|
async def update_notification_settings(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
settings: dict[str, bool]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update user's notification settings."""
|
||||||
|
return await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/telegram/notifications/{telegram_id}",
|
||||||
|
json=settings
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the HTTP session."""
|
"""Close the HTTP session."""
|
||||||
if self._session and not self._session.closed:
|
if self._session and not self._session.closed:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
|
import type { User, UserProfilePublic, UserStats, PasswordChangeData, NotificationSettings, NotificationSettingsUpdate } from '@/types'
|
||||||
|
|
||||||
export interface UpdateNicknameData {
|
export interface UpdateNicknameData {
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -48,4 +48,16 @@ export const usersApi = {
|
|||||||
})
|
})
|
||||||
return URL.createObjectURL(response.data)
|
return URL.createObjectURL(response.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Получить настройки уведомлений
|
||||||
|
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||||
|
const response = await client.get<NotificationSettings>('/users/me/notifications')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить настройки уведомлений
|
||||||
|
updateNotificationSettings: async (data: NotificationSettingsUpdate): Promise<NotificationSettings> => {
|
||||||
|
const response = await client.patch<NotificationSettings>('/users/me/notifications', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||||
Eye, EyeOff, Save, KeyRound, Shield
|
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||||
|
AlertTriangle, FileCheck
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Schemas
|
// Schemas
|
||||||
@@ -51,6 +52,12 @@ export function ProfilePage() {
|
|||||||
const [isPolling, setIsPolling] = useState(false)
|
const [isPolling, setIsPolling] = useState(false)
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Notification settings state
|
||||||
|
const [notifyEvents, setNotifyEvents] = useState(user?.notify_events ?? true)
|
||||||
|
const [notifyDisputes, setNotifyDisputes] = useState(user?.notify_disputes ?? true)
|
||||||
|
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
||||||
|
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
@@ -265,6 +272,29 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update notification setting
|
||||||
|
const handleNotificationToggle = async (
|
||||||
|
setting: 'notify_events' | 'notify_disputes' | 'notify_moderation',
|
||||||
|
currentValue: boolean,
|
||||||
|
setValue: (value: boolean) => void
|
||||||
|
) => {
|
||||||
|
setNotificationUpdating(setting)
|
||||||
|
const newValue = !currentValue
|
||||||
|
setValue(newValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usersApi.updateNotificationSettings({ [setting]: newValue })
|
||||||
|
updateUser({ [setting]: newValue })
|
||||||
|
toast.success('Настройки сохранены')
|
||||||
|
} catch {
|
||||||
|
// Revert on error
|
||||||
|
setValue(currentValue)
|
||||||
|
toast.error('Не удалось сохранить настройки')
|
||||||
|
} finally {
|
||||||
|
setNotificationUpdating(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isLinked = !!user?.telegram_id
|
const isLinked = !!user?.telegram_id
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
@@ -544,6 +574,109 @@ export function ProfilePage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{isLinked && (
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Bell className="w-6 h-6 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Уведомления</h2>
|
||||||
|
<p className="text-sm text-gray-400">Настройте типы уведомлений в Telegram</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Events toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_events', notifyEvents, setNotifyEvents)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">События</p>
|
||||||
|
<p className="text-sm text-gray-400">Golden Hour, Jackpot, Double Risk и др.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyEvents ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_events' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyEvents ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Disputes toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_disputes', notifyDisputes, setNotifyDisputes)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">Споры</p>
|
||||||
|
<p className="text-sm text-gray-400">Оспаривания заданий и их решения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyDisputes ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_disputes' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyDisputes ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Moderation toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_moderation', notifyModeration, setNotifyModeration)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||||
|
<FileCheck className="w-5 h-5 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">Модерация</p>
|
||||||
|
<p className="text-sm text-gray-400">Одобрение/отклонение игр и челленджей</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyModeration ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_moderation' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyModeration ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info about mandatory notifications */}
|
||||||
|
<p className="text-xs text-gray-500 mt-4">
|
||||||
|
Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ export interface User extends UserPublic {
|
|||||||
telegram_username?: string | null // Only visible to self
|
telegram_username?: string | null // Only visible to self
|
||||||
telegram_first_name?: string | null // Only visible to self
|
telegram_first_name?: string | null // Only visible to self
|
||||||
telegram_last_name?: string | null // Only visible to self
|
telegram_last_name?: string | null // Only visible to self
|
||||||
|
// Notification settings
|
||||||
|
notify_events?: boolean
|
||||||
|
notify_disputes?: boolean
|
||||||
|
notify_moderation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification settings
|
||||||
|
export interface NotificationSettings {
|
||||||
|
notify_events: boolean
|
||||||
|
notify_disputes: boolean
|
||||||
|
notify_moderation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettingsUpdate {
|
||||||
|
notify_events?: boolean
|
||||||
|
notify_disputes?: boolean
|
||||||
|
notify_moderation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user