lots of stuff

This commit is contained in:
counterweight 2025-12-23 17:03:51 +01:00
parent f946fbf7b8
commit 4be45f8f7c
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 513 additions and 236 deletions

View file

@ -1,3 +1,4 @@
import uuid
from datetime import UTC, date, datetime, time
from enum import Enum as PyEnum
from typing import TypedDict
@ -16,6 +17,7 @@ from sqlalchemy import (
UniqueConstraint,
select,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -328,9 +330,20 @@ class Exchange(Base):
"""Bitcoin exchange trades booked by users."""
__tablename__ = "exchanges"
__table_args__ = (UniqueConstraint("slot_start", name="uq_exchange_slot_start"),)
# Note: No unique constraint on slot_start to allow cancelled bookings
# to be replaced. Application-level check in create_exchange ensures only
# one BOOKED trade per slot. For existing databases, manually drop the
# constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS
# uq_exchange_slot_start;
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
public_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
unique=True,
index=True,
default=uuid.uuid4,
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)

View file

@ -1,5 +1,6 @@
"""Exchange routes for Bitcoin trading."""
import uuid
from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
@ -167,6 +168,7 @@ def _to_exchange_response(
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,
@ -396,7 +398,8 @@ async def create_exchange(
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. Trade ID: {existing_trade.id}"
f"Only one trade per day is allowed. "
f"Trade ID: {existing_trade.public_id}"
),
)
@ -512,6 +515,26 @@ async def create_exchange(
# 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,
@ -532,12 +555,14 @@ async def create_exchange(
try:
await db.commit()
await db.refresh(exchange)
except IntegrityError:
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="This slot has already been booked. Select another slot.",
) from None
detail="Database constraint violation. Please try again.",
) from e
return _to_exchange_response(exchange, current_user.email)
@ -565,16 +590,16 @@ async def get_my_trades(
return [_to_exchange_response(ex, current_user.email) for ex in exchanges]
@trades_router.get("/{exchange_id}", response_model=ExchangeResponse)
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
async def get_my_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> ExchangeResponse:
"""Get a specific trade by ID. User can only access their own trades."""
"""Get a specific trade by public ID. User can only access their own trades."""
result = await db.execute(
select(Exchange).where(
and_(Exchange.id == exchange_id, Exchange.user_id == current_user.id)
and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
)
)
exchange = result.scalar_one_or_none()
@ -588,9 +613,9 @@ async def get_my_trade(
return _to_exchange_response(exchange, current_user.email)
@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse)
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
async def cancel_my_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse:
@ -599,14 +624,14 @@ async def cancel_my_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Verify ownership
@ -651,6 +676,7 @@ def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
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(
@ -760,11 +786,9 @@ async def get_past_trades(
return [_to_admin_exchange_response(ex) for ex in exchanges]
@admin_trades_router.post(
"/{exchange_id}/complete", response_model=AdminExchangeResponse
)
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
async def complete_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
@ -773,14 +797,14 @@ async def complete_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check slot has passed
@ -806,11 +830,9 @@ async def complete_trade(
return _to_admin_exchange_response(exchange)
@admin_trades_router.post(
"/{exchange_id}/no-show", response_model=AdminExchangeResponse
)
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
async def mark_no_show(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse:
@ -819,14 +841,14 @@ async def mark_no_show(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check slot has passed
@ -852,9 +874,9 @@ async def mark_no_show(
return _to_admin_exchange_response(exchange)
@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse)
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
async def admin_cancel_trade(
exchange_id: int,
public_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
) -> AdminExchangeResponse:
@ -863,14 +885,14 @@ async def admin_cancel_trade(
result = await db.execute(
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.id == exchange_id)
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail=f"Trade {exchange_id} not found",
detail="Trade not found",
)
# Check status is BOOKED

View file

@ -205,7 +205,8 @@ class ExchangeRequest(BaseModel):
class ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
id: int
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
slot_start: datetime
@ -236,7 +237,8 @@ class ExchangeUserContact(BaseModel):
class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
id: int
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
user_contact: ExchangeUserContact

View file

@ -670,14 +670,14 @@ class TestGetMyTrade:
},
)
assert create_response.status_code == 200
trade_id = create_response.json()["id"]
public_id = create_response.json()["public_id"]
# Get the trade
get_response = await client.get(f"/api/trades/{trade_id}")
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 200
data = get_response.json()
assert data["id"] == trade_id
assert data["public_id"] == public_id
assert data["direction"] == "buy"
assert data["bitcoin_transfer_method"] == "onchain"
@ -701,13 +701,13 @@ class TestGetMyTrade:
},
)
assert create_response.status_code == 200
trade_id = create_response.json()["id"]
public_id = create_response.json()["public_id"]
# Second user tries to get it
async with client_factory.create(
cookies=alt_regular_user["cookies"]
) as client:
get_response = await client.get(f"/api/trades/{trade_id}")
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 404
@ -717,7 +717,10 @@ class TestGetMyTrade:
):
"""Getting a nonexistent trade returns 404."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/trades/99999")
# Use a valid UUID format but non-existent
response = await client.get(
"/api/trades/00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
@ -742,10 +745,10 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Cancel
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 200
data = response.json()
@ -772,7 +775,7 @@ class TestCancelTrade:
},
)
assert book_response.status_code == 200
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Verify the slot is NOT available
slots_response = await client.get(
@ -784,7 +787,7 @@ class TestCancelTrade:
assert f"{target_date}T09:00:00Z" not in slot_starts
# Cancel the trade
cancel_response = await client.post(f"/api/trades/{trade_id}/cancel")
cancel_response = await client.post(f"/api/trades/{public_id}/cancel")
assert cancel_response.status_code == 200
# Verify the slot IS available again
@ -815,11 +818,11 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Second user tries to cancel (no mock needed for this)
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 403
@ -827,7 +830,9 @@ class TestCancelTrade:
async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user):
"""Returns 404 for non-existent trade."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/trades/99999/cancel")
response = await client.post(
"/api/trades/00000000-0000-0000-0000-000000000000/cancel"
)
assert response.status_code == 404
@ -850,11 +855,11 @@ class TestCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
await client.post(f"/api/trades/{trade_id}/cancel")
public_id = book_response.json()["public_id"]
await client.post(f"/api/trades/{public_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
@ -884,11 +889,11 @@ class TestCancelTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# User tries to cancel
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{trade_id}/cancel")
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "already passed" in response.json()["detail"]
@ -1008,11 +1013,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Admin completes
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 200
data = response.json()
@ -1038,11 +1043,11 @@ class TestAdminCompleteTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Admin tries to complete
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 400
assert "not yet started" in response.json()["detail"]
@ -1071,11 +1076,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Regular user tries to complete
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 403
@ -1103,11 +1108,11 @@ class TestAdminCompleteTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Regular user tries to mark as no-show
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/no-show")
response = await client.post(f"/api/admin/trades/{public_id}/no-show")
assert response.status_code == 403
@ -1139,11 +1144,11 @@ class TestAdminNoShowTrade:
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
public_id = exchange.public_id
# Admin marks no-show
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/no-show")
response = await client.post(f"/api/admin/trades/{public_id}/no-show")
assert response.status_code == 200
data = response.json()
@ -1172,11 +1177,11 @@ class TestAdminCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
response = await client.post(f"/api/admin/trades/{public_id}/cancel")
assert response.status_code == 200
data = response.json()
@ -1201,10 +1206,10 @@ class TestAdminCancelTrade:
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
public_id = book_response.json()["public_id"]
# User tries admin cancel
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
response = await client.post(f"/api/admin/trades/{public_id}/cancel")
assert response.status_code == 403