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:
parent
ce9159c5b0
commit
d39ada1bef
1 changed files with 263 additions and 3 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"""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 pydantic import BaseModel
|
||||
|
|
@ -22,7 +22,12 @@ from models import (
|
|||
User,
|
||||
)
|
||||
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 (
|
||||
EUR_TRADE_INCREMENT,
|
||||
EUR_TRADE_MAX,
|
||||
|
|
@ -468,5 +473,260 @@ async def cancel_my_trade(
|
|||
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
|
||||
routers = [router, trades_router]
|
||||
routers = [router, trades_router, admin_trades_router]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue