557 lines
24 KiB
Python
557 lines
24 KiB
Python
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, update, delete
|
||
from sqlalchemy.orm import selectinload
|
||
from .models import User, Lottery, Participation, Winner, Account
|
||
from typing import List, Optional, Dict, Any
|
||
from ..utils.account_utils import validate_account_number, format_account_number
|
||
import random
|
||
|
||
|
||
class UserService:
|
||
"""Сервис для работы с пользователями"""
|
||
|
||
@staticmethod
|
||
async def get_or_create_user(session, telegram_id, username=None, first_name=None, last_name=None, nickname=None):
|
||
"""Upsert without racing or erasing metadata when only an ID is supplied."""
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||
values = {key: value for key, value in dict(username=username, first_name=first_name,
|
||
last_name=last_name, nickname=nickname).items() if value is not None}
|
||
stmt = insert(User).values(telegram_id=telegram_id, **values)
|
||
if values:
|
||
stmt = stmt.on_conflict_do_update(index_elements=[User.telegram_id], set_=values)
|
||
else:
|
||
stmt = stmt.on_conflict_do_nothing(index_elements=[User.telegram_id])
|
||
await session.execute(stmt)
|
||
await session.commit()
|
||
return (await session.scalars(select(User).options(selectinload(User.accounts))
|
||
.where(User.telegram_id == telegram_id).execution_options(populate_existing=True))).one()
|
||
|
||
@staticmethod
|
||
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
||
"""Получить пользователя по Telegram ID"""
|
||
result = await session.execute(
|
||
select(User).where(User.telegram_id == telegram_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||
"""Получить пользователя по ID"""
|
||
result = await session.execute(select(User).where(User.id == user_id))
|
||
return result.scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||
"""Получить пользователя по username"""
|
||
result = await session.execute(select(User).where(User.username == username))
|
||
return result.scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
async def get_all_users(session: AsyncSession, limit: int = None, offset: int = 0) -> List[User]:
|
||
"""Получить всех пользователей"""
|
||
query = select(User).order_by(User.created_at.desc())
|
||
|
||
if limit:
|
||
query = query.offset(offset).limit(limit)
|
||
|
||
result = await session.execute(query)
|
||
return result.scalars().all()
|
||
|
||
@staticmethod
|
||
async def search_users(session: AsyncSession, search_term: str, limit: int = 20) -> List[User]:
|
||
"""Поиск пользователей по имени или username"""
|
||
from sqlalchemy import or_, func
|
||
|
||
search_pattern = f"%{search_term.lower()}%"
|
||
|
||
result = await session.execute(
|
||
select(User).where(
|
||
or_(
|
||
func.lower(User.first_name).contains(search_pattern),
|
||
func.lower(User.last_name).contains(search_pattern) if User.last_name else False,
|
||
func.lower(User.username).contains(search_pattern) if User.username else False
|
||
)
|
||
).limit(limit)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
@staticmethod
|
||
async def delete_user(session: AsyncSession, user_id: int) -> bool:
|
||
"""Удалить пользователя и все связанные данные"""
|
||
user = await session.scalar(select(User).where(User.id == user_id).with_for_update()
|
||
.execution_options(populate_existing=True))
|
||
if not user:
|
||
return False
|
||
|
||
from .config import ADMIN_IDS, CASHIER_IDS
|
||
if user.is_admin or user.is_cashier or user.telegram_id in set(ADMIN_IDS) | set(CASHIER_IDS):
|
||
return False
|
||
|
||
# Keep records needed by draws, claims, chat history, or staff audit trails.
|
||
# Cleanup may delete only users without linked data; moderation uses bans.
|
||
from .database import Base
|
||
for table in Base.metadata.sorted_tables:
|
||
for foreign_key in table.foreign_keys:
|
||
if foreign_key.column.table.name == "users":
|
||
if await session.scalar(select(table.c.id).where(foreign_key.parent == user_id).limit(1)):
|
||
return False
|
||
# Удаляем все участия
|
||
await session.execute(
|
||
delete(Participation).where(Participation.user_id == user_id)
|
||
)
|
||
|
||
# Удаляем все победы
|
||
await session.execute(
|
||
delete(Winner).where(Winner.user_id == user_id)
|
||
)
|
||
|
||
# Удаляем пользователя
|
||
await session.delete(user)
|
||
await session.commit()
|
||
return True
|
||
|
||
@staticmethod
|
||
async def set_account_number(session, telegram_id, account_number):
|
||
from sqlalchemy.exc import IntegrityError
|
||
formatted = format_account_number(account_number)
|
||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||
if not formatted or not user:
|
||
return False
|
||
if await session.scalar(select(Account.id).where(Account.account_number == formatted)):
|
||
return False
|
||
session.add(Account(account_number=formatted, owner_id=user.id))
|
||
try:
|
||
await session.commit()
|
||
except IntegrityError:
|
||
await session.rollback()
|
||
return False
|
||
return True
|
||
|
||
@staticmethod
|
||
async def get_user_by_account(session, account_number):
|
||
formatted = format_account_number(account_number)
|
||
if not formatted:
|
||
return None
|
||
return await session.scalar(select(User).join(Account).options(selectinload(User.accounts)).where(
|
||
Account.account_number == formatted, Account.is_active.is_(True)))
|
||
|
||
@staticmethod
|
||
async def get_user_by_club_card(session, club_card_number):
|
||
return await session.scalar(select(User).where(User.club_card_number == club_card_number))
|
||
|
||
@staticmethod
|
||
async def search_by_account(session, account_pattern):
|
||
clean = ''.join(c for c in account_pattern if c in '0123456789-')
|
||
if not clean:
|
||
return []
|
||
return list((await session.scalars(select(User).join(Account).distinct()
|
||
.where(Account.account_number.contains(clean, autoescape=True)).limit(20))).all())
|
||
|
||
|
||
class LotteryService:
|
||
"""Сервис для работы с розыгрышами"""
|
||
|
||
@staticmethod
|
||
async def create_lottery(session: AsyncSession, title: str, description: str,
|
||
prizes: List[str], creator_id: int) -> Lottery:
|
||
"""Создать новый розыгрыш"""
|
||
lottery = Lottery(
|
||
title=title,
|
||
description=description,
|
||
prizes=prizes,
|
||
creator_id=creator_id
|
||
)
|
||
session.add(lottery)
|
||
await session.commit()
|
||
await session.refresh(lottery)
|
||
return lottery
|
||
|
||
@staticmethod
|
||
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
|
||
"""Получить розыгрыш по ID"""
|
||
result = await session.execute(
|
||
select(Lottery)
|
||
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
||
.where(Lottery.id == lottery_id)
|
||
.execution_options(populate_existing=True)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
async def get_active_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
|
||
"""Получить список активных розыгрышей"""
|
||
query = select(Lottery).where(
|
||
Lottery.is_active == True,
|
||
Lottery.is_completed == False
|
||
).order_by(Lottery.created_at.desc())
|
||
|
||
if limit:
|
||
query = query.limit(limit)
|
||
|
||
result = await session.execute(query)
|
||
return result.scalars().all()
|
||
|
||
@staticmethod
|
||
async def update_lottery(
|
||
session: AsyncSession,
|
||
lottery_id: int,
|
||
**updates
|
||
) -> bool:
|
||
"""Обновить данные розыгрыша"""
|
||
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
|
||
return False
|
||
try:
|
||
result = await session.execute(
|
||
update(Lottery)
|
||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||
.values(**updates)
|
||
)
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
except Exception:
|
||
await session.rollback()
|
||
return False
|
||
|
||
@staticmethod
|
||
async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
|
||
"""Получить список всех розыгрышей"""
|
||
query = select(Lottery).order_by(Lottery.created_at.desc())
|
||
|
||
if limit:
|
||
query = query.limit(limit)
|
||
|
||
result = await session.execute(query)
|
||
return result.scalars().all()
|
||
|
||
@staticmethod
|
||
async def set_manual_winner(session, lottery_id, place, telegram_id):
|
||
from .transactions import lock_open_lottery
|
||
if not await lock_open_lottery(session, lottery_id):
|
||
await session.rollback()
|
||
return False
|
||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||
if not user or place < 1 or place > len(lottery.prizes or [None]):
|
||
await session.rollback()
|
||
return False
|
||
if not any(p.user_id == user.id for p in lottery.participations):
|
||
await session.rollback()
|
||
return False
|
||
# Reassign JSON: in-place edits are not tracked by a plain JSON column.
|
||
winners = dict(lottery.manual_winners or {})
|
||
if telegram_id in (value for key, value in winners.items() if key != str(place)):
|
||
await session.rollback()
|
||
return False
|
||
winners[str(place)] = telegram_id
|
||
lottery.manual_winners = winners
|
||
await session.commit()
|
||
return True
|
||
|
||
@staticmethod
|
||
async def conduct_draw(session, lottery_id):
|
||
"""Select and persist winners once, in one transaction, before notifications."""
|
||
from types import SimpleNamespace
|
||
from datetime import datetime, timezone
|
||
from .transactions import lock_open_lottery
|
||
from .models import WinnerVerification
|
||
if not await lock_open_lottery(session, lottery_id):
|
||
await session.rollback()
|
||
return {}
|
||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||
participations = list(lottery.participations)
|
||
if not participations:
|
||
await session.rollback()
|
||
return {}
|
||
num_prizes = len(lottery.prizes or [None])
|
||
existing = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id))).all())
|
||
if any(w.is_claimed for w in existing):
|
||
raise ValueError("В розыгрыше уже есть выданные призы")
|
||
selected = {}
|
||
available = participations.copy()
|
||
for place in range(1, num_prizes + 1):
|
||
preset = next((w for w in existing if w.place == place and w.is_manual), None)
|
||
manual = (lottery.manual_winners or {}).get(str(place))
|
||
candidate = next((p for p in available if
|
||
(preset and preset.account_number and p.account_number == preset.account_number) or
|
||
(manual and p.user and p.user.telegram_id == manual)), None)
|
||
if candidate:
|
||
selected[place] = (candidate, True)
|
||
available.remove(candidate)
|
||
rng = random.SystemRandom()
|
||
for place in range(1, num_prizes + 1):
|
||
if place not in selected and available:
|
||
candidate = rng.choice(available)
|
||
available.remove(candidate)
|
||
selected[place] = (candidate, False)
|
||
if existing:
|
||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_([w.id for w in existing])))
|
||
await session.execute(delete(Winner).where(Winner.lottery_id == lottery_id))
|
||
results, stored = {}, {}
|
||
for place, (participation, manual) in sorted(selected.items()):
|
||
owner = participation.user
|
||
# Preserve the selected ticket, including for users with multiple accounts.
|
||
actor = SimpleNamespace(id=participation.user_id,
|
||
telegram_id=owner.telegram_id if owner else None,
|
||
username=owner.username if owner else None,
|
||
first_name=owner.first_name if owner else None,
|
||
nickname=owner.nickname if owner else None,
|
||
account_number=participation.account_number)
|
||
prize = lottery.prizes[place - 1] if lottery.prizes else f"Приз {place} места"
|
||
session.add(Winner(lottery_id=lottery_id, user_id=actor.id, account_number=actor.account_number,
|
||
place=place, prize=prize, is_manual=manual))
|
||
results[place] = dict(user=actor, prize=prize, is_manual=manual)
|
||
stored[str(place)] = dict(user_id=actor.id, telegram_id=actor.telegram_id,
|
||
username=actor.username, account_number=actor.account_number,
|
||
prize=prize, is_manual=manual)
|
||
lottery.is_completed = True
|
||
lottery.is_active = False
|
||
lottery.end_date = datetime.now(timezone.utc)
|
||
lottery.draw_results = stored
|
||
await session.commit()
|
||
return results
|
||
|
||
@staticmethod
|
||
async def get_winners(session: AsyncSession, lottery_id: int) -> List[Winner]:
|
||
"""Получить победителей розыгрыша"""
|
||
result = await session.execute(
|
||
select(Winner)
|
||
.options(selectinload(Winner.user))
|
||
.where(Winner.lottery_id == lottery_id)
|
||
.order_by(Winner.place)
|
||
)
|
||
return result.scalars().all()
|
||
|
||
@staticmethod
|
||
async def set_winner_display_type(session: AsyncSession, lottery_id: int, display_type: str) -> bool:
|
||
"""Установить тип отображения победителей для розыгрыша"""
|
||
from ..display.winner_display import validate_display_type
|
||
|
||
if not validate_display_type(display_type):
|
||
return False
|
||
|
||
result = await session.execute(
|
||
update(Lottery)
|
||
.where(Lottery.id == lottery_id)
|
||
.values(winner_display_type=display_type)
|
||
)
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
|
||
@staticmethod
|
||
async def set_lottery_active(session: AsyncSession, lottery_id: int, is_active: bool) -> bool:
|
||
"""Установить статус активности розыгрыша"""
|
||
result = await session.execute(
|
||
update(Lottery)
|
||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||
.values(is_active=is_active)
|
||
)
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
|
||
@staticmethod
|
||
async def complete_lottery(session: AsyncSession, lottery_id: int) -> bool:
|
||
"""Завершить розыгрыш (сделать неактивным и завершенным)"""
|
||
from datetime import datetime
|
||
|
||
result = await session.execute(
|
||
update(Lottery)
|
||
.where(Lottery.id == lottery_id)
|
||
.values(is_active=False, is_completed=True, end_date=datetime.now())
|
||
)
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
|
||
@staticmethod
|
||
async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool:
|
||
"""Удалить розыгрыш и все связанные данные"""
|
||
from .models import WinnerVerification
|
||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(
|
||
select(Winner.id).where(Winner.lottery_id == lottery_id))))
|
||
# Сначала удаляем все связанные данные
|
||
# Удаляем победителей
|
||
await session.execute(
|
||
delete(Winner).where(Winner.lottery_id == lottery_id)
|
||
)
|
||
|
||
# Удаляем участников
|
||
await session.execute(
|
||
delete(Participation).where(Participation.lottery_id == lottery_id)
|
||
)
|
||
|
||
# Удаляем сам розыгрыш
|
||
result = await session.execute(
|
||
delete(Lottery).where(Lottery.id == lottery_id)
|
||
)
|
||
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
|
||
|
||
class ParticipationService:
|
||
"""Сервис для работы с участием в розыгрышах"""
|
||
|
||
@staticmethod
|
||
async def add_participant(session, lottery_id, user_id):
|
||
from .transactions import lock_open_lottery
|
||
if not await lock_open_lottery(session, lottery_id):
|
||
await session.rollback()
|
||
return False
|
||
exists = await session.scalar(select(Participation.id).where(
|
||
Participation.lottery_id == lottery_id, Participation.user_id == user_id).limit(1))
|
||
user = await session.get(User, user_id)
|
||
if exists or not user:
|
||
await session.rollback()
|
||
return False
|
||
session.add(Participation(lottery_id=lottery_id, user_id=user_id))
|
||
await session.commit()
|
||
return True
|
||
|
||
@staticmethod
|
||
async def remove_participant(session, lottery_id, user_id):
|
||
from .transactions import lock_open_lottery
|
||
if not await lock_open_lottery(session, lottery_id):
|
||
await session.rollback()
|
||
return False
|
||
result = await session.execute(delete(Participation).where(
|
||
Participation.lottery_id == lottery_id, Participation.user_id == user_id))
|
||
await session.commit()
|
||
return result.rowcount > 0
|
||
|
||
@staticmethod
|
||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||
"""Получить участников розыгрыша"""
|
||
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id)
|
||
|
||
if limit:
|
||
query = query.offset(offset).limit(limit)
|
||
|
||
result = await session.execute(query)
|
||
return list(result.scalars().all())
|
||
|
||
@staticmethod
|
||
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
|
||
"""Получить участие пользователя в розыгрышах"""
|
||
result = await session.execute(
|
||
select(Participation)
|
||
.options(selectinload(Participation.lottery))
|
||
.where(Participation.user_id == user_id)
|
||
.order_by(Participation.created_at.desc())
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
@staticmethod
|
||
async def get_participants_count(session, lottery_id):
|
||
from sqlalchemy import func
|
||
return await session.scalar(select(func.count()).select_from(Participation).where(Participation.lottery_id == lottery_id))
|
||
|
||
@staticmethod
|
||
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
||
"""Массовое добавление участников"""
|
||
results = {
|
||
"added": 0,
|
||
"skipped": 0,
|
||
"errors": [],
|
||
"details": []
|
||
}
|
||
|
||
for telegram_id in telegram_ids:
|
||
try:
|
||
# Проверяем, существует ли пользователь
|
||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||
if not user:
|
||
results["errors"].append(f"Пользователь {telegram_id} не найден")
|
||
continue
|
||
|
||
# Пробуем добавить
|
||
if await ParticipationService.add_participant(session, lottery_id, user.id):
|
||
results["added"] += 1
|
||
results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})")
|
||
else:
|
||
results["skipped"] += 1
|
||
results["details"].append(f"Уже участвует: {user.first_name}")
|
||
|
||
except Exception as e:
|
||
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||
|
||
return results
|
||
|
||
@staticmethod
|
||
async def remove_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
||
"""Массовое удаление участников"""
|
||
results = {
|
||
"removed": 0,
|
||
"not_found": 0,
|
||
"errors": [],
|
||
"details": []
|
||
}
|
||
|
||
for telegram_id in telegram_ids:
|
||
try:
|
||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||
if not user:
|
||
results["not_found"] += 1
|
||
results["details"].append(f"Не найден: {telegram_id}")
|
||
continue
|
||
|
||
if await ParticipationService.remove_participant(session, lottery_id, user.id):
|
||
results["removed"] += 1
|
||
results["details"].append(f"Удален: {user.first_name}")
|
||
else:
|
||
results["not_found"] += 1
|
||
results["details"].append(f"Не участвовал: {user.first_name}")
|
||
|
||
except Exception as e:
|
||
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
|
||
|
||
return results
|
||
|
||
@staticmethod
|
||
async def add_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||
from src.handlers.account_services import AccountParticipationService
|
||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, account_numbers)
|
||
result['invalid_accounts'] = [a for a in account_numbers if not a.split() or not format_account_number(a.split()[-1])]
|
||
return result
|
||
|
||
@staticmethod
|
||
async def remove_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||
from src.handlers.account_services import AccountParticipationService
|
||
result = dict(removed=0, not_found=0, errors=[], details=[], invalid_accounts=[])
|
||
for account in account_numbers:
|
||
value = account.split()[-1] if account.split() else ''
|
||
item = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, value)
|
||
result['removed' if item['success'] else 'not_found'] += 1
|
||
result['details'].append(item['message'])
|
||
if not item['success']:
|
||
result['errors'].append(item['message'])
|
||
return result
|
||
|
||
@staticmethod
|
||
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||
"""Статистика участника"""
|
||
from sqlalchemy import func
|
||
|
||
# Количество участий
|
||
participations_count = await session.scalar(
|
||
select(func.count(Participation.id)).where(Participation.user_id == user_id)
|
||
)
|
||
|
||
# Количество побед
|
||
wins_count = await session.scalar(
|
||
select(func.count(Winner.id)).where(Winner.user_id == user_id)
|
||
)
|
||
|
||
# Последнее участие
|
||
last_participation = await session.execute(
|
||
select(Participation).where(Participation.user_id == user_id)
|
||
.order_by(Participation.created_at.desc()).limit(1)
|
||
)
|
||
last_participation = last_participation.scalar_one_or_none()
|
||
|
||
return {
|
||
"participations_count": participations_count,
|
||
"wins_count": wins_count,
|
||
"last_participation": last_participation.created_at if last_participation else None
|
||
}
|