lots of stuff
This commit is contained in:
parent
f946fbf7b8
commit
4be45f8f7c
9 changed files with 513 additions and 236 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue