2025-12-20 22:18:14 +01:00
|
|
|
"""Pydantic schemas for API request/response models."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
|
|
|
|
from datetime import date, datetime, time
|
2025-12-20 22:18:14 +01:00
|
|
|
from typing import Generic, TypeVar
|
|
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
from pydantic import BaseModel, EmailStr, field_validator
|
2025-12-20 22:18:14 +01:00
|
|
|
|
2025-12-23 14:28:28 +01:00
|
|
|
from models import BitcoinTransferMethod, InviteStatus, Permission
|
2025-12-21 17:56:38 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
|
|
|
|
|
class UserCredentials(BaseModel):
|
|
|
|
|
"""Base model for user email/password."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
email: EmailStr
|
|
|
|
|
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
UserCreate = UserCredentials
|
|
|
|
|
UserLogin = UserCredentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
|
|
|
"""Response model for authenticated user info."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
id: int
|
|
|
|
|
email: str
|
2025-12-20 23:10:05 +01:00
|
|
|
roles: list[str]
|
2025-12-20 22:18:14 +01:00
|
|
|
permissions: list[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RegisterWithInvite(BaseModel):
|
|
|
|
|
"""Request model for registration with invite."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
email: EmailStr
|
|
|
|
|
password: str
|
|
|
|
|
invite_identifier: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RecordT = TypeVar("RecordT", bound=BaseModel)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PaginatedResponse(BaseModel, Generic[RecordT]):
|
|
|
|
|
"""Generic paginated response wrapper."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
records: list[RecordT]
|
|
|
|
|
total: int
|
|
|
|
|
page: int
|
|
|
|
|
per_page: int
|
|
|
|
|
total_pages: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileResponse(BaseModel):
|
|
|
|
|
"""Response model for profile data."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
contact_email: str | None
|
|
|
|
|
telegram: str | None
|
|
|
|
|
signal: str | None
|
|
|
|
|
nostr_npub: str | None
|
|
|
|
|
godfather_email: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
|
|
|
"""Request model for updating profile."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
contact_email: str | None = None
|
|
|
|
|
telegram: str | None = None
|
|
|
|
|
signal: str | None = None
|
|
|
|
|
nostr_npub: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InviteCheckResponse(BaseModel):
|
|
|
|
|
"""Response for invite check endpoint."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
valid: bool
|
|
|
|
|
status: str | None = None
|
|
|
|
|
error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InviteCreate(BaseModel):
|
|
|
|
|
"""Request model for creating an invite."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
godfather_id: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InviteResponse(BaseModel):
|
|
|
|
|
"""Response model for invite data (admin view)."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
id: int
|
|
|
|
|
identifier: str
|
|
|
|
|
godfather_id: int
|
|
|
|
|
godfather_email: str
|
|
|
|
|
status: str
|
|
|
|
|
used_by_id: int | None
|
|
|
|
|
used_by_email: str | None
|
|
|
|
|
created_at: datetime
|
|
|
|
|
spent_at: datetime | None
|
|
|
|
|
revoked_at: datetime | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserInviteResponse(BaseModel):
|
|
|
|
|
"""Response model for a user's invite (simpler than admin view)."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
id: int
|
|
|
|
|
identifier: str
|
|
|
|
|
status: str
|
|
|
|
|
used_by_email: str | None
|
|
|
|
|
created_at: datetime
|
|
|
|
|
spent_at: datetime | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PaginatedInviteRecords = PaginatedResponse[InviteResponse]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminUserResponse(BaseModel):
|
|
|
|
|
"""Minimal user info for admin dropdowns."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 22:18:14 +01:00
|
|
|
id: int
|
|
|
|
|
email: str
|
2025-12-20 23:06:05 +01:00
|
|
|
|
|
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
# =============================================================================
|
|
|
|
|
# Availability Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
class TimeSlot(BaseModel):
|
|
|
|
|
"""A single time slot (start and end time)."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
start_time: time
|
|
|
|
|
end_time: time
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
@field_validator("start_time", "end_time")
|
|
|
|
|
@classmethod
|
|
|
|
|
def validate_15min_boundary(cls, v: time) -> time:
|
|
|
|
|
"""Ensure times are on 15-minute boundaries."""
|
|
|
|
|
if v.minute not in (0, 15, 30, 45):
|
|
|
|
|
raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)")
|
|
|
|
|
if v.second != 0 or v.microsecond != 0:
|
|
|
|
|
raise ValueError("Time must not have seconds or microseconds")
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AvailabilityDay(BaseModel):
|
|
|
|
|
"""Availability for a single day."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
date: date
|
|
|
|
|
slots: list[TimeSlot]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AvailabilityResponse(BaseModel):
|
|
|
|
|
"""Response model for availability query."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
days: list[AvailabilityDay]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetAvailabilityRequest(BaseModel):
|
|
|
|
|
"""Request to set availability for a specific date."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
date: date
|
|
|
|
|
slots: list[TimeSlot]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CopyAvailabilityRequest(BaseModel):
|
|
|
|
|
"""Request to copy availability from one day to others."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:36:11 +01:00
|
|
|
source_date: date
|
|
|
|
|
target_dates: list[date]
|
|
|
|
|
|
|
|
|
|
|
2025-12-21 00:03:34 +01:00
|
|
|
# =============================================================================
|
|
|
|
|
# Booking Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-22 15:42:31 +01:00
|
|
|
# =============================================================================
|
|
|
|
|
# Price History Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PriceHistoryResponse(BaseModel):
|
|
|
|
|
"""Response model for a price history record."""
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
source: str
|
|
|
|
|
pair: str
|
|
|
|
|
price: float
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
2025-12-22 18:28:56 +01:00
|
|
|
# =============================================================================
|
|
|
|
|
# Exchange Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExchangeRequest(BaseModel):
|
|
|
|
|
"""Request to create an exchange trade."""
|
|
|
|
|
|
|
|
|
|
slot_start: datetime
|
|
|
|
|
direction: str # "buy" or "sell"
|
2025-12-23 14:36:39 +01:00
|
|
|
bitcoin_transfer_method: str # "onchain" or "lightning"
|
2025-12-22 18:28:56 +01:00
|
|
|
eur_amount: int # EUR cents (e.g., 10000 = €100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExchangeResponse(BaseModel):
|
|
|
|
|
"""Response model for an exchange trade."""
|
|
|
|
|
|
2025-12-23 17:03:51 +01:00
|
|
|
id: int # Keep for backward compatibility, but prefer public_id
|
|
|
|
|
public_id: str # UUID as string
|
2025-12-22 18:28:56 +01:00
|
|
|
user_id: int
|
|
|
|
|
user_email: str
|
|
|
|
|
slot_start: datetime
|
|
|
|
|
slot_end: datetime
|
|
|
|
|
direction: str
|
2025-12-23 14:36:39 +01:00
|
|
|
bitcoin_transfer_method: str
|
2025-12-22 18:28:56 +01:00
|
|
|
eur_amount: int # EUR cents
|
|
|
|
|
sats_amount: int # Satoshis
|
|
|
|
|
market_price_eur: float
|
|
|
|
|
agreed_price_eur: float
|
|
|
|
|
premium_percentage: int
|
|
|
|
|
status: str
|
|
|
|
|
created_at: datetime
|
|
|
|
|
cancelled_at: datetime | None
|
|
|
|
|
completed_at: datetime | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExchangeUserContact(BaseModel):
|
|
|
|
|
"""User contact info for admin view."""
|
|
|
|
|
|
|
|
|
|
email: str
|
|
|
|
|
contact_email: str | None
|
|
|
|
|
telegram: str | None
|
|
|
|
|
signal: str | None
|
|
|
|
|
nostr_npub: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdminExchangeResponse(BaseModel):
|
|
|
|
|
"""Response model for admin exchange view (includes user contact)."""
|
|
|
|
|
|
2025-12-23 17:03:51 +01:00
|
|
|
id: int # Keep for backward compatibility, but prefer public_id
|
|
|
|
|
public_id: str # UUID as string
|
2025-12-22 18:28:56 +01:00
|
|
|
user_id: int
|
|
|
|
|
user_email: str
|
|
|
|
|
user_contact: ExchangeUserContact
|
|
|
|
|
slot_start: datetime
|
|
|
|
|
slot_end: datetime
|
|
|
|
|
direction: str
|
2025-12-23 14:36:39 +01:00
|
|
|
bitcoin_transfer_method: str
|
2025-12-22 18:28:56 +01:00
|
|
|
eur_amount: int
|
|
|
|
|
sats_amount: int
|
|
|
|
|
market_price_eur: float
|
|
|
|
|
agreed_price_eur: float
|
|
|
|
|
premium_percentage: int
|
|
|
|
|
status: str
|
|
|
|
|
created_at: datetime
|
|
|
|
|
cancelled_at: datetime | None
|
|
|
|
|
completed_at: datetime | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PaginatedExchanges = PaginatedResponse[ExchangeResponse]
|
|
|
|
|
PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse]
|
|
|
|
|
|
|
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
# =============================================================================
|
|
|
|
|
# Meta/Constants Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-20 23:06:05 +01:00
|
|
|
class ConstantsResponse(BaseModel):
|
2025-12-21 23:55:47 +01:00
|
|
|
"""Response model for shared constants.
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-21 23:55:47 +01:00
|
|
|
Note: Using actual enum types ensures OpenAPI schema includes enum values,
|
|
|
|
|
allowing frontend type generation to produce matching TypeScript enums.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
permissions: list[Permission]
|
2025-12-20 23:06:05 +01:00
|
|
|
roles: list[str]
|
2025-12-21 23:55:47 +01:00
|
|
|
invite_statuses: list[InviteStatus]
|
2025-12-23 14:28:28 +01:00
|
|
|
bitcoin_transfer_methods: list[BitcoinTransferMethod]
|
2025-12-25 00:59:57 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Exchange Price/Config Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExchangeConfigResponse(BaseModel):
|
|
|
|
|
"""Exchange configuration for the frontend."""
|
|
|
|
|
|
|
|
|
|
eur_min: int
|
|
|
|
|
eur_max: int
|
|
|
|
|
eur_increment: int
|
|
|
|
|
premium_percentage: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PriceResponse(BaseModel):
|
|
|
|
|
"""Current BTC/EUR price for trading.
|
|
|
|
|
|
|
|
|
|
Note: The actual agreed price depends on trade direction (buy/sell)
|
|
|
|
|
and is calculated by the frontend using market_price and premium_percentage.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
market_price: float # Raw price from exchange
|
|
|
|
|
premium_percentage: int
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
is_stale: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExchangePriceResponse(BaseModel):
|
|
|
|
|
"""Combined price and configuration response."""
|
|
|
|
|
|
|
|
|
|
price: PriceResponse | None # None if price fetch failed
|
|
|
|
|
config: ExchangeConfigResponse
|
|
|
|
|
error: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BookableSlot(BaseModel):
|
|
|
|
|
"""A single bookable time slot."""
|
|
|
|
|
|
|
|
|
|
start_time: datetime
|
|
|
|
|
end_time: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AvailableSlotsResponse(BaseModel):
|
|
|
|
|
"""Response containing available slots for a date."""
|
|
|
|
|
|
|
|
|
|
date: date
|
|
|
|
|
slots: list[BookableSlot]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Admin User Search Schemas
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserSearchResult(BaseModel):
|
|
|
|
|
"""Result item for user search."""
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
email: str
|