refactors

This commit is contained in:
counterweight 2025-12-26 20:04:46 +01:00
parent 4e1a339432
commit 82c4d0168e
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
28 changed files with 1042 additions and 782 deletions

View file

@ -0,0 +1,88 @@
# Export pagination
# Export auth schemas
from .auth import RegisterWithInvite, UserCreate, UserCredentials, UserLogin
# Export availability schemas
from .availability import (
AvailabilityDay,
AvailabilityResponse,
CopyAvailabilityRequest,
SetAvailabilityRequest,
TimeSlot,
)
# Export exchange schemas
from .exchange import (
AdminExchangeResponse,
AvailableSlotsResponse,
BookableSlot,
ExchangeRequest,
ExchangeResponse,
ExchangeUserContact,
PaginatedAdminExchanges,
PaginatedExchanges,
)
# Export invite schemas
from .invite import (
InviteCheckResponse,
InviteCreate,
InviteResponse,
PaginatedInviteRecords,
UserInviteResponse,
)
# Export meta schemas
from .meta import ConstantsResponse
from .pagination import PaginatedResponse, RecordT
# Export price schemas
from .price import (
ExchangeConfigResponse,
ExchangePriceResponse,
PriceHistoryResponse,
PriceResponse,
)
# Export profile schemas
from .profile import ProfileResponse, ProfileUpdate
# Export user schemas
from .user import AdminUserResponse, UserResponse, UserSearchResult
__all__ = [
"AdminExchangeResponse",
"AdminUserResponse",
"AvailabilityDay",
"AvailabilityResponse",
"AvailableSlotsResponse",
"BookableSlot",
"ConstantsResponse",
"CopyAvailabilityRequest",
"ExchangeConfigResponse",
"ExchangePriceResponse",
"ExchangeRequest",
"ExchangeResponse",
"ExchangeUserContact",
"InviteCheckResponse",
"InviteCreate",
"InviteResponse",
"PaginatedAdminExchanges",
"PaginatedExchanges",
"PaginatedInviteRecords",
"PaginatedResponse",
"PriceHistoryResponse",
"PriceResponse",
"ProfileResponse",
"ProfileUpdate",
"RecordT",
"RegisterWithInvite",
"SetAvailabilityRequest",
"TimeSlot",
"UserCreate",
"UserCredentials",
"UserInviteResponse",
"UserLogin",
"UserResponse",
"UserSearchResult",
]

20
backend/schemas/auth.py Normal file
View file

@ -0,0 +1,20 @@
from pydantic import BaseModel, EmailStr
class UserCredentials(BaseModel):
"""Base model for user email/password."""
email: EmailStr
password: str
UserCreate = UserCredentials
UserLogin = UserCredentials
class RegisterWithInvite(BaseModel):
"""Request model for registration with invite."""
email: EmailStr
password: str
invite_identifier: str

View file

@ -0,0 +1,47 @@
from datetime import date, time
from pydantic import BaseModel, field_validator
class TimeSlot(BaseModel):
"""A single time slot (start and end time)."""
start_time: time
end_time: time
@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."""
date: date
slots: list[TimeSlot]
class AvailabilityResponse(BaseModel):
"""Response model for availability query."""
days: list[AvailabilityDay]
class SetAvailabilityRequest(BaseModel):
"""Request to set availability for a specific date."""
date: date
slots: list[TimeSlot]
class CopyAvailabilityRequest(BaseModel):
"""Request to copy availability from one day to others."""
source_date: date
target_dates: list[date]

View file

@ -0,0 +1,87 @@
from datetime import date, datetime
from pydantic import BaseModel
from .pagination import PaginatedResponse
class ExchangeRequest(BaseModel):
"""Request to create an exchange trade."""
slot_start: datetime
direction: str # "buy" or "sell"
bitcoin_transfer_method: str # "onchain" or "lightning"
eur_amount: int # EUR cents (e.g., 10000 = €100)
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 ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
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
slot_end: datetime
direction: str
bitcoin_transfer_method: str
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 AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
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
slot_start: datetime
slot_end: datetime
direction: str
bitcoin_transfer_method: str
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]
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]

48
backend/schemas/invite.py Normal file
View file

@ -0,0 +1,48 @@
from datetime import datetime
from pydantic import BaseModel
from .pagination import PaginatedResponse
class InviteCheckResponse(BaseModel):
"""Response for invite check endpoint."""
valid: bool
status: str | None = None
error: str | None = None
class InviteCreate(BaseModel):
"""Request model for creating an invite."""
godfather_id: int
class InviteResponse(BaseModel):
"""Response model for invite data (admin view)."""
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)."""
id: int
identifier: str
status: str
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
PaginatedInviteRecords = PaginatedResponse[InviteResponse]

16
backend/schemas/meta.py Normal file
View file

@ -0,0 +1,16 @@
from pydantic import BaseModel
from models import BitcoinTransferMethod, InviteStatus, Permission
class ConstantsResponse(BaseModel):
"""Response model for shared constants.
Note: Using actual enum types ensures OpenAPI schema includes enum values,
allowing frontend type generation to produce matching TypeScript enums.
"""
permissions: list[Permission]
roles: list[str]
invite_statuses: list[InviteStatus]
bitcoin_transfer_methods: list[BitcoinTransferMethod]

View file

@ -0,0 +1,15 @@
from typing import Generic, TypeVar
from pydantic import BaseModel
RecordT = TypeVar("RecordT", bound=BaseModel)
class PaginatedResponse(BaseModel, Generic[RecordT]):
"""Generic paginated response wrapper."""
records: list[RecordT]
total: int
page: int
per_page: int
total_pages: int

44
backend/schemas/price.py Normal file
View file

@ -0,0 +1,44 @@
from datetime import datetime
from pydantic import BaseModel
class PriceHistoryResponse(BaseModel):
"""Response model for a price history record."""
id: int
source: str
pair: str
price: float
timestamp: datetime
created_at: datetime
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

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
class ProfileResponse(BaseModel):
"""Response model for profile data."""
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."""
contact_email: str | None = None
telegram: str | None = None
signal: str | None = None
nostr_npub: str | None = None

24
backend/schemas/user.py Normal file
View file

@ -0,0 +1,24 @@
from pydantic import BaseModel
class UserResponse(BaseModel):
"""Response model for authenticated user info."""
id: int
email: str
roles: list[str]
permissions: list[str]
class AdminUserResponse(BaseModel):
"""Minimal user info for admin dropdowns."""
id: int
email: str
class UserSearchResult(BaseModel):
"""Result item for user search."""
id: int
email: str