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:
parent
c3a501e3b2
commit
280c1e5687
12 changed files with 571 additions and 303 deletions
206
backend/services/invite.py
Normal file
206
backend/services/invite.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue