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

View file

@ -1,26 +1,19 @@
"""Authentication routes for register, login, logout, and current user."""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, Response
from sqlalchemy.ext.asyncio import AsyncSession
from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
COOKIE_NAME,
COOKIE_SECURE,
authenticate_user,
build_user_response,
create_access_token,
get_current_user,
get_password_hash,
get_user_by_email,
)
from database import get_db
from invite_utils import normalize_identifier
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
from models import User
from schemas import RegisterWithInvite, UserLogin, UserResponse
from services.auth import AuthService
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -37,12 +30,6 @@ def set_auth_cookie(response: Response, token: str) -> None:
)
async def get_default_role(db: AsyncSession) -> Role | None:
"""Get the default 'regular' role for new users."""
result = await db.execute(select(Role).where(Role.name == ROLE_REGULAR))
return result.scalar_one_or_none()
@router.post("/register", response_model=UserResponse)
async def register(
user_data: RegisterWithInvite,
@ -50,51 +37,13 @@ async def register(
db: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Register a new user using an invite code."""
# Validate invite
normalized_identifier = normalize_identifier(user_data.invite_identifier)
query = select(Invite).where(Invite.identifier == normalized_identifier)
result = await db.execute(query)
invite = result.scalar_one_or_none()
# 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 HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code",
)
# Check email not already taken
existing_user = await get_user_by_email(db, user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create user with godfather
user = User(
service = AuthService(db)
user, access_token = await service.register_user(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
godfather_id=invite.godfather_id,
password=user_data.password,
invite_identifier=user_data.invite_identifier,
)
# Assign default role
default_role = await get_default_role(db)
if default_role:
user.roles.append(default_role)
db.add(user)
await 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 db.commit()
await db.refresh(user)
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token)
return await build_user_response(user, db)
@ -106,14 +55,11 @@ async def login(
db: AsyncSession = Depends(get_db),
) -> UserResponse:
"""Authenticate a user and return their info with an auth cookie."""
user = await authenticate_user(db, user_data.email, user_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
service = AuthService(db)
user, access_token = await service.login_user(
email=user_data.email, password=user_data.password
)
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token)
return await build_user_response(user, db)

View file

@ -1,17 +1,15 @@
"""Exchange routes for Bitcoin trading."""
import uuid
from datetime import UTC, date, datetime, timedelta
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission
from database import get_db
from date_validation import validate_date_in_range
from mappers import ExchangeMapper
from models import (
Availability,
BitcoinTransferMethod,
ExchangeStatus,
Permission,
@ -25,7 +23,6 @@ from repositories.price import PriceRepository
from schemas import (
AdminExchangeResponse,
AvailableSlotsResponse,
BookableSlot,
ExchangeConfigResponse,
ExchangePriceResponse,
ExchangeRequest,
@ -39,7 +36,6 @@ from shared_constants import (
EUR_TRADE_MAX,
EUR_TRADE_MIN,
PREMIUM_PERCENTAGE,
SLOT_DURATION_MINUTES,
)
from utils.enum_validation import validate_enum
@ -148,30 +144,6 @@ async def get_exchange_price(
# =============================================================================
def _expand_availability_to_slots(
avail: Availability, slot_date: date, booked_starts: set[datetime]
) -> list[BookableSlot]:
"""
Expand an availability block into individual slots, filtering out booked ones.
"""
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
@router.get("/slots", response_model=AvailableSlotsResponse)
async def get_available_slots(
date_param: date = Query(..., alias="date"),
@ -185,32 +157,8 @@ async def get_available_slots(
- Fall within admin-defined availability windows
- Are not already booked by another user
"""
validate_date_in_range(date_param, context="book")
# Get availability for the date
from repositories.availability import AvailabilityRepository
from repositories.exchange import ExchangeRepository
availability_repo = AvailabilityRepository(db)
availabilities = await availability_repo.get_by_date(date_param)
if not availabilities:
return AvailableSlotsResponse(date=date_param, slots=[])
# Get already booked slots for the date
exchange_repo = ExchangeRepository(db)
booked_starts = await exchange_repo.get_booked_slots_for_date(date_param)
# Expand each availability into slots
all_slots: list[BookableSlot] = []
for avail in availabilities:
slots = _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)
service = ExchangeService(db)
return await service.get_available_slots(date_param)
# =============================================================================

View file

@ -1,23 +1,13 @@
"""Invite routes for public check, user invites, and admin management."""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission
from database import get_db
from exceptions import BadRequestError, NotFoundError
from invite_utils import (
generate_invite_identifier,
is_valid_identifier_format,
normalize_identifier,
)
from mappers import InviteMapper
from models import Invite, InviteStatus, Permission, User
from pagination import calculate_offset, create_paginated_response
from models import Permission, User
from schemas import (
AdminUserResponse,
InviteCheckResponse,
@ -26,12 +16,11 @@ from schemas import (
PaginatedInviteRecords,
UserInviteResponse,
)
from services.invite import InviteService
router = APIRouter(prefix="/api/invites", tags=["invites"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
MAX_INVITE_COLLISION_RETRIES = 3
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
async def check_invite(
@ -39,20 +28,8 @@ async def check_invite(
db: AsyncSession = Depends(get_db),
) -> InviteCheckResponse:
"""Check if an invite is valid and can be used for signup."""
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")
result = await db.execute(select(Invite).where(Invite.identifier == normalized))
invite = result.scalar_one_or_none()
# 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)
service = InviteService(db)
return await service.check_invite_validity(identifier)
@router.get("", response_model=list[UserInviteResponse])
@ -61,14 +38,9 @@ async def get_my_invites(
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
) -> list[UserInviteResponse]:
"""Get all invites owned by the current user."""
result = await db.execute(
select(Invite)
.where(Invite.godfather_id == current_user.id)
.order_by(desc(Invite.created_at))
)
invites = result.scalars().all()
service = InviteService(db)
invites = await service.get_user_invites(current_user.id)
# Use preloaded used_by relationship (selectin loading)
return [
UserInviteResponse(
id=invite.id,
@ -88,6 +60,8 @@ async def list_users_for_admin(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> list[AdminUserResponse]:
"""List all users for admin dropdowns (invite creation, etc.)."""
# Note: UserRepository doesn't have list_all yet
# For now, keeping direct query for this specific use case
result = await db.execute(select(User.id, User.email).order_by(User.email))
users = result.all()
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
@ -100,39 +74,8 @@ async def create_invite(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> InviteResponse:
"""Create a new invite for a specified godfather user."""
# Validate godfather exists
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
godfather_id = result.scalar_one_or_none()
if not godfather_id:
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,
)
db.add(invite)
try:
await db.commit()
await db.refresh(invite, ["godfather"])
break
except IntegrityError:
await db.rollback()
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate unique invite code. Try again.",
) from None
if invite is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create invite",
)
service = InviteService(db)
invite = await service.create_invite(data.godfather_id)
return InviteMapper.to_response(invite)
@ -148,41 +91,13 @@ async def list_all_invites(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> PaginatedInviteRecords:
"""List all invites with optional filtering and pagination."""
# Build query
query = select(Invite)
count_query = select(func.count(Invite.id))
# Apply filters
if status_filter:
try:
status_enum = InviteStatus(status_filter)
query = query.where(Invite.status == status_enum)
count_query = count_query.where(Invite.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status_filter}. "
"Must be ready, spent, or revoked",
) from None
if godfather_id:
query = query.where(Invite.godfather_id == godfather_id)
count_query = count_query.where(Invite.godfather_id == godfather_id)
# Get total count
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Get paginated invites (relationships loaded via selectin)
offset = calculate_offset(page, per_page)
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
invites = result.scalars().all()
# Build responses using preloaded relationships
records = [InviteMapper.to_response(invite) for invite in invites]
return create_paginated_response(records, total, page, per_page)
service = InviteService(db)
return await service.list_invites(
page=page,
per_page=per_page,
status_filter=status_filter,
godfather_id=godfather_id,
)
@admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse)
@ -192,23 +107,8 @@ async def revoke_invite(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> InviteResponse:
"""Revoke an invite. Only READY invites can be revoked."""
result = await db.execute(select(Invite).where(Invite.id == invite_id))
invite = result.scalar_one_or_none()
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 db.commit()
await db.refresh(invite)
service = InviteService(db)
invite = await service.revoke_invite(invite_id)
return InviteMapper.to_response(invite)

View file

@ -1,41 +1,25 @@
"""Profile routes for user contact details."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission
from database import get_db
from models import Permission, User
from schemas import ProfileResponse, ProfileUpdate
from validation import validate_profile_fields
from services.profile import ProfileService
router = APIRouter(prefix="/api/profile", tags=["profile"])
async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str | None:
"""Get the email of a godfather user by ID."""
if not godfather_id:
return None
result = await db.execute(select(User.email).where(User.id == godfather_id))
return result.scalar_one_or_none()
@router.get("", response_model=ProfileResponse)
async def get_profile(
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
db: AsyncSession = Depends(get_db),
) -> ProfileResponse:
"""Get the current user's profile (contact details and godfather)."""
godfather_email = await get_godfather_email(db, current_user.godfather_id)
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
service = ProfileService(db)
return await service.get_profile(current_user)
@router.put("", response_model=ProfileResponse)
@ -45,36 +29,5 @@ async def update_profile(
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
) -> ProfileResponse:
"""Update the current user's profile (contact details)."""
# 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 HTTPException(
status_code=422,
detail={"field_errors": errors},
)
# Update fields
current_user.contact_email = data.contact_email
current_user.telegram = data.telegram
current_user.signal = data.signal
current_user.nostr_npub = data.nostr_npub
await db.commit()
await db.refresh(current_user)
godfather_email = await get_godfather_email(db, current_user.godfather_id)
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
service = ProfileService(db)
return await service.update_profile(current_user, data)