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):
|
class ServiceUnavailableError(APIError):
|
||||||
"""Service unavailable error (503)."""
|
"""Service unavailable error (503)."""
|
||||||
|
|
||||||
|
|
@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError):
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
message=message,
|
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 import desc, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from models import Invite, InviteStatus
|
from models import Invite, InviteStatus
|
||||||
|
|
||||||
|
|
@ -13,22 +14,32 @@ class InviteRepository:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get_by_identifier(self, identifier: str) -> Invite | None:
|
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(
|
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_by_id(self, invite_id: int) -> Invite | None:
|
async def get_by_id(self, invite_id: int) -> Invite | None:
|
||||||
"""Get an invite by ID."""
|
"""Get an invite by ID, eagerly loading relationships."""
|
||||||
result = await self.db.execute(select(Invite).where(Invite.id == invite_id))
|
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_by_godfather_id(
|
async def get_by_godfather_id(
|
||||||
self, godfather_id: int, order_by_desc: bool = True
|
self, godfather_id: int, order_by_desc: bool = True
|
||||||
) -> list[Invite]:
|
) -> list[Invite]:
|
||||||
"""Get all invites for a godfather user."""
|
"""Get all invites for a godfather user, eagerly loading relationships."""
|
||||||
query = select(Invite).where(Invite.godfather_id == godfather_id)
|
query = (
|
||||||
|
select(Invite)
|
||||||
|
.options(joinedload(Invite.used_by))
|
||||||
|
.where(Invite.godfather_id == godfather_id)
|
||||||
|
)
|
||||||
if order_by_desc:
|
if order_by_desc:
|
||||||
query = query.order_by(desc(Invite.created_at))
|
query = query.order_by(desc(Invite.created_at))
|
||||||
else:
|
else:
|
||||||
|
|
@ -57,9 +68,11 @@ class InviteRepository:
|
||||||
status: InviteStatus | None = None,
|
status: InviteStatus | None = None,
|
||||||
godfather_id: int | None = None,
|
godfather_id: int | None = None,
|
||||||
) -> list[Invite]:
|
) -> list[Invite]:
|
||||||
"""Get paginated list of invites."""
|
"""Get paginated list of invites, eagerly loading relationships."""
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
query = select(Invite)
|
query = select(Invite).options(
|
||||||
|
joinedload(Invite.godfather), joinedload(Invite.used_by)
|
||||||
|
)
|
||||||
if status:
|
if status:
|
||||||
query = query.where(Invite.status == status)
|
query = query.where(Invite.status == status)
|
||||||
if godfather_id:
|
if godfather_id:
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,12 @@ class UserRepository:
|
||||||
"""Get a user by ID."""
|
"""Get a user by ID."""
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
return result.scalar_one_or_none()
|
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."""
|
"""Authentication routes for register, login, logout, and current user."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
COOKIE_SECURE,
|
COOKIE_SECURE,
|
||||||
authenticate_user,
|
|
||||||
build_user_response,
|
build_user_response,
|
||||||
create_access_token,
|
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_password_hash,
|
|
||||||
get_user_by_email,
|
|
||||||
)
|
)
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from invite_utils import normalize_identifier
|
from models import User
|
||||||
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
|
|
||||||
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
||||||
|
from services.auth import AuthService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
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)
|
@router.post("/register", response_model=UserResponse)
|
||||||
async def register(
|
async def register(
|
||||||
user_data: RegisterWithInvite,
|
user_data: RegisterWithInvite,
|
||||||
|
|
@ -50,51 +37,13 @@ async def register(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
"""Register a new user using an invite code."""
|
"""Register a new user using an invite code."""
|
||||||
# Validate invite
|
service = AuthService(db)
|
||||||
normalized_identifier = normalize_identifier(user_data.invite_identifier)
|
user, access_token = await service.register_user(
|
||||||
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(
|
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
hashed_password=get_password_hash(user_data.password),
|
password=user_data.password,
|
||||||
godfather_id=invite.godfather_id,
|
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)
|
set_auth_cookie(response, access_token)
|
||||||
return await build_user_response(user, db)
|
return await build_user_response(user, db)
|
||||||
|
|
||||||
|
|
@ -106,14 +55,11 @@ async def login(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
"""Authenticate a user and return their info with an auth cookie."""
|
"""Authenticate a user and return their info with an auth cookie."""
|
||||||
user = await authenticate_user(db, user_data.email, user_data.password)
|
service = AuthService(db)
|
||||||
if not user:
|
user, access_token = await service.login_user(
|
||||||
raise HTTPException(
|
email=user_data.email, password=user_data.password
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect email or password",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": str(user.id)})
|
|
||||||
set_auth_cookie(response, access_token)
|
set_auth_cookie(response, access_token)
|
||||||
return await build_user_response(user, db)
|
return await build_user_response(user, db)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
"""Exchange routes for Bitcoin trading."""
|
"""Exchange routes for Bitcoin trading."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from date_validation import validate_date_in_range
|
|
||||||
from mappers import ExchangeMapper
|
from mappers import ExchangeMapper
|
||||||
from models import (
|
from models import (
|
||||||
Availability,
|
|
||||||
BitcoinTransferMethod,
|
BitcoinTransferMethod,
|
||||||
ExchangeStatus,
|
ExchangeStatus,
|
||||||
Permission,
|
Permission,
|
||||||
|
|
@ -25,7 +23,6 @@ from repositories.price import PriceRepository
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminExchangeResponse,
|
AdminExchangeResponse,
|
||||||
AvailableSlotsResponse,
|
AvailableSlotsResponse,
|
||||||
BookableSlot,
|
|
||||||
ExchangeConfigResponse,
|
ExchangeConfigResponse,
|
||||||
ExchangePriceResponse,
|
ExchangePriceResponse,
|
||||||
ExchangeRequest,
|
ExchangeRequest,
|
||||||
|
|
@ -39,7 +36,6 @@ from shared_constants import (
|
||||||
EUR_TRADE_MAX,
|
EUR_TRADE_MAX,
|
||||||
EUR_TRADE_MIN,
|
EUR_TRADE_MIN,
|
||||||
PREMIUM_PERCENTAGE,
|
PREMIUM_PERCENTAGE,
|
||||||
SLOT_DURATION_MINUTES,
|
|
||||||
)
|
)
|
||||||
from utils.enum_validation import validate_enum
|
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)
|
@router.get("/slots", response_model=AvailableSlotsResponse)
|
||||||
async def get_available_slots(
|
async def get_available_slots(
|
||||||
date_param: date = Query(..., alias="date"),
|
date_param: date = Query(..., alias="date"),
|
||||||
|
|
@ -185,32 +157,8 @@ async def get_available_slots(
|
||||||
- Fall within admin-defined availability windows
|
- Fall within admin-defined availability windows
|
||||||
- Are not already booked by another user
|
- Are not already booked by another user
|
||||||
"""
|
"""
|
||||||
validate_date_in_range(date_param, context="book")
|
service = ExchangeService(db)
|
||||||
|
return await service.get_available_slots(date_param)
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
"""Invite routes for public check, user invites, and admin management."""
|
"""Invite routes for public check, user invites, and admin management."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy import desc, func, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
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 mappers import InviteMapper
|
||||||
from models import Invite, InviteStatus, Permission, User
|
from models import Permission, User
|
||||||
from pagination import calculate_offset, create_paginated_response
|
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminUserResponse,
|
AdminUserResponse,
|
||||||
InviteCheckResponse,
|
InviteCheckResponse,
|
||||||
|
|
@ -26,12 +16,11 @@ from schemas import (
|
||||||
PaginatedInviteRecords,
|
PaginatedInviteRecords,
|
||||||
UserInviteResponse,
|
UserInviteResponse,
|
||||||
)
|
)
|
||||||
|
from services.invite import InviteService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
||||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
MAX_INVITE_COLLISION_RETRIES = 3
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
||||||
async def check_invite(
|
async def check_invite(
|
||||||
|
|
@ -39,20 +28,8 @@ async def check_invite(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> InviteCheckResponse:
|
) -> InviteCheckResponse:
|
||||||
"""Check if an invite is valid and can be used for signup."""
|
"""Check if an invite is valid and can be used for signup."""
|
||||||
normalized = normalize_identifier(identifier)
|
service = InviteService(db)
|
||||||
|
return await service.check_invite_validity(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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserInviteResponse])
|
@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)),
|
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
|
||||||
) -> list[UserInviteResponse]:
|
) -> list[UserInviteResponse]:
|
||||||
"""Get all invites owned by the current user."""
|
"""Get all invites owned by the current user."""
|
||||||
result = await db.execute(
|
service = InviteService(db)
|
||||||
select(Invite)
|
invites = await service.get_user_invites(current_user.id)
|
||||||
.where(Invite.godfather_id == current_user.id)
|
|
||||||
.order_by(desc(Invite.created_at))
|
|
||||||
)
|
|
||||||
invites = result.scalars().all()
|
|
||||||
|
|
||||||
# Use preloaded used_by relationship (selectin loading)
|
|
||||||
return [
|
return [
|
||||||
UserInviteResponse(
|
UserInviteResponse(
|
||||||
id=invite.id,
|
id=invite.id,
|
||||||
|
|
@ -88,6 +60,8 @@ async def list_users_for_admin(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> list[AdminUserResponse]:
|
) -> list[AdminUserResponse]:
|
||||||
"""List all users for admin dropdowns (invite creation, etc.)."""
|
"""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))
|
result = await db.execute(select(User.id, User.email).order_by(User.email))
|
||||||
users = result.all()
|
users = result.all()
|
||||||
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
|
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)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> InviteResponse:
|
) -> InviteResponse:
|
||||||
"""Create a new invite for a specified godfather user."""
|
"""Create a new invite for a specified godfather user."""
|
||||||
# Validate godfather exists
|
service = InviteService(db)
|
||||||
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
|
invite = await service.create_invite(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",
|
|
||||||
)
|
|
||||||
return InviteMapper.to_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,41 +91,13 @@ async def list_all_invites(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> PaginatedInviteRecords:
|
) -> PaginatedInviteRecords:
|
||||||
"""List all invites with optional filtering and pagination."""
|
"""List all invites with optional filtering and pagination."""
|
||||||
# Build query
|
service = InviteService(db)
|
||||||
query = select(Invite)
|
return await service.list_invites(
|
||||||
count_query = select(func.count(Invite.id))
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
# Apply filters
|
status_filter=status_filter,
|
||||||
if status_filter:
|
godfather_id=godfather_id,
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse)
|
@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)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> InviteResponse:
|
) -> InviteResponse:
|
||||||
"""Revoke an invite. Only READY invites can be revoked."""
|
"""Revoke an invite. Only READY invites can be revoked."""
|
||||||
result = await db.execute(select(Invite).where(Invite.id == invite_id))
|
service = InviteService(db)
|
||||||
invite = result.scalar_one_or_none()
|
invite = await service.revoke_invite(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 db.commit()
|
|
||||||
await db.refresh(invite)
|
|
||||||
|
|
||||||
return InviteMapper.to_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,25 @@
|
||||||
"""Profile routes for user contact details."""
|
"""Profile routes for user contact details."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Permission, User
|
from models import Permission, User
|
||||||
from schemas import ProfileResponse, ProfileUpdate
|
from schemas import ProfileResponse, ProfileUpdate
|
||||||
from validation import validate_profile_fields
|
from services.profile import ProfileService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
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)
|
@router.get("", response_model=ProfileResponse)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ProfileResponse:
|
) -> ProfileResponse:
|
||||||
"""Get the current user's profile (contact details and godfather)."""
|
"""Get the current user's profile (contact details and godfather)."""
|
||||||
godfather_email = await get_godfather_email(db, current_user.godfather_id)
|
service = ProfileService(db)
|
||||||
|
return await service.get_profile(current_user)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=ProfileResponse)
|
@router.put("", response_model=ProfileResponse)
|
||||||
|
|
@ -45,36 +29,5 @@ async def update_profile(
|
||||||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||||
) -> ProfileResponse:
|
) -> ProfileResponse:
|
||||||
"""Update the current user's profile (contact details)."""
|
"""Update the current user's profile (contact details)."""
|
||||||
# Validate all fields
|
service = ProfileService(db)
|
||||||
errors = validate_profile_fields(
|
return await service.update_profile(current_user, data)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
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,
|
ServiceUnavailableError,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
|
Availability,
|
||||||
BitcoinTransferMethod,
|
BitcoinTransferMethod,
|
||||||
Exchange,
|
Exchange,
|
||||||
ExchangeStatus,
|
ExchangeStatus,
|
||||||
|
|
@ -21,8 +22,10 @@ from models import (
|
||||||
TradeDirection,
|
TradeDirection,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from repositories.availability import AvailabilityRepository
|
||||||
from repositories.exchange import ExchangeRepository
|
from repositories.exchange import ExchangeRepository
|
||||||
from repositories.price import PriceRepository
|
from repositories.price import PriceRepository
|
||||||
|
from schemas import AvailableSlotsResponse, BookableSlot
|
||||||
from shared_constants import (
|
from shared_constants import (
|
||||||
EUR_TRADE_INCREMENT,
|
EUR_TRADE_INCREMENT,
|
||||||
EUR_TRADE_MAX,
|
EUR_TRADE_MAX,
|
||||||
|
|
@ -44,6 +47,7 @@ class ExchangeService:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.price_repo = PriceRepository(db)
|
self.price_repo = PriceRepository(db)
|
||||||
self.exchange_repo = ExchangeRepository(db)
|
self.exchange_repo = ExchangeRepository(db)
|
||||||
|
self.availability_repo = AvailabilityRepository(db)
|
||||||
|
|
||||||
def apply_premium_for_direction(
|
def apply_premium_for_direction(
|
||||||
self,
|
self,
|
||||||
|
|
@ -379,3 +383,73 @@ class ExchangeService:
|
||||||
await self.db.refresh(exchange)
|
await self.db.refresh(exchange)
|
||||||
|
|
||||||
return 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
|
return f"unique-word-{call_count:02d}" # Won't collide
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"routes.invites.generate_invite_identifier", side_effect=mock_generator
|
"services.invite.generate_invite_identifier", side_effect=mock_generator
|
||||||
):
|
):
|
||||||
response2 = await client.post(
|
response2 = await client.post(
|
||||||
"/api/admin/invites",
|
"/api/admin/invites",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue