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."""
|
"""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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue