Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

View File

@@ -11,39 +11,22 @@ class UserService:
"""Сервис для работы с пользователями"""
@staticmethod
async def get_or_create_user(session: AsyncSession, telegram_id: int,
username: str = None, first_name: str = None,
last_name: str = None, nickname: str = None) -> User:
"""Получить или создать пользователя"""
# Пробуем найти существующего пользователя
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user:
# Обновляем информацию о пользователе
user.username = username
user.first_name = first_name
user.last_name = last_name
# Обновляем nickname только если он передан
if nickname is not None:
user.nickname = nickname
await session.commit()
return user
# Создаем нового пользователя
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
nickname=nickname
)
session.add(user)
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()
await session.refresh(user)
return user
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]:
@@ -101,6 +84,14 @@ class UserService:
if not user:
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)
@@ -128,72 +119,41 @@ class UserService:
return result.rowcount > 0
@staticmethod
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
"""Установить номер клиентского счета пользователю"""
# Валидируем и форматируем номер
formatted_number = format_account_number(account_number)
if not formatted_number:
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
# Проверяем уникальность номера
existing = await session.execute(
select(User).where(User.account_number == formatted_number)
)
if existing.scalar_one_or_none():
return False # Номер уже занят
# Обновляем пользователя
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(account_number=formatted_number)
)
await session.commit()
return result.rowcount > 0
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: AsyncSession, account_number: str) -> Optional[User]:
"""Получить пользователя по номеру счета"""
formatted_number = format_account_number(account_number)
if not formatted_number:
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: AsyncSession, club_card_number: str) -> Optional[User]:
"""
Получить пользователя по номеру клубной карты
Args:
session: Сессия БД
club_card_number: Номер клубной карты (4 цифры)
Returns:
User или None если не найден
"""
result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
return result.scalar_one_or_none()
result = await session.execute(
select(User).where(User.account_number == formatted_number)
)
return result.scalar_one_or_none()
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: AsyncSession, account_pattern: str) -> List[User]:
"""Поиск пользователей по части номера счета"""
# Убираем все кроме цифр и дефисов
clean_pattern = ''.join(c for c in account_pattern if c.isdigit() or c == '-')
if not clean_pattern:
async def search_by_account(session, account_pattern):
clean = ''.join(c for c in account_pattern if c in '0123456789-')
if not clean:
return []
result = await session.execute(
select(User).where(
User.account_number.like(f'%{clean_pattern}%')
).limit(20)
)
return result.scalars().all()
return list((await session.scalars(select(User).join(Account).distinct()
.where(Account.account_number.contains(clean, autoescape=True)).limit(20))).all())
class LotteryService:
@@ -221,6 +181,7 @@ class LotteryService:
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()
@@ -245,14 +206,16 @@ class LotteryService:
**updates
) -> bool:
"""Обновить данные розыгрыша"""
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
return False
try:
await session.execute(
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
.values(**updates)
)
await session.commit()
return True
return result.rowcount > 0
except Exception:
await session.rollback()
return False
@@ -269,126 +232,90 @@ class LotteryService:
return result.scalars().all()
@staticmethod
async def set_manual_winner(session: AsyncSession, lottery_id: int,
place: int, telegram_id: int) -> bool:
"""Установить ручного победителя для определенного места"""
# Получаем пользователя
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
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)
if not lottery:
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 lottery.manual_winners:
lottery.manual_winners = {}
lottery.manual_winners[str(place)] = telegram_id
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: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
"""Провести розыгрыш с учетом ручных победителей"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
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)
if not lottery or lottery.is_completed:
logger.warning(f"conduct_draw: lottery не найден или завершён")
participations = list(lottery.participations)
if not participations:
await session.rollback()
return {}
logger.info(f"conduct_draw: получаем участников")
# Получаем всех участников (включая тех, у кого нет user)
participants = []
for p in lottery.participations:
if p.user:
participants.append(p.user)
else:
# Создаем временный объект для участников без пользователя
# Храним только номер счета
participants.append(type('obj', (object,), {
'id': None,
'telegram_id': None,
'account_number': p.account_number
})())
logger.info(f"conduct_draw: участников {len(participants)}")
if not participants:
logger.warning(f"conduct_draw: нет участников")
return {}
# Определяем количество призовых мест
num_prizes = len(lottery.prizes) if lottery.prizes else 1
results = {}
remaining_participants = participants.copy()
manual_winners = lottery.manual_winners or {}
# Сначала обрабатываем ручных победителей
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):
place_str = str(place)
if place_str in manual_winners:
# Находим пользователя среди участников
manual_winner = None
for participant in remaining_participants:
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
manual_winner = participant
break
if manual_winner:
results[place] = {
'user': manual_winner,
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
'is_manual': True
}
remaining_participants.remove(manual_winner)
# Заполняем оставшиеся места случайными участниками
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 results and remaining_participants:
winner = random.choice(remaining_participants)
results[place] = {
'user': winner,
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
'is_manual': False
}
remaining_participants.remove(winner)
# Сохраняем победителей в базу данных
for place, winner_info in results.items():
user_obj = winner_info['user']
winner = Winner(
lottery_id=lottery_id,
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
place=place,
prize=winner_info['prize'],
is_manual=winner_info['is_manual']
)
session.add(winner)
# Обновляем статус розыгрыша
logger.info(f"conduct_draw: обновляем статус lottery")
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.draw_results = {}
for place, info in results.items():
user_obj = info['user']
lottery.draw_results[str(place)] = {
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
'username': user_obj.username if hasattr(user_obj, 'username') else None,
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
'prize': info['prize'],
'is_manual': info['is_manual']
}
# НЕ коммитим здесь - это должно сделать вызывающая функция
logger.info(f"conduct_draw: изменения подготовлены, победителей: {len(results)}")
lottery.is_active = False
lottery.end_date = datetime.now(timezone.utc)
lottery.draw_results = stored
await session.commit()
return results
@staticmethod
@@ -423,7 +350,7 @@ class LotteryService:
"""Установить статус активности розыгрыша"""
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
.values(is_active=is_active)
)
await session.commit()
@@ -445,6 +372,9 @@ class LotteryService:
@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(
@@ -469,36 +399,31 @@ class ParticipationService:
"""Сервис для работы с участием в розыгрышах"""
@staticmethod
async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
"""Добавить участника в розыгрыш"""
# Проверяем, не участвует ли уже пользователь
existing = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
)
if existing.scalar_one_or_none():
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
participation = Participation(lottery_id=lottery_id, user_id=user_id)
session.add(participation)
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: AsyncSession, lottery_id: int, user_id: int) -> bool:
"""Удалить участника из розыгрыша"""
participation = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
)
participation = participation.scalar_one_or_none()
if not participation:
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
await session.delete(participation)
result = await session.execute(delete(Participation).where(
Participation.lottery_id == lottery_id, Participation.user_id == user_id))
await session.commit()
return True
return result.rowcount > 0
@staticmethod
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
@@ -523,13 +448,9 @@ class ParticipationService:
return list(result.scalars().all())
@staticmethod
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
"""Получить количество участников в розыгрыше"""
result = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id)
)
return len(result.scalars().all())
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]:
@@ -593,190 +514,24 @@ class ParticipationService:
return results
@staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое добавление участников по номерам счетов"""
import logging
logger = logging.getLogger(__name__)
results = {
"added": 0,
"skipped": 0,
"errors": [],
"details": [],
"invalid_accounts": []
}
for account_input in account_numbers:
account_input = account_input.strip()
if not account_input:
continue
logger.info(f"DEBUG: Processing account_input={account_input!r}")
try:
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split()
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
if len(parts) == 2:
card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета
card_number = None
account_number = parts[0]
logger.info(f"DEBUG: 1 part - account={account_number!r}")
else:
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}")
continue
# Валидируем и форматируем номер счета
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
formatted_account = format_account_number(account_number)
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
logger.error(f"DEBUG: Format failed for {account_number!r}")
continue
# Ищем владельца счёта через таблицу Account
from ..core.registration_services import AccountService
user = await AccountService.get_account_owner(session, formatted_account)
if not user:
card_info = f" (карта: {card_number})" if card_number else ""
results["errors"].append(f"Пользователь с счётом {formatted_account}{card_info} не найден")
continue
# Получаем запись Account для этого счета
account_record = await session.execute(
select(Account).where(Account.account_number == formatted_account)
)
account_record = account_record.scalar_one_or_none()
if not account_record:
card_info = f" (карта: {card_number})" if card_number else ""
results["errors"].append(f"Запись счета {formatted_account}{card_info} не найдена в базе")
continue
# Проверяем, не участвует ли уже этот счет
existing = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == formatted_account
)
)
if existing.scalar_one_or_none():
results["skipped"] += 1
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(f"Уже участвует: {detail}")
continue
# Добавляем участие по счету
participation = Participation(
lottery_id=lottery_id,
user_id=user.id,
account_id=account_record.id,
account_number=formatted_account
)
session.add(participation)
await session.commit()
results["added"] += 1
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(detail)
except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
return results
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: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое удаление участников по номерам счетов"""
results = {
"removed": 0,
"not_found": 0,
"errors": [],
"details": [],
"invalid_accounts": []
}
for account_input in account_numbers:
account_input = account_input.strip()
if not account_input:
continue
try:
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split()
if len(parts) == 2:
card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета
elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета
card_number = None
account_number = parts[0]
else:
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}")
continue
# Валидируем и форматируем номер счета
formatted_account = format_account_number(account_number)
if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
continue
# Ищем владельца счёта через таблицу Account
from ..core.registration_services import AccountService
user = await AccountService.get_account_owner(session, formatted_account)
if not user:
card_info = f" (карта: {card_number})" if card_number else ""
results["not_found"] += 1
results["details"].append(f"Не найден: {formatted_account}{card_info}")
continue
# Ищем участие по номеру счета (не по user_id!)
participation = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
Participation.account_number == formatted_account
)
)
participation = participation.scalar_one_or_none()
if participation:
await session.delete(participation)
await session.commit()
results["removed"] += 1
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(detail)
else:
results["not_found"] += 1
detail = f"{user.first_name} ({formatted_account})"
if card_number:
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
results["details"].append(f"Не участвовал: {detail}")
except Exception as e:
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
return results
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]:
@@ -804,4 +559,4 @@ class ParticipationService:
"participations_count": participations_count,
"wins_count": wins_count,
"last_participation": last_participation.created_at if last_participation else None
}
}