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
This commit is contained in:
counterweight 2025-12-25 18:42:46 +01:00
parent c3a501e3b2
commit 280c1e5687
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
12 changed files with 571 additions and 303 deletions

206
backend/services/invite.py Normal file
View file

@ -0,0 +1,206 @@
"""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