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

114
backend/services/auth.py Normal file
View file

@ -0,0 +1,114 @@
"""Authentication service for user registration and login."""
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from auth import (
create_access_token,
get_password_hash,
)
from exceptions import BadRequestError, UnauthorizedError
from invite_utils import normalize_identifier
from models import ROLE_REGULAR, InviteStatus, User
from repositories.invite import InviteRepository
from repositories.role import RoleRepository
from repositories.user import UserRepository
class AuthService:
"""Service for authentication-related business logic."""
def __init__(self, db: AsyncSession):
self.db = db
self.user_repo = UserRepository(db)
self.invite_repo = InviteRepository(db)
self.role_repo = RoleRepository(db)
async def register_user(
self, email: str, password: str, invite_identifier: str
) -> tuple[User, str]:
"""
Register a new user using an invite code.
Args:
email: User email address
password: Plain text password (will be hashed)
invite_identifier: Invite code identifier
Returns:
Tuple of (User, access_token)
Raises:
BadRequestError: If invite is invalid, email already taken,
or other validation fails
"""
# Validate invite
normalized_identifier = normalize_identifier(invite_identifier)
invite = await self.invite_repo.get_by_identifier(normalized_identifier)
# Return same error for not found, spent, and revoked
# to avoid information leakage
if not invite or invite.status in (
InviteStatus.SPENT,
InviteStatus.REVOKED,
):
raise BadRequestError("Invalid invite code")
# Check email not already taken
existing_user = await self.user_repo.get_by_email(email)
if existing_user:
raise BadRequestError("Email already registered")
# Create user with godfather
user = User(
email=email,
hashed_password=get_password_hash(password),
godfather_id=invite.godfather_id,
)
# Assign default role
default_role = await self.role_repo.get_by_name(ROLE_REGULAR)
if default_role:
user.roles.append(default_role)
self.db.add(user)
await self.db.flush() # Get user ID
# Mark invite as spent
invite.status = InviteStatus.SPENT
invite.used_by_id = user.id
invite.spent_at = datetime.now(UTC)
await self.db.commit()
await self.db.refresh(user)
# Create access token
access_token = create_access_token(data={"sub": str(user.id)})
return user, access_token
async def login_user(self, email: str, password: str) -> tuple[User, str]:
"""
Authenticate a user and create access token.
Args:
email: User email address
password: Plain text password
Returns:
Tuple of (User, access_token)
Raises:
BadRequestError: If authentication fails
"""
from auth import authenticate_user
user = await authenticate_user(self.db, email, password)
if not user:
raise UnauthorizedError("Incorrect email or password")
# Create access token
access_token = create_access_token(data={"sub": str(user.id)})
return user, access_token

View file

@ -14,6 +14,7 @@ from exceptions import (
ServiceUnavailableError,
)
from models import (
Availability,
BitcoinTransferMethod,
Exchange,
ExchangeStatus,
@ -21,8 +22,10 @@ from models import (
TradeDirection,
User,
)
from repositories.availability import AvailabilityRepository
from repositories.exchange import ExchangeRepository
from repositories.price import PriceRepository
from schemas import AvailableSlotsResponse, BookableSlot
from shared_constants import (
EUR_TRADE_INCREMENT,
EUR_TRADE_MAX,
@ -44,6 +47,7 @@ class ExchangeService:
self.db = db
self.price_repo = PriceRepository(db)
self.exchange_repo = ExchangeRepository(db)
self.availability_repo = AvailabilityRepository(db)
def apply_premium_for_direction(
self,
@ -379,3 +383,73 @@ class ExchangeService:
await self.db.refresh(exchange)
return exchange
def _expand_availability_to_slots(
self, avail: Availability, slot_date: date, booked_starts: set[datetime]
) -> list[BookableSlot]:
"""
Expand an availability block into individual slots, filtering out booked ones.
Args:
avail: Availability record
slot_date: Date for the slots
booked_starts: Set of already-booked slot start times
Returns:
List of available BookableSlot records
"""
slots: list[BookableSlot] = []
# Start from the availability's start time
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
# Only include if not already booked
if current_start not in booked_starts:
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
current_start = slot_end
return slots
async def get_available_slots(self, date_param: date) -> AvailableSlotsResponse:
"""
Get available booking slots for a specific date.
Returns all slots that:
- Fall within admin-defined availability windows
- Are not already booked by another user
Args:
date_param: Date to get slots for
Returns:
AvailableSlotsResponse with date and list of available slots
Raises:
BadRequestError: If date is out of range
"""
validate_date_in_range(date_param, context="book")
# Get availability for the date
availabilities = await self.availability_repo.get_by_date(date_param)
if not availabilities:
return AvailableSlotsResponse(date=date_param, slots=[])
# Get already booked slots for the date
booked_starts = await self.exchange_repo.get_booked_slots_for_date(date_param)
# Expand each availability into slots
all_slots: list[BookableSlot] = []
for avail in availabilities:
slots = self._expand_availability_to_slots(avail, date_param, booked_starts)
all_slots.extend(slots)
# Sort by start time
all_slots.sort(key=lambda s: s.start_time)
return AvailableSlotsResponse(date=date_param, slots=all_slots)

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

View file

@ -0,0 +1,82 @@
"""Profile service for managing user profile details."""
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions import ValidationError
from models import User
from repositories.user import UserRepository
from schemas import ProfileResponse, ProfileUpdate
from validation import validate_profile_fields
class ProfileService:
"""Service for profile-related business logic."""
def __init__(self, db: AsyncSession):
self.db = db
self.user_repo = UserRepository(db)
async def get_profile(self, user: User) -> ProfileResponse:
"""
Get user profile with godfather email.
Args:
user: The user to get profile for
Returns:
ProfileResponse with all profile fields and godfather email
"""
godfather_email = await self.user_repo.get_godfather_email(user.godfather_id)
return ProfileResponse(
contact_email=user.contact_email,
telegram=user.telegram,
signal=user.signal,
nostr_npub=user.nostr_npub,
godfather_email=godfather_email,
)
async def update_profile(self, user: User, data: ProfileUpdate) -> ProfileResponse:
"""
Validate and update profile fields.
Args:
user: The user to update
data: Profile update data
Returns:
ProfileResponse with updated fields
Raises:
ValidationError: If validation fails (with field_errors dict)
"""
# Validate all fields
errors = validate_profile_fields(
contact_email=data.contact_email,
telegram=data.telegram,
signal=data.signal,
nostr_npub=data.nostr_npub,
)
if errors:
# Keep field_errors format for backward compatibility with frontend
raise ValidationError(message="Validation failed", field_errors=errors)
# Update fields
user.contact_email = data.contact_email
user.telegram = data.telegram
user.signal = data.signal
user.nostr_npub = data.nostr_npub
await self.db.commit()
await self.db.refresh(user)
godfather_email = await self.user_repo.get_godfather_email(user.godfather_id)
return ProfileResponse(
contact_email=user.contact_email,
telegram=user.telegram,
signal=user.signal,
nostr_npub=user.nostr_npub,
godfather_email=godfather_email,
)