This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas import ChatInfo
|
||||
from app.core.database import get_db
|
||||
from app.models.managed_chat import ManagedChat
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/chats", tags=["chats"])
|
||||
|
||||
# chat_id → (bytes, content_type, expires_at)
|
||||
_chat_avatar_cache: dict[int, tuple[bytes, str, float]] = {}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChatInfo])
|
||||
async def list_accessible_chats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[ChatInfo]:
|
||||
"""Groups: any member may use. Channels: admins/creators only."""
|
||||
from app.bot.router import bot
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
|
||||
result = await db.execute(
|
||||
select(ManagedChat).where(ManagedChat.is_active == True)
|
||||
)
|
||||
chats = result.scalars().all()
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
|
||||
_unverifiable = (
|
||||
"user_id_invalid",
|
||||
"have no rights",
|
||||
"not enough rights",
|
||||
"administrators only",
|
||||
)
|
||||
|
||||
accessible: list[ChatInfo] = []
|
||||
for chat in chats:
|
||||
try:
|
||||
member = await bot.get_chat_member(chat.id, current_user.id)
|
||||
status = member.status
|
||||
if chat.chat_type == "channel":
|
||||
allowed = status in ("administrator", "creator")
|
||||
else:
|
||||
allowed = status in ("member", "administrator", "creator", "restricted")
|
||||
except TelegramBadRequest as e:
|
||||
msg = str(e).lower()
|
||||
# Can't verify due to anonymous/privacy settings - include the chat
|
||||
allowed = any(p in msg for p in _unverifiable)
|
||||
if allowed:
|
||||
logger.debug("Chat %s membership unverifiable for user %s (%s) - including", chat.id, current_user.id, e)
|
||||
except TelegramForbiddenError:
|
||||
continue
|
||||
except TelegramAPIError:
|
||||
continue
|
||||
|
||||
if allowed:
|
||||
accessible.append(ChatInfo(
|
||||
id=chat.id,
|
||||
title=chat.title,
|
||||
username=chat.username,
|
||||
chat_type=chat.chat_type,
|
||||
))
|
||||
|
||||
return accessible
|
||||
|
||||
|
||||
@router.get("/{chat_id}/link")
|
||||
async def get_chat_link(
|
||||
chat_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Return the best navigation URL for a chat (username > last_message_id > /1 fallback)."""
|
||||
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
|
||||
managed = result.scalar_one_or_none()
|
||||
if not managed:
|
||||
raise HTTPException(404, "Chat not found")
|
||||
|
||||
if managed.username:
|
||||
return {"url": f"https://t.me/{managed.username}"}
|
||||
channel_id = str(abs(chat_id))[3:] # strip -100 prefix
|
||||
return {"url": f"https://t.me/c/{channel_id}/{managed.last_message_id or 1}"}
|
||||
|
||||
|
||||
@router.get("/{chat_id}/invite")
|
||||
async def get_chat_invite_link(
|
||||
chat_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Return a persistent invite link for a chat.
|
||||
|
||||
Reading an already-stored link: any authenticated user associated with a
|
||||
lottery that requires this chat (owner or participant).
|
||||
Generating a new link: only the chat's admin/creator.
|
||||
"""
|
||||
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
|
||||
managed = result.scalar_one_or_none()
|
||||
if not managed:
|
||||
raise HTTPException(404, "Chat not found")
|
||||
|
||||
# Already-stored link: any authenticated user may read it.
|
||||
# The admin intentionally generated it for participants to use.
|
||||
if managed.invite_link:
|
||||
return {"url": managed.invite_link}
|
||||
|
||||
# No link stored yet - only an admin/creator may trigger generation
|
||||
from app.bot.router import bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
|
||||
is_admin = False
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id, current_user.id)
|
||||
is_admin = member.status in ("administrator", "creator")
|
||||
except TelegramBadRequest as e:
|
||||
msg = str(e).lower()
|
||||
is_admin = any(p in msg for p in ("administrators only", "have no rights", "not enough rights"))
|
||||
except TelegramAPIError:
|
||||
pass
|
||||
|
||||
if not is_admin:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
|
||||
url: str | None = None
|
||||
error: str | None = None
|
||||
try:
|
||||
link = await bot.create_chat_invite_link(chat_id)
|
||||
url = link.invite_link
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
logger.warning("create_chat_invite_link failed for chat %s: %s", chat_id, e)
|
||||
|
||||
if url:
|
||||
managed.invite_link = url
|
||||
await db.commit()
|
||||
|
||||
return {"url": url, "error": error}
|
||||
|
||||
|
||||
@router.get("/{chat_id}/avatar")
|
||||
async def chat_avatar(chat_id: int) -> Response:
|
||||
from app.core.config import settings
|
||||
ttl = settings.avatar_cache_ttl
|
||||
cached = _chat_avatar_cache.get(chat_id)
|
||||
if cached and time.monotonic() < cached[2]:
|
||||
return Response(content=cached[0], media_type=cached[1],
|
||||
headers={"Cache-Control": f"public, max-age={ttl}"})
|
||||
from app.bot.router import bot
|
||||
try:
|
||||
chat = await bot.get_chat(chat_id)
|
||||
if not chat.photo:
|
||||
return Response(status_code=404)
|
||||
data = await bot.download(chat.photo.small_file_id)
|
||||
content = data.read()
|
||||
content_type = "image/jpeg"
|
||||
_chat_avatar_cache[chat_id] = (content, content_type, time.monotonic() + ttl)
|
||||
return Response(content=content, media_type=content_type,
|
||||
headers={"Cache-Control": f"public, max-age={ttl}"})
|
||||
except Exception:
|
||||
return Response(status_code=404)
|
||||
@@ -0,0 +1,609 @@
|
||||
import html
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas import LotteryCreate, LotteryResponse, LotteryUpdate, PrizeResponse, WinnerResponse
|
||||
from app.core.database import get_db
|
||||
from app.models.lottery import Lottery, LotteryStatus
|
||||
from app.models.participant import Participant
|
||||
from app.models.prize import Prize
|
||||
from app.models.user import User
|
||||
from app.models.winner import Winner
|
||||
from app.services.draw_service import execute_draw
|
||||
|
||||
router = APIRouter(prefix="/lotteries", tags=["lotteries"])
|
||||
|
||||
|
||||
async def _revoke_invite_links(db: AsyncSession, chat_ids: set[int]) -> None:
|
||||
from app.bot.router import bot
|
||||
from app.models.managed_chat import ManagedChat
|
||||
for chat_id in chat_ids:
|
||||
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
|
||||
managed = result.scalar_one_or_none()
|
||||
if managed and managed.invite_link:
|
||||
try:
|
||||
await bot.revoke_chat_invite_link(chat_id, managed.invite_link)
|
||||
except Exception:
|
||||
pass
|
||||
managed.invite_link = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _build_lottery_response(
|
||||
db: AsyncSession, lottery: Lottery, current_user_id: int | None = None
|
||||
) -> LotteryResponse:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
|
||||
)
|
||||
participant_count = count_result.scalar() or 0
|
||||
|
||||
prize_result = await db.execute(
|
||||
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
|
||||
)
|
||||
prizes = prize_result.scalars().all()
|
||||
total_prizes = sum(p.quantity for p in prizes)
|
||||
|
||||
is_winner: bool | None = None
|
||||
if current_user_id is not None and lottery.status == LotteryStatus.FINISHED:
|
||||
w = await db.execute(
|
||||
select(Winner).where(Winner.lottery_id == lottery.id, Winner.user_id == current_user_id).limit(1)
|
||||
)
|
||||
is_winner = w.scalar_one_or_none() is not None
|
||||
|
||||
return LotteryResponse(
|
||||
id=lottery.id,
|
||||
creator_id=lottery.creator_id,
|
||||
title=lottery.title,
|
||||
description=lottery.description,
|
||||
contact_info=lottery.contact_info,
|
||||
status=lottery.status,
|
||||
target_chat_id=lottery.target_chat_id,
|
||||
target_chat_title=lottery.target_chat_title,
|
||||
require_membership=lottery.require_membership,
|
||||
required_chat_ids=lottery.required_chat_ids or [],
|
||||
required_chat_titles=lottery.required_chat_titles or [],
|
||||
required_chat_types=lottery.required_chat_types or [],
|
||||
required_chat_usernames=lottery.required_chat_usernames or [],
|
||||
announcement_targets=lottery.announcement_targets or [],
|
||||
allow_multiple_wins=lottery.allow_multiple_wins,
|
||||
invite_link_chat_ids=lottery.invite_link_chat_ids or [],
|
||||
required_chat_passphrases=lottery.required_chat_passphrases or [],
|
||||
max_participants=lottery.max_participants,
|
||||
draw_at=lottery.draw_at,
|
||||
auto_draw_count=lottery.auto_draw_count,
|
||||
creator_timezone=lottery.creator_timezone,
|
||||
participant_count=participant_count,
|
||||
prize_count=len(prizes),
|
||||
total_prizes=total_prizes,
|
||||
created_at=lottery.created_at,
|
||||
drawn_at=lottery.drawn_at,
|
||||
draw_trigger=lottery.draw_trigger,
|
||||
prizes=[PrizeResponse.model_validate(p) for p in prizes],
|
||||
is_winner=is_winner,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[LotteryResponse])
|
||||
async def list_my_lotteries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[LotteryResponse]:
|
||||
result = await db.execute(
|
||||
select(Lottery)
|
||||
.where(Lottery.creator_id == current_user.id)
|
||||
.order_by(Lottery.created_at.desc())
|
||||
)
|
||||
lotteries = result.scalars().all()
|
||||
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
|
||||
|
||||
|
||||
@router.get("/joined", response_model=list[LotteryResponse])
|
||||
async def list_joined_lotteries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[LotteryResponse]:
|
||||
result = await db.execute(
|
||||
select(Lottery)
|
||||
.join(Participant, Participant.lottery_id == Lottery.id)
|
||||
.where(Participant.user_id == current_user.id)
|
||||
.order_by(Lottery.created_at.desc())
|
||||
)
|
||||
lotteries = result.scalars().all()
|
||||
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
|
||||
|
||||
|
||||
@router.get("/active", response_model=list[LotteryResponse])
|
||||
async def list_active_lotteries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[LotteryResponse]:
|
||||
"""Active lotteries created by others that the user can join."""
|
||||
result = await db.execute(
|
||||
select(Lottery)
|
||||
.where(Lottery.status == LotteryStatus.ACTIVE)
|
||||
.where(Lottery.creator_id != current_user.id)
|
||||
.order_by(Lottery.created_at.desc())
|
||||
)
|
||||
lotteries = result.scalars().all()
|
||||
return [await _build_lottery_response(db, l) for l in lotteries]
|
||||
|
||||
|
||||
@router.post("", response_model=LotteryResponse)
|
||||
async def create_lottery(
|
||||
data: LotteryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> LotteryResponse:
|
||||
lottery = Lottery(creator_id=current_user.id, **data.model_dump())
|
||||
db.add(lottery)
|
||||
await db.commit()
|
||||
await db.refresh(lottery)
|
||||
return await _build_lottery_response(db, lottery)
|
||||
|
||||
|
||||
@router.get("/{lottery_id}", response_model=LotteryResponse)
|
||||
async def get_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> LotteryResponse:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
return await _build_lottery_response(db, lottery)
|
||||
|
||||
|
||||
@router.put("/{lottery_id}", response_model=LotteryResponse)
|
||||
async def update_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
data: LotteryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> LotteryResponse:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status in (LotteryStatus.FINISHED, LotteryStatus.CANCELLED):
|
||||
raise HTTPException(400, "Cannot edit a finished or cancelled lottery")
|
||||
|
||||
old_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(lottery, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(lottery)
|
||||
|
||||
new_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
|
||||
removed_ids = old_invite_ids - new_invite_ids
|
||||
if removed_ids:
|
||||
await _revoke_invite_links(db, removed_ids)
|
||||
|
||||
# Auto-sync announcement messages whenever the lottery is edited
|
||||
if lottery.announcement_targets or lottery.announcement_message_id:
|
||||
try:
|
||||
await sync_announcement(db, lottery, force_send=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await _build_lottery_response(db, lottery)
|
||||
|
||||
|
||||
@router.post("/{lottery_id}/activate", response_model=LotteryResponse)
|
||||
async def activate_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> LotteryResponse:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status != LotteryStatus.DRAFT:
|
||||
raise HTTPException(400, "Lottery is already activated")
|
||||
|
||||
prize_result = await db.execute(
|
||||
select(Prize).where(Prize.lottery_id == lottery_id).limit(1)
|
||||
)
|
||||
if prize_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(400, "Add at least one prize before activating")
|
||||
|
||||
lottery.status = LotteryStatus.ACTIVE
|
||||
await db.commit()
|
||||
await db.refresh(lottery)
|
||||
return await _build_lottery_response(db, lottery)
|
||||
|
||||
|
||||
@router.post("/{lottery_id}/draw", response_model=list[WinnerResponse])
|
||||
async def draw_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[WinnerResponse]:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status != LotteryStatus.ACTIVE:
|
||||
raise HTTPException(400, "Lottery is not active")
|
||||
|
||||
try:
|
||||
winners_data = await execute_draw(db, lottery_id, trigger="manual")
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
# Announce winners to all announcement targets
|
||||
targets = lottery.announcement_targets or (
|
||||
[{"chat_id": lottery.target_chat_id, "chat_title": lottery.target_chat_title or "", "message_id": lottery.announcement_message_id}]
|
||||
if lottery.target_chat_id else []
|
||||
)
|
||||
if targets and winners_data:
|
||||
from app.bot.router import bot
|
||||
from aiogram.types import ReplyParameters
|
||||
lines = []
|
||||
for w in winners_data:
|
||||
full_name = html.escape(
|
||||
w["first_name"] + (f" {w['last_name']}" if w.get("last_name") else "")
|
||||
)
|
||||
safe_name = f'<a href="tg://user?id={w["user_id"]}">{full_name}</a>'
|
||||
safe_prize = html.escape(w["prize_name"])
|
||||
lines.append(f"🏆 {safe_name} - {safe_prize}")
|
||||
text = (
|
||||
f"🎉 <b>Draw Results: {html.escape(lottery.title)}</b>\n\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nCongratulations to all winners! 🎊"
|
||||
)
|
||||
for t in targets:
|
||||
try:
|
||||
reply = (
|
||||
ReplyParameters(message_id=t["message_id"], allow_sending_without_reply=True)
|
||||
if t.get("message_id")
|
||||
else None
|
||||
)
|
||||
await bot.send_message(
|
||||
t["chat_id"],
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_parameters=reply,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if winners_data:
|
||||
from app.services.scheduler import notify_winners
|
||||
import asyncio
|
||||
asyncio.create_task(notify_winners(lottery.title, winners_data, lottery.contact_info, lottery_id=lottery.id))
|
||||
|
||||
return [WinnerResponse(**w) for w in winners_data]
|
||||
|
||||
|
||||
@router.get("/{lottery_id}/winners", response_model=list[WinnerResponse])
|
||||
async def get_winners(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[WinnerResponse]:
|
||||
result = await db.execute(
|
||||
select(Winner, User)
|
||||
.join(User, Winner.user_id == User.id)
|
||||
.where(Winner.lottery_id == lottery_id)
|
||||
.order_by(Winner.drawn_at)
|
||||
)
|
||||
return [
|
||||
WinnerResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
prize_name=winner.prize_name,
|
||||
drawn_at=winner.drawn_at,
|
||||
)
|
||||
for winner, user in result.all()
|
||||
]
|
||||
|
||||
|
||||
async def _build_announce_text(db: AsyncSession, lottery: Lottery, include_count: bool = True) -> str:
|
||||
prize_result = await db.execute(
|
||||
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
|
||||
)
|
||||
prizes = prize_result.scalars().all()
|
||||
count_result = await db.execute(
|
||||
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
|
||||
)
|
||||
participant_count = count_result.scalar() or 0
|
||||
|
||||
text = f"🎰 <b>{html.escape(lottery.title)}</b>"
|
||||
|
||||
if lottery.description:
|
||||
text += f"\n\n{html.escape(lottery.description)}"
|
||||
|
||||
# Membership requirements
|
||||
required_ids = lottery.required_chat_ids or []
|
||||
required_titles = lottery.required_chat_titles or []
|
||||
required_usernames = lottery.required_chat_usernames or []
|
||||
required_passphrases = lottery.required_chat_passphrases or []
|
||||
invite_link_ids = set(lottery.invite_link_chat_ids or [])
|
||||
|
||||
if lottery.require_membership and required_ids:
|
||||
from app.models.managed_chat import ManagedChat
|
||||
mc_result = await db.execute(select(ManagedChat).where(ManagedChat.id.in_(required_ids)))
|
||||
managed_chats = {mc.id: mc for mc in mc_result.scalars().all()}
|
||||
|
||||
text += "\n\n📮 <b>Requirements:</b>"
|
||||
for i, chat_id in enumerate(required_ids):
|
||||
title = required_titles[i] if i < len(required_titles) else str(chat_id)
|
||||
username = required_usernames[i] if i < len(required_usernames) else None
|
||||
mc = managed_chats.get(chat_id)
|
||||
|
||||
if chat_id in invite_link_ids and mc and mc.invite_link:
|
||||
link: str | None = mc.invite_link
|
||||
elif username:
|
||||
link = f"https://t.me/{username}"
|
||||
elif mc:
|
||||
channel_id = str(abs(chat_id))[3:]
|
||||
link = f"https://t.me/c/{channel_id}/{mc.last_message_id or 1}"
|
||||
else:
|
||||
link = None
|
||||
|
||||
label = f'<a href="{link}">{html.escape(title)}</a>' if link else html.escape(title)
|
||||
text += f"\n 🎫 Join - {label}"
|
||||
if i < len(required_passphrases) and required_passphrases[i]:
|
||||
text += f"\n └ 🔑 Send passphrase ﹝<code>{html.escape(required_passphrases[i])}</code>﹞"
|
||||
elif lottery.require_membership and lottery.target_chat_title:
|
||||
text += f"\n\n📮 <b>Requirements:</b>\n 🎫 Join - {html.escape(lottery.target_chat_title)}"
|
||||
|
||||
# Prizes
|
||||
prize_lines = "\n".join(f" - {html.escape(p.name)} ×{p.quantity}" for p in prizes)
|
||||
text += f"\n\n🎁 <b>Prizes:</b>\n{prize_lines}"
|
||||
|
||||
# Participant count
|
||||
if include_count:
|
||||
text += f"\n\n👥 Participants: {participant_count}"
|
||||
|
||||
# Draw conditions
|
||||
if lottery.draw_at:
|
||||
from datetime import timezone as _tz
|
||||
draw_dt = lottery.draw_at.replace(tzinfo=_tz.utc)
|
||||
tz_label = "UTC"
|
||||
if lottery.creator_timezone:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
draw_dt = draw_dt.astimezone(ZoneInfo(lottery.creator_timezone))
|
||||
offset = draw_dt.utcoffset()
|
||||
if offset is not None:
|
||||
total_min = int(offset.total_seconds() / 60)
|
||||
sign = "+" if total_min >= 0 else "-"
|
||||
h, m = divmod(abs(total_min), 60)
|
||||
tz_label = f"UTC{sign}{h}" if m == 0 else f"UTC{sign}{h}:{m:02d}"
|
||||
except Exception:
|
||||
pass
|
||||
text += f"\n\n📅 <b>Draw time:</b> {draw_dt.strftime('%Y/%m/%d %H:%M')} ({tz_label})"
|
||||
if lottery.auto_draw_count:
|
||||
text += f"\n🎯 Auto-draw at {lottery.auto_draw_count} participants"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
async def _sync_single_target(
|
||||
bot,
|
||||
text: str,
|
||||
keyboard,
|
||||
target: dict,
|
||||
force_send: bool,
|
||||
) -> int | None:
|
||||
"""Sync announcement to one chat. Returns the resulting message_id (or None)."""
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
chat_id = target["chat_id"]
|
||||
msg_id = target.get("message_id")
|
||||
|
||||
if msg_id:
|
||||
try:
|
||||
await bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=msg_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return msg_id
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e).lower():
|
||||
return msg_id
|
||||
# Message deleted or inaccessible
|
||||
if not force_send:
|
||||
return msg_id # keep existing id, silently skip
|
||||
msg_id = None # will resend below
|
||||
|
||||
if force_send:
|
||||
msg = await bot.send_message(
|
||||
chat_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await _update_chat_last_message(chat_id, msg.message_id)
|
||||
return msg.message_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _update_chat_last_message(chat_id: int, message_id: int) -> None:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.managed_chat import ManagedChat
|
||||
from sqlalchemy import select
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
|
||||
managed = result.scalar_one_or_none()
|
||||
if managed and message_id > (managed.last_message_id or 0):
|
||||
managed.last_message_id = message_id
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def sync_announcement(db: AsyncSession, lottery: Lottery, force_send: bool = False) -> None:
|
||||
"""Edit or send announcement messages to all announcement_targets."""
|
||||
from app.bot.router import bot
|
||||
from app.bot.keyboards.inline import lottery_announce_keyboard
|
||||
|
||||
# Build target list - backward compat with old single-target lotteries
|
||||
targets: list[dict] = lottery.announcement_targets or []
|
||||
if not targets and lottery.target_chat_id:
|
||||
targets = [{
|
||||
"chat_id": lottery.target_chat_id,
|
||||
"chat_title": lottery.target_chat_title or "",
|
||||
"message_id": lottery.announcement_message_id,
|
||||
}]
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
text = await _build_announce_text(db, lottery)
|
||||
keyboard = lottery_announce_keyboard(str(lottery.id))
|
||||
|
||||
updated: list[dict] = []
|
||||
changed = False
|
||||
for t in targets:
|
||||
new_msg_id = await _sync_single_target(bot, text, keyboard, t, force_send)
|
||||
updated.append({**t, "message_id": new_msg_id})
|
||||
if new_msg_id != t.get("message_id"):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
lottery.announcement_targets = updated
|
||||
await db.commit()
|
||||
|
||||
|
||||
class _AnnounceChatItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class _AnnounceRequest(BaseModel):
|
||||
chats: list[_AnnounceChatItem]
|
||||
|
||||
|
||||
@router.post("/{lottery_id}/announce")
|
||||
async def announce_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
data: _AnnounceRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if not data.chats:
|
||||
raise HTTPException(400, "Select at least one chat")
|
||||
if lottery.status == LotteryStatus.CANCELLED:
|
||||
raise HTTPException(400, "Cannot announce a cancelled lottery")
|
||||
|
||||
# Build new targets, preserving existing message_ids for same chats
|
||||
existing = {t["chat_id"]: t for t in (lottery.announcement_targets or [])}
|
||||
new_targets = [
|
||||
{
|
||||
"chat_id": c.id,
|
||||
"chat_title": c.title,
|
||||
"message_id": existing.get(c.id, {}).get("message_id"),
|
||||
}
|
||||
for c in data.chats
|
||||
]
|
||||
lottery.announcement_targets = new_targets
|
||||
# Keep legacy target_chat_id pointing to first chat for draw-result compat
|
||||
lottery.target_chat_id = data.chats[0].id
|
||||
lottery.target_chat_title = data.chats[0].title
|
||||
await db.commit()
|
||||
await db.refresh(lottery)
|
||||
|
||||
try:
|
||||
await sync_announcement(db, lottery, force_send=True)
|
||||
except TelegramAPIError as e:
|
||||
raise HTTPException(500, f"Failed to send message: {e}")
|
||||
|
||||
return {"status": "announced"}
|
||||
|
||||
|
||||
@router.post("/{lottery_id}/prepare-share")
|
||||
async def prepare_share(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.status == LotteryStatus.CANCELLED:
|
||||
raise HTTPException(400, "Cannot share a cancelled lottery")
|
||||
|
||||
from app.bot.router import bot
|
||||
from app.bot.keyboards.inline import lottery_announce_keyboard
|
||||
from aiogram.types import InlineQueryResultArticle, InputTextMessageContent
|
||||
|
||||
text = await _build_announce_text(db, lottery, include_count=False)
|
||||
keyboard = lottery_announce_keyboard(str(lottery.id))
|
||||
|
||||
try:
|
||||
prepared = await bot.save_prepared_inline_message(
|
||||
user_id=current_user.id,
|
||||
result=InlineQueryResultArticle(
|
||||
id=str(lottery.id),
|
||||
title=lottery.title,
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=text,
|
||||
parse_mode="HTML",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
),
|
||||
allow_user_chats=True,
|
||||
allow_bot_chats=False,
|
||||
allow_group_chats=True,
|
||||
allow_channel_chats=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return {"prepared_message_id": prepared.id}
|
||||
|
||||
|
||||
@router.delete("/{lottery_id}")
|
||||
async def cancel_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status == LotteryStatus.FINISHED:
|
||||
raise HTTPException(400, "Cannot cancel a finished lottery")
|
||||
|
||||
lottery.status = LotteryStatus.CANCELLED
|
||||
await db.commit()
|
||||
return {"status": "cancelled"}
|
||||
@@ -0,0 +1,148 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas import ParticipantResponse
|
||||
from app.core.database import get_db
|
||||
from app.models.lottery import Lottery, LotteryStatus
|
||||
from app.models.participant import Participant
|
||||
from app.models.user import User
|
||||
from app.services.membership_service import check_membership
|
||||
|
||||
router = APIRouter(tags=["participants"])
|
||||
|
||||
|
||||
@router.post("/lotteries/{lottery_id}/join")
|
||||
async def join_lottery(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
# Lock the lottery row so the capacity check + insert is atomic.
|
||||
# Without this, concurrent joins can all pass the count check and exceed
|
||||
# max_participants before any of them commits.
|
||||
result = await db.execute(
|
||||
select(Lottery).where(Lottery.id == lottery_id).with_for_update()
|
||||
)
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.status != LotteryStatus.ACTIVE:
|
||||
raise HTTPException(400, "Lottery is not active")
|
||||
|
||||
# Check max participants inside the lock
|
||||
if lottery.max_participants is not None:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Participant.id)).where(Participant.lottery_id == lottery_id)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
if count >= lottery.max_participants:
|
||||
raise HTTPException(400, "Lottery is full")
|
||||
|
||||
# Check membership if required
|
||||
if lottery.require_membership:
|
||||
chat_ids = lottery.required_chat_ids or (
|
||||
[lottery.target_chat_id] if lottery.target_chat_id else []
|
||||
)
|
||||
for i, cid in enumerate(chat_ids):
|
||||
is_member = await check_membership(cid, current_user.id)
|
||||
if not is_member:
|
||||
titles = lottery.required_chat_titles or []
|
||||
label = titles[i] if i < len(titles) else str(cid)
|
||||
raise HTTPException(403, f"You must join {label} first")
|
||||
|
||||
# Check passphrase if set for this group
|
||||
passphrases = lottery.required_chat_passphrases or []
|
||||
if i < len(passphrases) and passphrases[i]:
|
||||
from app.models.passphrase_verification import PassphraseVerification
|
||||
pv = await db.execute(
|
||||
select(PassphraseVerification).where(
|
||||
PassphraseVerification.lottery_id == lottery_id,
|
||||
PassphraseVerification.chat_id == cid,
|
||||
PassphraseVerification.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if pv.scalar_one_or_none() is None:
|
||||
titles = lottery.required_chat_titles or []
|
||||
label = titles[i] if i < len(titles) else str(cid)
|
||||
raise HTTPException(403, f"Please send the passphrase in {label} first")
|
||||
|
||||
# Check already joined
|
||||
existing = await db.execute(
|
||||
select(Participant).where(
|
||||
Participant.lottery_id == lottery_id,
|
||||
Participant.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(409, "Already joined this lottery")
|
||||
|
||||
participant = Participant(lottery_id=lottery_id, user_id=current_user.id)
|
||||
db.add(participant)
|
||||
await db.commit()
|
||||
|
||||
# Refresh count for post-join checks
|
||||
count_result = await db.execute(
|
||||
select(func.count(Participant.id)).where(Participant.lottery_id == lottery_id)
|
||||
)
|
||||
new_count = count_result.scalar() or 0
|
||||
|
||||
# Always update the group announcement with the new participant count
|
||||
if lottery.announcement_targets or (lottery.announcement_message_id and lottery.target_chat_id):
|
||||
try:
|
||||
from app.api.routers.lotteries import sync_announcement
|
||||
await sync_announcement(db, lottery, force_send=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Trigger auto-draw if participant count reached the threshold
|
||||
if lottery.auto_draw_count and new_count >= lottery.auto_draw_count:
|
||||
import asyncio
|
||||
from app.services.scheduler import run_auto_draw
|
||||
asyncio.create_task(run_auto_draw(lottery_id, trigger="auto_count"))
|
||||
|
||||
return {"status": "joined"}
|
||||
|
||||
|
||||
@router.get("/lotteries/{lottery_id}/passphrase-status")
|
||||
async def get_passphrase_status(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from app.models.passphrase_verification import PassphraseVerification
|
||||
result = await db.execute(
|
||||
select(PassphraseVerification.chat_id).where(
|
||||
PassphraseVerification.lottery_id == lottery_id,
|
||||
PassphraseVerification.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
return {"verified_chat_ids": [row[0] for row in result.all()]}
|
||||
|
||||
|
||||
@router.get("/lotteries/{lottery_id}/participants", response_model=list[ParticipantResponse])
|
||||
async def list_participants(
|
||||
lottery_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[ParticipantResponse]:
|
||||
result = await db.execute(
|
||||
select(Participant, User)
|
||||
.join(User, Participant.user_id == User.id)
|
||||
.where(Participant.lottery_id == lottery_id)
|
||||
.order_by(Participant.joined_at)
|
||||
)
|
||||
return [
|
||||
ParticipantResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
joined_at=participant.joined_at,
|
||||
)
|
||||
for participant, user in result.all()
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.schemas import PrizeCreate, PrizeResponse
|
||||
from app.core.database import get_db
|
||||
from app.models.lottery import Lottery, LotteryStatus
|
||||
from app.models.prize import Prize
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(tags=["prizes"])
|
||||
|
||||
|
||||
@router.post("/lotteries/{lottery_id}/prizes", response_model=PrizeResponse)
|
||||
async def add_prize(
|
||||
lottery_id: uuid.UUID,
|
||||
data: PrizeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Prize:
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
|
||||
if lottery is None:
|
||||
raise HTTPException(404, "Lottery not found")
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
|
||||
raise HTTPException(400, "Cannot modify prizes after activation")
|
||||
|
||||
# Determine next sort order
|
||||
last_result = await db.execute(
|
||||
select(Prize)
|
||||
.where(Prize.lottery_id == lottery_id)
|
||||
.order_by(Prize.sort_order.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last = last_result.scalar_one_or_none()
|
||||
sort_order = (last.sort_order + 1) if last else 0
|
||||
|
||||
prize = Prize(lottery_id=lottery_id, sort_order=sort_order, **data.model_dump())
|
||||
db.add(prize)
|
||||
await db.commit()
|
||||
await db.refresh(prize)
|
||||
return prize
|
||||
|
||||
|
||||
@router.put("/prizes/{prize_id}", response_model=PrizeResponse)
|
||||
async def update_prize(
|
||||
prize_id: uuid.UUID,
|
||||
data: PrizeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Prize:
|
||||
result = await db.execute(select(Prize).where(Prize.id == prize_id))
|
||||
prize = result.scalar_one_or_none()
|
||||
|
||||
if prize is None:
|
||||
raise HTTPException(404, "Prize not found")
|
||||
|
||||
lottery_result = await db.execute(select(Lottery).where(Lottery.id == prize.lottery_id))
|
||||
lottery = lottery_result.scalar_one()
|
||||
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
|
||||
raise HTTPException(400, "Cannot modify prizes after activation")
|
||||
|
||||
prize.name = data.name
|
||||
prize.quantity = data.quantity
|
||||
prize.description = data.description
|
||||
await db.commit()
|
||||
await db.refresh(prize)
|
||||
return prize
|
||||
|
||||
|
||||
@router.delete("/prizes/{prize_id}")
|
||||
async def delete_prize(
|
||||
prize_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await db.execute(select(Prize).where(Prize.id == prize_id))
|
||||
prize = result.scalar_one_or_none()
|
||||
|
||||
if prize is None:
|
||||
raise HTTPException(404, "Prize not found")
|
||||
|
||||
lottery_result = await db.execute(select(Lottery).where(Lottery.id == prize.lottery_id))
|
||||
lottery = lottery_result.scalar_one()
|
||||
|
||||
if lottery.creator_id != current_user.id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
|
||||
raise HTTPException(400, "Cannot modify prizes after activation")
|
||||
|
||||
await db.delete(prize)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,33 @@
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import Response
|
||||
|
||||
router = APIRouter(tags=["users"])
|
||||
|
||||
# user_id → (bytes, content_type, expires_at)
|
||||
_avatar_cache: dict[int, tuple[bytes, str, float]] = {}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/avatar")
|
||||
async def get_user_avatar(user_id: int) -> Response:
|
||||
from app.core.config import settings
|
||||
ttl = settings.avatar_cache_ttl
|
||||
cached = _avatar_cache.get(user_id)
|
||||
if cached and time.monotonic() < cached[2]:
|
||||
return Response(content=cached[0], media_type=cached[1],
|
||||
headers={"Cache-Control": f"public, max-age={ttl}"})
|
||||
from app.bot.router import bot
|
||||
try:
|
||||
photos = await bot.get_user_profile_photos(user_id, limit=1)
|
||||
if not photos.photos:
|
||||
return Response(status_code=404)
|
||||
file_id = photos.photos[0][-1].file_id
|
||||
data = await bot.download(file_id)
|
||||
content = data.read()
|
||||
content_type = "image/jpeg"
|
||||
_avatar_cache[user_id] = (content, content_type, time.monotonic() + ttl)
|
||||
return Response(content=content, media_type=content_type,
|
||||
headers={"Cache-Control": f"public, max-age={ttl}"})
|
||||
except Exception:
|
||||
return Response(status_code=404)
|
||||
Reference in New Issue
Block a user