Files
new_lottery_bot/src/core/services.py
Trevor1985 3c1a6a02a5
All checks were successful
continuous-integration/drone/push Build is passing
Separate staff roles and protect message delivery with operator guides
2026-09-13 21:00:53 +09:00

557 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}