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
|
|
@ -51,6 +51,16 @@ class BadRequestError(APIError):
|
|||
)
|
||||
|
||||
|
||||
class UnauthorizedError(APIError):
|
||||
"""Unauthorized error (401)."""
|
||||
|
||||
def __init__(self, message: str = "Not authenticated"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class ServiceUnavailableError(APIError):
|
||||
"""Service unavailable error (503)."""
|
||||
|
||||
|
|
@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError):
|
|||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(HTTPException):
|
||||
"""Validation error (422) with field-specific errors."""
|
||||
|
||||
def __init__(self, message: str, field_errors: dict[str, str] | None = None):
|
||||
detail: dict[str, str | dict[str, str]] = {"message": message}
|
||||
if field_errors:
|
||||
detail["field_errors"] = field_errors
|
||||
super().__init__(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from models import Invite, InviteStatus
|
||||
|
||||
|
|
@ -13,22 +14,32 @@ class InviteRepository:
|
|||
self.db = db
|
||||
|
||||
async def get_by_identifier(self, identifier: str) -> Invite | None:
|
||||
"""Get an invite by identifier."""
|
||||
"""Get an invite by identifier, eagerly loading relationships."""
|
||||
result = await self.db.execute(
|
||||
select(Invite).where(Invite.identifier == identifier)
|
||||
select(Invite)
|
||||
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
|
||||
.where(Invite.identifier == identifier)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, invite_id: int) -> Invite | None:
|
||||
"""Get an invite by ID."""
|
||||
result = await self.db.execute(select(Invite).where(Invite.id == invite_id))
|
||||
"""Get an invite by ID, eagerly loading relationships."""
|
||||
result = await self.db.execute(
|
||||
select(Invite)
|
||||
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
|
||||
.where(Invite.id == invite_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_godfather_id(
|
||||
self, godfather_id: int, order_by_desc: bool = True
|
||||
) -> list[Invite]:
|
||||
"""Get all invites for a godfather user."""
|
||||
query = select(Invite).where(Invite.godfather_id == godfather_id)
|
||||
"""Get all invites for a godfather user, eagerly loading relationships."""
|
||||
query = (
|
||||
select(Invite)
|
||||
.options(joinedload(Invite.used_by))
|
||||
.where(Invite.godfather_id == godfather_id)
|
||||
)
|
||||
if order_by_desc:
|
||||
query = query.order_by(desc(Invite.created_at))
|
||||
else:
|
||||
|
|
@ -57,9 +68,11 @@ class InviteRepository:
|
|||
status: InviteStatus | None = None,
|
||||
godfather_id: int | None = None,
|
||||
) -> list[Invite]:
|
||||
"""Get paginated list of invites."""
|
||||
"""Get paginated list of invites, eagerly loading relationships."""
|
||||
offset = (page - 1) * per_page
|
||||
query = select(Invite)
|
||||
query = select(Invite).options(
|
||||
joinedload(Invite.godfather), joinedload(Invite.used_by)
|
||||
)
|
||||
if status:
|
||||
query = query.where(Invite.status == status)
|
||||
if godfather_id:
|
||||
|
|
|
|||
|
|
@ -21,3 +21,12 @@ class UserRepository:
|
|||
"""Get a user by ID."""
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_godfather_email(self, godfather_id: int | None) -> str | None:
|
||||
"""Get the email of a godfather user by ID."""
|
||||
if not godfather_id:
|
||||
return None
|
||||
result = await self.db.execute(
|
||||
select(User.email).where(User.id == godfather_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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,
|
||||
)
|
||||
|
|
@ -430,7 +430,7 @@ async def test_create_invite_retries_on_collision(
|
|||
return f"unique-word-{call_count:02d}" # Won't collide
|
||||
|
||||
with patch(
|
||||
"routes.invites.generate_invite_identifier", side_effect=mock_generator
|
||||
"services.invite.generate_invite_identifier", side_effect=mock_generator
|
||||
):
|
||||
response2 = await client.post(
|
||||
"/api/admin/invites",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue