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
114
backend/services/auth.py
Normal file
114
backend/services/auth.py
Normal 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
|
||||
|
|
@ -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
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
|
||||
82
backend/services/profile.py
Normal file
82
backend/services/profile.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue