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 }