refactors
This commit is contained in:
parent
139a5fbef3
commit
f46d2ae8b3
12 changed files with 734 additions and 536 deletions
|
|
@ -3,16 +3,16 @@
|
|||
import uuid
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from date_validation import validate_date_in_range
|
||||
from exceptions import BadRequestError
|
||||
from mappers import ExchangeMapper
|
||||
from models import (
|
||||
Availability,
|
||||
BitcoinTransferMethod,
|
||||
|
|
@ -24,169 +24,35 @@ from models import (
|
|||
User,
|
||||
)
|
||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||
from repositories.price import PriceRepository
|
||||
from schemas import (
|
||||
AdminExchangeResponse,
|
||||
AvailableSlotsResponse,
|
||||
BookableSlot,
|
||||
ExchangeConfigResponse,
|
||||
ExchangePriceResponse,
|
||||
ExchangeRequest,
|
||||
ExchangeResponse,
|
||||
ExchangeUserContact,
|
||||
PriceResponse,
|
||||
UserSearchResult,
|
||||
)
|
||||
from services.exchange import ExchangeService
|
||||
from shared_constants import (
|
||||
EUR_TRADE_INCREMENT,
|
||||
EUR_TRADE_MAX,
|
||||
EUR_TRADE_MIN,
|
||||
LIGHTNING_MAX_EUR,
|
||||
PREMIUM_PERCENTAGE,
|
||||
PRICE_STALENESS_SECONDS,
|
||||
SLOT_DURATION_MINUTES,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
||||
|
||||
# =============================================================================
|
||||
# Constants for satoshi calculations
|
||||
# =============================================================================
|
||||
|
||||
SATS_PER_BTC = 100_000_000
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic models for price endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ExchangeConfigResponse(BaseModel):
|
||||
"""Exchange configuration for the frontend."""
|
||||
|
||||
eur_min: int
|
||||
eur_max: int
|
||||
eur_increment: int
|
||||
premium_percentage: int
|
||||
|
||||
|
||||
class PriceResponse(BaseModel):
|
||||
"""Current BTC/EUR price for trading.
|
||||
|
||||
Note: The actual agreed price depends on trade direction (buy/sell)
|
||||
and is calculated by the frontend using market_price and premium_percentage.
|
||||
"""
|
||||
|
||||
market_price: float # Raw price from exchange
|
||||
premium_percentage: int
|
||||
timestamp: datetime
|
||||
is_stale: bool
|
||||
|
||||
|
||||
class ExchangePriceResponse(BaseModel):
|
||||
"""Combined price and configuration response."""
|
||||
|
||||
price: PriceResponse | None # None if price fetch failed
|
||||
config: ExchangeConfigResponse
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BookableSlot(BaseModel):
|
||||
"""A single bookable time slot."""
|
||||
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
|
||||
|
||||
class AvailableSlotsResponse(BaseModel):
|
||||
"""Response containing available slots for a date."""
|
||||
|
||||
date: date
|
||||
slots: list[BookableSlot]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def apply_premium_for_direction(
|
||||
market_price: float,
|
||||
premium_percentage: int,
|
||||
direction: TradeDirection,
|
||||
) -> float:
|
||||
"""
|
||||
Apply premium to market price based on trade direction.
|
||||
|
||||
The premium is always favorable to the admin:
|
||||
- When user BUYS BTC: user pays MORE (market * (1 + premium/100))
|
||||
- When user SELLS BTC: user receives LESS (market * (1 - premium/100))
|
||||
"""
|
||||
if direction == TradeDirection.BUY:
|
||||
return market_price * (1 + premium_percentage / 100)
|
||||
else: # SELL
|
||||
return market_price * (1 - premium_percentage / 100)
|
||||
|
||||
|
||||
def calculate_sats_amount(
|
||||
eur_cents: int,
|
||||
price_eur_per_btc: float,
|
||||
) -> int:
|
||||
"""
|
||||
Calculate satoshi amount from EUR cents and price.
|
||||
|
||||
Args:
|
||||
eur_cents: Amount in EUR cents (e.g., 10000 = €100)
|
||||
price_eur_per_btc: Price in EUR per BTC
|
||||
|
||||
Returns:
|
||||
Amount in satoshis
|
||||
"""
|
||||
eur_amount = eur_cents / 100
|
||||
btc_amount = eur_amount / price_eur_per_btc
|
||||
return int(btc_amount * SATS_PER_BTC)
|
||||
|
||||
|
||||
async def get_latest_price(db: AsyncSession) -> PriceHistory | None:
|
||||
"""Get the most recent price from the database."""
|
||||
query = (
|
||||
select(PriceHistory)
|
||||
.where(
|
||||
PriceHistory.source == SOURCE_BITFINEX, PriceHistory.pair == PAIR_BTC_EUR
|
||||
)
|
||||
.order_by(desc(PriceHistory.timestamp))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def is_price_stale(price_timestamp: datetime) -> bool:
|
||||
"""Check if a price is older than the staleness threshold."""
|
||||
age_seconds = (datetime.now(UTC) - price_timestamp).total_seconds()
|
||||
return age_seconds > PRICE_STALENESS_SECONDS
|
||||
|
||||
|
||||
def _to_exchange_response(
|
||||
exchange: Exchange,
|
||||
user_email: str | None = None,
|
||||
) -> ExchangeResponse:
|
||||
"""Convert an Exchange model to ExchangeResponse schema."""
|
||||
email = user_email if user_email is not None else exchange.user.email
|
||||
return ExchangeResponse(
|
||||
id=exchange.id,
|
||||
public_id=str(exchange.public_id),
|
||||
user_id=exchange.user_id,
|
||||
user_email=email,
|
||||
slot_start=exchange.slot_start,
|
||||
slot_end=exchange.slot_end,
|
||||
direction=exchange.direction.value,
|
||||
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
||||
eur_amount=exchange.eur_amount,
|
||||
sats_amount=exchange.sats_amount,
|
||||
market_price_eur=exchange.market_price_eur,
|
||||
agreed_price_eur=exchange.agreed_price_eur,
|
||||
premium_percentage=exchange.premium_percentage,
|
||||
status=exchange.status.value,
|
||||
created_at=exchange.created_at,
|
||||
cancelled_at=exchange.cancelled_at,
|
||||
completed_at=exchange.completed_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Price Endpoint
|
||||
# =============================================================================
|
||||
|
|
@ -216,11 +82,14 @@ async def get_exchange_price(
|
|||
premium_percentage=PREMIUM_PERCENTAGE,
|
||||
)
|
||||
|
||||
price_repo = PriceRepository(db)
|
||||
service = ExchangeService(db)
|
||||
|
||||
# Try to get the latest cached price
|
||||
cached_price = await get_latest_price(db)
|
||||
cached_price = await price_repo.get_latest()
|
||||
|
||||
# If no cached price or it's stale, try to fetch a new one
|
||||
if cached_price is None or is_price_stale(cached_price.timestamp):
|
||||
if cached_price is None or service.is_price_stale(cached_price.timestamp):
|
||||
try:
|
||||
price_value, timestamp = await fetch_btc_eur_price()
|
||||
|
||||
|
|
@ -270,7 +139,7 @@ async def get_exchange_price(
|
|||
market_price=cached_price.price,
|
||||
premium_percentage=PREMIUM_PERCENTAGE,
|
||||
timestamp=cached_price.timestamp,
|
||||
is_stale=is_price_stale(cached_price.timestamp),
|
||||
is_stale=service.is_price_stale(cached_price.timestamp),
|
||||
),
|
||||
config=config,
|
||||
)
|
||||
|
|
@ -377,194 +246,34 @@ async def create_exchange(
|
|||
- Price is not stale
|
||||
- EUR amount is within configured limits
|
||||
"""
|
||||
slot_date = request.slot_start.date()
|
||||
validate_date_in_range(slot_date, context="book")
|
||||
|
||||
# Check if user already has a trade on this date
|
||||
existing_trade_query = select(Exchange).where(
|
||||
and_(
|
||||
Exchange.user_id == current_user.id,
|
||||
Exchange.slot_start >= datetime.combine(slot_date, time.min, tzinfo=UTC),
|
||||
Exchange.slot_start
|
||||
< datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1),
|
||||
Exchange.status == ExchangeStatus.BOOKED,
|
||||
)
|
||||
)
|
||||
existing_trade_result = await db.execute(existing_trade_query)
|
||||
existing_trade = existing_trade_result.scalar_one_or_none()
|
||||
|
||||
if existing_trade:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. "
|
||||
f"Only one trade per day is allowed. "
|
||||
f"Trade ID: {existing_trade.public_id}"
|
||||
),
|
||||
)
|
||||
|
||||
# Validate direction
|
||||
try:
|
||||
direction = TradeDirection(request.direction)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'.",
|
||||
raise BadRequestError(
|
||||
f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
|
||||
) from None
|
||||
|
||||
# Validate bitcoin transfer method
|
||||
try:
|
||||
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
||||
"Must be 'onchain' or 'lightning'."
|
||||
),
|
||||
raise BadRequestError(
|
||||
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
||||
"Must be 'onchain' or 'lightning'."
|
||||
) from None
|
||||
|
||||
# Validate EUR amount
|
||||
if request.eur_amount < EUR_TRADE_MIN * 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"EUR amount must be at least €{EUR_TRADE_MIN}",
|
||||
)
|
||||
if request.eur_amount > EUR_TRADE_MAX * 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"EUR amount must be at most €{EUR_TRADE_MAX}",
|
||||
)
|
||||
if request.eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}",
|
||||
)
|
||||
|
||||
# Validate Lightning threshold
|
||||
if (
|
||||
bitcoin_transfer_method == BitcoinTransferMethod.LIGHTNING
|
||||
and request.eur_amount > LIGHTNING_MAX_EUR * 100
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Lightning payments are only allowed for amounts up to "
|
||||
f"€{LIGHTNING_MAX_EUR}. For amounts above €{LIGHTNING_MAX_EUR}, "
|
||||
"please use onchain transactions."
|
||||
),
|
||||
)
|
||||
|
||||
# Validate slot timing - compute valid boundaries from slot duration
|
||||
valid_minutes = tuple(range(0, 60, SLOT_DURATION_MINUTES))
|
||||
if request.slot_start.minute not in valid_minutes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary",
|
||||
)
|
||||
if request.slot_start.second != 0 or request.slot_start.microsecond != 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Slot start time must not have seconds or microseconds",
|
||||
)
|
||||
|
||||
# Verify slot falls within availability
|
||||
slot_start_time = request.slot_start.time()
|
||||
slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||
slot_end_time = slot_end_dt.time()
|
||||
|
||||
result = await db.execute(
|
||||
select(Availability).where(
|
||||
and_(
|
||||
Availability.date == slot_date,
|
||||
Availability.start_time <= slot_start_time,
|
||||
Availability.end_time >= slot_end_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
matching_availability = result.scalar_one_or_none()
|
||||
|
||||
if not matching_availability:
|
||||
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Selected slot at {slot_str} UTC is not available",
|
||||
)
|
||||
|
||||
# Get and validate price
|
||||
cached_price = await get_latest_price(db)
|
||||
|
||||
if cached_price is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Price data unavailable. Please try again later.",
|
||||
)
|
||||
|
||||
if is_price_stale(cached_price.timestamp):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Price is stale. Please refresh and try again.",
|
||||
)
|
||||
|
||||
# Calculate agreed price based on direction
|
||||
market_price = cached_price.price
|
||||
agreed_price = apply_premium_for_direction(
|
||||
market_price, PREMIUM_PERCENTAGE, direction
|
||||
)
|
||||
|
||||
# Calculate sats amount based on agreed price
|
||||
sats_amount = calculate_sats_amount(request.eur_amount, agreed_price)
|
||||
|
||||
# Check if slot is already booked (only consider BOOKED status, not cancelled)
|
||||
slot_booked_query = select(Exchange).where(
|
||||
and_(
|
||||
Exchange.slot_start == request.slot_start,
|
||||
Exchange.status == ExchangeStatus.BOOKED,
|
||||
)
|
||||
)
|
||||
slot_booked_result = await db.execute(slot_booked_query)
|
||||
slot_booked = slot_booked_result.scalar_one_or_none()
|
||||
|
||||
if slot_booked:
|
||||
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"This slot at {slot_str} UTC has already been booked. "
|
||||
"Select another slot."
|
||||
),
|
||||
)
|
||||
|
||||
# Create the exchange
|
||||
exchange = Exchange(
|
||||
user_id=current_user.id,
|
||||
# Use service to create exchange (handles all validation)
|
||||
service = ExchangeService(db)
|
||||
exchange = await service.create_exchange(
|
||||
user=current_user,
|
||||
slot_start=request.slot_start,
|
||||
slot_end=slot_end_dt,
|
||||
direction=direction,
|
||||
bitcoin_transfer_method=bitcoin_transfer_method,
|
||||
eur_amount=request.eur_amount,
|
||||
sats_amount=sats_amount,
|
||||
market_price_eur=market_price,
|
||||
agreed_price_eur=agreed_price,
|
||||
premium_percentage=PREMIUM_PERCENTAGE,
|
||||
status=ExchangeStatus.BOOKED,
|
||||
)
|
||||
|
||||
db.add(exchange)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(exchange)
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
# This should rarely happen now since we check explicitly above,
|
||||
# but keep it for other potential integrity violations
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Database constraint violation. Please try again.",
|
||||
) from e
|
||||
|
||||
return _to_exchange_response(exchange, current_user.email)
|
||||
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -587,7 +296,7 @@ async def get_my_trades(
|
|||
)
|
||||
exchanges = result.scalars().all()
|
||||
|
||||
return [_to_exchange_response(ex, current_user.email) for ex in exchanges]
|
||||
return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
|
||||
|
||||
|
||||
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
|
||||
|
|
@ -597,20 +306,10 @@ async def get_my_trade(
|
|||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
||||
) -> ExchangeResponse:
|
||||
"""Get a specific trade by public ID. User can only access their own trades."""
|
||||
result = await db.execute(
|
||||
select(Exchange).where(
|
||||
and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
|
||||
)
|
||||
)
|
||||
exchange = result.scalar_one_or_none()
|
||||
service = ExchangeService(db)
|
||||
exchange = await service.get_exchange_by_public_id(public_id, user=current_user)
|
||||
|
||||
if not exchange:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trade not found or you don't have permission to view it.",
|
||||
)
|
||||
|
||||
return _to_exchange_response(exchange, current_user.email)
|
||||
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||
|
||||
|
||||
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
|
||||
|
|
@ -620,48 +319,20 @@ async def cancel_my_trade(
|
|||
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
|
||||
) -> ExchangeResponse:
|
||||
"""Cancel one of the current user's exchanges."""
|
||||
# Get the exchange with eager loading of user relationship
|
||||
result = await db.execute(
|
||||
select(Exchange)
|
||||
.options(joinedload(Exchange.user))
|
||||
.where(Exchange.public_id == public_id)
|
||||
)
|
||||
exchange = result.scalar_one_or_none()
|
||||
service = ExchangeService(db)
|
||||
# Get exchange without user filter first to check ownership separately
|
||||
exchange = await service.get_exchange_by_public_id(public_id)
|
||||
|
||||
if not exchange:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trade not found",
|
||||
)
|
||||
|
||||
# Verify ownership
|
||||
# Check ownership - return 403 if user doesn't own it
|
||||
if exchange.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot cancel another user's trade",
|
||||
)
|
||||
|
||||
# Check if already in a final state
|
||||
if exchange.status != ExchangeStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel: status is '{exchange.status.value}'",
|
||||
)
|
||||
exchange = await service.cancel_exchange(exchange, current_user, is_admin=False)
|
||||
|
||||
# Check if slot time has already passed
|
||||
if exchange.slot_start <= datetime.now(UTC):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot cancel: trade slot time has already passed",
|
||||
)
|
||||
|
||||
exchange.status = ExchangeStatus.CANCELLED_BY_USER
|
||||
exchange.cancelled_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(exchange)
|
||||
|
||||
return _to_exchange_response(exchange, current_user.email)
|
||||
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -671,37 +342,6 @@ async def cancel_my_trade(
|
|||
admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"])
|
||||
|
||||
|
||||
def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
|
||||
"""Convert an Exchange model to AdminExchangeResponse with user contact."""
|
||||
user = exchange.user
|
||||
return AdminExchangeResponse(
|
||||
id=exchange.id,
|
||||
public_id=str(exchange.public_id),
|
||||
user_id=exchange.user_id,
|
||||
user_email=user.email,
|
||||
user_contact=ExchangeUserContact(
|
||||
email=user.email,
|
||||
contact_email=user.contact_email,
|
||||
telegram=user.telegram,
|
||||
signal=user.signal,
|
||||
nostr_npub=user.nostr_npub,
|
||||
),
|
||||
slot_start=exchange.slot_start,
|
||||
slot_end=exchange.slot_end,
|
||||
direction=exchange.direction.value,
|
||||
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
||||
eur_amount=exchange.eur_amount,
|
||||
sats_amount=exchange.sats_amount,
|
||||
market_price_eur=exchange.market_price_eur,
|
||||
agreed_price_eur=exchange.agreed_price_eur,
|
||||
premium_percentage=exchange.premium_percentage,
|
||||
status=exchange.status.value,
|
||||
created_at=exchange.created_at,
|
||||
cancelled_at=exchange.cancelled_at,
|
||||
completed_at=exchange.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
|
||||
async def get_upcoming_trades(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
|
@ -722,7 +362,7 @@ async def get_upcoming_trades(
|
|||
)
|
||||
exchanges = result.scalars().all()
|
||||
|
||||
return [_to_admin_exchange_response(ex) for ex in exchanges]
|
||||
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||
|
||||
|
||||
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
|
||||
|
|
@ -783,7 +423,7 @@ async def get_past_trades(
|
|||
result = await db.execute(query)
|
||||
exchanges = result.scalars().all()
|
||||
|
||||
return [_to_admin_exchange_response(ex) for ex in exchanges]
|
||||
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||
|
||||
|
||||
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
|
||||
|
|
@ -793,41 +433,11 @@ async def complete_trade(
|
|||
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
||||
) -> AdminExchangeResponse:
|
||||
"""Mark a trade as completed. Only possible after slot time has passed."""
|
||||
service = ExchangeService(db)
|
||||
exchange = await service.get_exchange_by_public_id(public_id)
|
||||
exchange = await service.complete_exchange(exchange)
|
||||
|
||||
result = await db.execute(
|
||||
select(Exchange)
|
||||
.options(joinedload(Exchange.user))
|
||||
.where(Exchange.public_id == public_id)
|
||||
)
|
||||
exchange = result.scalar_one_or_none()
|
||||
|
||||
if not exchange:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trade not found",
|
||||
)
|
||||
|
||||
# Check slot has passed
|
||||
if exchange.slot_start > datetime.now(UTC):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot complete: trade slot has not yet started",
|
||||
)
|
||||
|
||||
# Check status is BOOKED
|
||||
if exchange.status != ExchangeStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot complete: status is '{exchange.status.value}'",
|
||||
)
|
||||
|
||||
exchange.status = ExchangeStatus.COMPLETED
|
||||
exchange.completed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(exchange)
|
||||
|
||||
return _to_admin_exchange_response(exchange)
|
||||
return ExchangeMapper.to_admin_response(exchange)
|
||||
|
||||
|
||||
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
|
||||
|
|
@ -837,41 +447,11 @@ async def mark_no_show(
|
|||
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
||||
) -> AdminExchangeResponse:
|
||||
"""Mark a trade as no-show. Only possible after slot time has passed."""
|
||||
service = ExchangeService(db)
|
||||
exchange = await service.get_exchange_by_public_id(public_id)
|
||||
exchange = await service.mark_no_show(exchange)
|
||||
|
||||
result = await db.execute(
|
||||
select(Exchange)
|
||||
.options(joinedload(Exchange.user))
|
||||
.where(Exchange.public_id == public_id)
|
||||
)
|
||||
exchange = result.scalar_one_or_none()
|
||||
|
||||
if not exchange:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trade not found",
|
||||
)
|
||||
|
||||
# Check slot has passed
|
||||
if exchange.slot_start > datetime.now(UTC):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot mark as no-show: trade slot has not yet started",
|
||||
)
|
||||
|
||||
# Check status is BOOKED
|
||||
if exchange.status != ExchangeStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot mark as no-show: status is '{exchange.status.value}'",
|
||||
)
|
||||
|
||||
exchange.status = ExchangeStatus.NO_SHOW
|
||||
exchange.completed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(exchange)
|
||||
|
||||
return _to_admin_exchange_response(exchange)
|
||||
return ExchangeMapper.to_admin_response(exchange)
|
||||
|
||||
|
||||
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
|
||||
|
|
@ -881,34 +461,11 @@ async def admin_cancel_trade(
|
|||
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
|
||||
) -> AdminExchangeResponse:
|
||||
"""Cancel any trade (admin only)."""
|
||||
service = ExchangeService(db)
|
||||
exchange = await service.get_exchange_by_public_id(public_id)
|
||||
exchange = await service.cancel_exchange(exchange, _current_user, is_admin=True)
|
||||
|
||||
result = await db.execute(
|
||||
select(Exchange)
|
||||
.options(joinedload(Exchange.user))
|
||||
.where(Exchange.public_id == public_id)
|
||||
)
|
||||
exchange = result.scalar_one_or_none()
|
||||
|
||||
if not exchange:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trade not found",
|
||||
)
|
||||
|
||||
# Check status is BOOKED
|
||||
if exchange.status != ExchangeStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel: status is '{exchange.status.value}'",
|
||||
)
|
||||
|
||||
exchange.status = ExchangeStatus.CANCELLED_BY_ADMIN
|
||||
exchange.cancelled_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(exchange)
|
||||
|
||||
return _to_admin_exchange_response(exchange)
|
||||
return ExchangeMapper.to_admin_response(exchange)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -918,13 +475,6 @@ async def admin_cancel_trade(
|
|||
admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||
|
||||
|
||||
class UserSearchResult(BaseModel):
|
||||
"""Result item for user search."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
|
||||
|
||||
@admin_users_router.get("/search", response_model=list[UserSearchResult])
|
||||
async def search_users(
|
||||
q: str = Query(..., min_length=1, description="Search query for user email"),
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ 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 schemas import (
|
||||
|
|
@ -31,22 +33,6 @@ admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|||
MAX_INVITE_COLLISION_RETRIES = 3
|
||||
|
||||
|
||||
def _to_invite_response(invite: Invite) -> InviteResponse:
|
||||
"""Build an InviteResponse from an Invite with loaded relationships."""
|
||||
return InviteResponse(
|
||||
id=invite.id,
|
||||
identifier=invite.identifier,
|
||||
godfather_id=invite.godfather_id,
|
||||
godfather_email=invite.godfather.email,
|
||||
status=invite.status.value,
|
||||
used_by_id=invite.used_by_id,
|
||||
used_by_email=invite.used_by.email if invite.used_by else None,
|
||||
created_at=invite.created_at,
|
||||
spent_at=invite.spent_at,
|
||||
revoked_at=invite.revoked_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
||||
async def check_invite(
|
||||
identifier: str,
|
||||
|
|
@ -118,10 +104,7 @@ async def create_invite(
|
|||
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 HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Godfather user not found",
|
||||
)
|
||||
raise BadRequestError("Godfather user not found")
|
||||
|
||||
# Try to create invite with retry on collision
|
||||
invite: Invite | None = None
|
||||
|
|
@ -150,7 +133,7 @@ async def create_invite(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create invite",
|
||||
)
|
||||
return _to_invite_response(invite)
|
||||
return InviteMapper.to_response(invite)
|
||||
|
||||
|
||||
@admin_router.get("/invites", response_model=PaginatedInviteRecords)
|
||||
|
|
@ -197,7 +180,7 @@ async def list_all_invites(
|
|||
invites = result.scalars().all()
|
||||
|
||||
# Build responses using preloaded relationships
|
||||
records = [_to_invite_response(invite) for invite in invites]
|
||||
records = [InviteMapper.to_response(invite) for invite in invites]
|
||||
|
||||
return create_paginated_response(records, total, page, per_page)
|
||||
|
||||
|
|
@ -213,16 +196,12 @@ async def revoke_invite(
|
|||
invite = result.scalar_one_or_none()
|
||||
|
||||
if not invite:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invite not found",
|
||||
)
|
||||
raise NotFoundError("Invite")
|
||||
|
||||
if invite.status != InviteStatus.READY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot revoke invite with status '{invite.status.value}'. "
|
||||
"Only READY invites can be revoked.",
|
||||
raise BadRequestError(
|
||||
f"Cannot revoke invite with status '{invite.status.value}'. "
|
||||
"Only READY invites can be revoked."
|
||||
)
|
||||
|
||||
invite.status = InviteStatus.REVOKED
|
||||
|
|
@ -230,7 +209,7 @@ async def revoke_invite(
|
|||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
return _to_invite_response(invite)
|
||||
return InviteMapper.to_response(invite)
|
||||
|
||||
|
||||
# All routers from this module for easy registration
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ async def update_profile(
|
|||
)
|
||||
|
||||
if errors:
|
||||
# Keep field_errors format for backward compatibility with frontend
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"field_errors": errors},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue