Phase 2.4: Add admin exchange endpoints

Admin trade management:
- GET /api/admin/trades/upcoming: Upcoming booked trades (sorted by time)
- GET /api/admin/trades/past: Past trades with filters
  - status, start_date, end_date, user_search
- POST /api/admin/trades/{id}/complete: Mark as completed (after slot time)
- POST /api/admin/trades/{id}/no-show: Mark as no-show (after slot time)
- POST /api/admin/trades/{id}/cancel: Admin cancel trade

AdminExchangeResponse includes user contact info for admin view.
This commit is contained in:
counterweight 2025-12-22 18:34:56 +01:00
parent ce9159c5b0
commit d39ada1bef
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C

View file

@ -1,6 +1,6 @@
"""Exchange routes for Bitcoin trading.""" """Exchange routes for Bitcoin trading."""
from datetime import UTC, datetime, timedelta from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@ -22,7 +22,12 @@ from models import (
User, User,
) )
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from schemas import ExchangeRequest, ExchangeResponse from schemas import (
AdminExchangeResponse,
ExchangeRequest,
ExchangeResponse,
ExchangeUserContact,
)
from shared_constants import ( from shared_constants import (
EUR_TRADE_INCREMENT, EUR_TRADE_INCREMENT,
EUR_TRADE_MAX, EUR_TRADE_MAX,
@ -468,5 +473,260 @@ async def cancel_my_trade(
return _to_exchange_response(exchange, current_user.email) return _to_exchange_response(exchange, current_user.email)
# =============================================================================
# Admin Exchanges Endpoints
# =============================================================================
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,
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,
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),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)),
) -> list[AdminExchangeResponse]:
"""Get all upcoming booked trades, sorted by slot time ascending."""
now = datetime.now(UTC)
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(
and_(
Exchange.slot_start > now,
Exchange.status == ExchangeStatus.BOOKED,
)
)
.order_by(Exchange.slot_start.asc())
)
exchanges = result.scalars().all()
return [_to_admin_exchange_response(ex) for ex in exchanges]
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
async def get_past_trades(
status: str | None = None,
start_date: date | None = None,
end_date: date | None = None,
user_search: str | None = None,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)),
) -> list[AdminExchangeResponse]:
"""
Get past trades with optional filters.
Filters:
- status: Filter by exchange status
- start_date, end_date: Filter by slot_start date range
- user_search: Search by user email (partial match)
"""
now = datetime.now(UTC)
# Start with base query for past trades (slot_start <= now OR not booked)
query = (
select(Exchange)
.options(joinedload(Exchange.user))
.where(
(Exchange.slot_start <= now) | (Exchange.status != ExchangeStatus.BOOKED)
)
)
# Apply status filter
if status:
try:
status_enum = ExchangeStatus(status)
query = query.where(Exchange.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}",
) from None
# Apply date range filter
if start_date:
start_dt = datetime.combine(start_date, time.min, tzinfo=UTC)
query = query.where(Exchange.slot_start >= start_dt)
if end_date:
end_dt = datetime.combine(end_date, time.max, tzinfo=UTC)
query = query.where(Exchange.slot_start <= end_dt)
# Apply user search filter (join with User table)
if user_search:
query = query.join(Exchange.user).where(User.email.ilike(f"%{user_search}%"))
# Order by most recent first
query = query.order_by(Exchange.slot_start.desc())
result = await db.execute(query)
exchanges = result.scalars().all()
return [_to_admin_exchange_response(ex) for ex in exchanges]
@admin_trades_router.post(
"/{exchange_id}/complete", response_model=AdminExchangeResponse
)
async def complete_trade(
exchange_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse:
"""Mark a trade as completed. Only possible after slot time has passed."""
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} 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)
@admin_trades_router.post(
"/{exchange_id}/no-show", response_model=AdminExchangeResponse
)
async def mark_no_show(
exchange_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse:
"""Mark a trade as no-show. Only possible after slot time has passed."""
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} 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)
@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse)
async def admin_cancel_trade(
exchange_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse:
"""Cancel any trade (admin only)."""
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} 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)
# All routers from this module for easy registration # All routers from this module for easy registration
routers = [router, trades_router] routers = [router, trades_router, admin_trades_router]