arbret/backend/services/invite.py
counterweight 280c1e5687
Move slot expansion logic to ExchangeService
- Add get_available_slots() and _expand_availability_to_slots() to ExchangeService
- Update routes/exchange.py to use ExchangeService.get_available_slots()
- Remove all business logic from get_available_slots endpoint
- Add AvailabilityRepository to ExchangeService dependencies
- Add Availability and BookableSlot imports to ExchangeService
- Fix import path for validate_date_in_range (use date_validation module)
- Remove unused user_repo variable and import from routes/invites.py
- Fix mypy error in ValidationError by adding proper type annotation
2025-12-25 18:42:46 +01:00

206 lines
6.5 KiB
Python

"""Invite service for managing invites."""
from datetime import UTC, datetime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions import BadRequestError, ConflictError, NotFoundError
from invite_utils import (
generate_invite_identifier,
is_valid_identifier_format,
normalize_identifier,
)
from mappers import InviteMapper
from models import Invite, InviteStatus
from pagination import create_paginated_response
from repositories.invite import InviteRepository
from schemas import (
InviteCheckResponse,
PaginatedInviteRecords,
)
from utils.enum_validation import validate_enum
MAX_INVITE_COLLISION_RETRIES = 3
class InviteService:
"""Service for invite-related business logic."""
def __init__(self, db: AsyncSession):
self.db = db
self.invite_repo = InviteRepository(db)
async def check_invite_validity(self, identifier: str) -> InviteCheckResponse:
"""
Check if an invite is valid and can be used for signup.
Args:
identifier: Invite identifier to check
Returns:
InviteCheckResponse with validity status
"""
normalized = normalize_identifier(identifier)
# Validate format before querying database
if not is_valid_identifier_format(normalized):
return InviteCheckResponse(valid=False, error="Invalid invite code format")
invite = await self.invite_repo.get_by_identifier(normalized)
# Return same error for not found, spent, and revoked
# to avoid information leakage
if not invite or invite.status in (
InviteStatus.SPENT,
InviteStatus.REVOKED,
):
return InviteCheckResponse(valid=False, error="Invite not found")
return InviteCheckResponse(valid=True, status=invite.status.value)
async def get_user_invites(self, user_id: int) -> list[Invite]:
"""
Get all invites owned by a user.
Args:
user_id: ID of the godfather user
Returns:
List of Invite records, most recent first
"""
return await self.invite_repo.get_by_godfather_id(user_id, order_by_desc=True)
async def list_invites(
self,
page: int,
per_page: int,
status_filter: str | None = None,
godfather_id: int | None = None,
) -> PaginatedInviteRecords:
"""
List invites with pagination and filtering.
Args:
page: Page number (1-indexed)
per_page: Number of records per page
status_filter: Optional status filter (ready, spent, revoked)
godfather_id: Optional godfather user ID filter
Returns:
PaginatedInviteRecords with invites and pagination metadata
Raises:
BadRequestError: If status_filter is invalid
"""
# Validate status filter if provided
status_enum = None
if status_filter:
status_enum = validate_enum(InviteStatus, status_filter, "status")
# Get total count
total = await self.invite_repo.count(
status=status_enum, godfather_id=godfather_id
)
# Get paginated invites
invites = await self.invite_repo.list_paginated(
page=page,
per_page=per_page,
status=status_enum,
godfather_id=godfather_id,
)
# Build responses using preloaded relationships
records = [InviteMapper.to_response(invite) for invite in invites]
return create_paginated_response(records, total, page, per_page)
async def create_invite(self, godfather_id: int) -> Invite:
"""
Create a new invite for a specified godfather user.
Args:
godfather_id: ID of the godfather user
Returns:
Created Invite record
Raises:
BadRequestError: If godfather user not found
ConflictError: If unable to generate unique invite code after retries
"""
from repositories.user import UserRepository
# Validate godfather exists
user_repo = UserRepository(self.db)
godfather = await user_repo.get_by_id(godfather_id)
if not godfather:
raise BadRequestError("Godfather user not found")
# Try to create invite with retry on collision
invite: Invite | None = None
for attempt in range(MAX_INVITE_COLLISION_RETRIES):
identifier = generate_invite_identifier()
invite = Invite(
identifier=identifier,
godfather_id=godfather_id,
status=InviteStatus.READY,
)
self.db.add(invite)
try:
await self.db.commit()
await self.db.refresh(invite)
# Reload with relationships
from sqlalchemy import select
from sqlalchemy.orm import joinedload
result = await self.db.execute(
select(Invite)
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
.where(Invite.id == invite.id)
)
invite = result.scalar_one()
break
except IntegrityError:
await self.db.rollback()
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
raise ConflictError(
"Failed to generate unique invite code. Try again."
) from None
if invite is None:
raise BadRequestError("Failed to create invite")
return invite
async def revoke_invite(self, invite_id: int) -> Invite:
"""
Revoke an invite. Only READY invites can be revoked.
Args:
invite_id: ID of the invite to revoke
Returns:
Revoked Invite record
Raises:
NotFoundError: If invite not found
BadRequestError: If invite cannot be revoked (not READY)
"""
invite = await self.invite_repo.get_by_id(invite_id)
if not invite:
raise NotFoundError("Invite")
if invite.status != InviteStatus.READY:
raise BadRequestError(
f"Cannot revoke invite with status '{invite.status.value}'. "
"Only READY invites can be revoked."
)
invite.status = InviteStatus.REVOKED
invite.revoked_at = datetime.now(UTC)
await self.db.commit()
await self.db.refresh(invite)
return invite