Fix: Update permission names, models and constants

Permission renames:
- BOOK_APPOINTMENT -> CREATE_EXCHANGE
- VIEW_OWN_APPOINTMENTS -> VIEW_OWN_EXCHANGES
- CANCEL_OWN_APPOINTMENT -> CANCEL_OWN_EXCHANGE
- VIEW_ALL_APPOINTMENTS -> VIEW_ALL_EXCHANGES
- CANCEL_ANY_APPOINTMENT -> CANCEL_ANY_EXCHANGE
- Add COMPLETE_EXCHANGE permission

Model changes:
- Delete AppointmentStatus enum
- Delete Appointment model

Schema changes:
- Delete BookingRequest (was for old booking)
- Delete AppointmentResponse, PaginatedAppointments
- Delete BookableSlot, AvailableSlotsResponse (unused)

Constants changes:
- Remove appointmentStatuses from shared/constants.json
- Merge booking constants into exchange section
- Update shared_constants.py and validate_constants.py
This commit is contained in:
counterweight 2025-12-22 20:28:21 +01:00
parent 743129b11d
commit fa07490b7b
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
6 changed files with 32 additions and 147 deletions

View file

@ -41,15 +41,16 @@ class Permission(str, PyEnum):
MANAGE_INVITES = "manage_invites" MANAGE_INVITES = "manage_invites"
VIEW_OWN_INVITES = "view_own_invites" VIEW_OWN_INVITES = "view_own_invites"
# Booking permissions (regular users) # Exchange permissions (regular users)
BOOK_APPOINTMENT = "book_appointment" CREATE_EXCHANGE = "create_exchange"
VIEW_OWN_APPOINTMENTS = "view_own_appointments" VIEW_OWN_EXCHANGES = "view_own_exchanges"
CANCEL_OWN_APPOINTMENT = "cancel_own_appointment" CANCEL_OWN_EXCHANGE = "cancel_own_exchange"
# Availability/Appointments permissions (admin) # Availability/Exchange permissions (admin)
MANAGE_AVAILABILITY = "manage_availability" MANAGE_AVAILABILITY = "manage_availability"
VIEW_ALL_APPOINTMENTS = "view_all_appointments" VIEW_ALL_EXCHANGES = "view_all_exchanges"
CANCEL_ANY_APPOINTMENT = "cancel_any_appointment" CANCEL_ANY_EXCHANGE = "cancel_any_exchange"
COMPLETE_EXCHANGE = "complete_exchange"
class InviteStatus(str, PyEnum): class InviteStatus(str, PyEnum):
@ -60,14 +61,6 @@ class InviteStatus(str, PyEnum):
REVOKED = "revoked" REVOKED = "revoked"
class AppointmentStatus(str, PyEnum):
"""Status of an appointment."""
BOOKED = "booked"
CANCELLED_BY_USER = "cancelled_by_user"
CANCELLED_BY_ADMIN = "cancelled_by_admin"
class ExchangeStatus(str, PyEnum): class ExchangeStatus(str, PyEnum):
"""Status of an exchange trade.""" """Status of an exchange trade."""
@ -92,24 +85,25 @@ ROLE_REGULAR = "regular"
# Role definitions with their permissions # Role definitions with their permissions
ROLE_DEFINITIONS: dict[str, RoleConfig] = { ROLE_DEFINITIONS: dict[str, RoleConfig] = {
ROLE_ADMIN: { ROLE_ADMIN: {
"description": "Administrator with audit/invite/appointment access", "description": "Administrator with audit/invite/exchange access",
"permissions": [ "permissions": [
Permission.VIEW_AUDIT, Permission.VIEW_AUDIT,
Permission.FETCH_PRICE, Permission.FETCH_PRICE,
Permission.MANAGE_INVITES, Permission.MANAGE_INVITES,
Permission.MANAGE_AVAILABILITY, Permission.MANAGE_AVAILABILITY,
Permission.VIEW_ALL_APPOINTMENTS, Permission.VIEW_ALL_EXCHANGES,
Permission.CANCEL_ANY_APPOINTMENT, Permission.CANCEL_ANY_EXCHANGE,
Permission.COMPLETE_EXCHANGE,
], ],
}, },
ROLE_REGULAR: { ROLE_REGULAR: {
"description": "Regular user with profile, invite, and booking access", "description": "Regular user with profile, invite, and exchange access",
"permissions": [ "permissions": [
Permission.MANAGE_OWN_PROFILE, Permission.MANAGE_OWN_PROFILE,
Permission.VIEW_OWN_INVITES, Permission.VIEW_OWN_INVITES,
Permission.BOOK_APPOINTMENT, Permission.CREATE_EXCHANGE,
Permission.VIEW_OWN_APPOINTMENTS, Permission.VIEW_OWN_EXCHANGES,
Permission.CANCEL_OWN_APPOINTMENT, Permission.CANCEL_OWN_EXCHANGE,
], ],
}, },
} }
@ -303,33 +297,6 @@ class Availability(Base):
) )
class Appointment(Base):
"""User appointment bookings."""
__tablename__ = "appointments"
__table_args__ = (UniqueConstraint("slot_start", name="uq_appointment_slot_start"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined")
slot_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
note: Mapped[str | None] = mapped_column(String(144), nullable=True)
status: Mapped[AppointmentStatus] = mapped_column(
Enum(AppointmentStatus), nullable=False, default=AppointmentStatus.BOOKED
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class PriceHistory(Base): class PriceHistory(Base):
"""Price history records from external exchanges.""" """Price history records from external exchanges."""

View file

@ -180,7 +180,7 @@ def _to_exchange_response(
@router.get("/price", response_model=ExchangePriceResponse) @router.get("/price", response_model=ExchangePriceResponse)
async def get_exchange_price( async def get_exchange_price(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), _current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
) -> ExchangePriceResponse: ) -> ExchangePriceResponse:
""" """
Get the current BTC/EUR price for trading. Get the current BTC/EUR price for trading.
@ -275,7 +275,7 @@ async def get_exchange_price(
async def create_exchange( async def create_exchange(
request: ExchangeRequest, request: ExchangeRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)),
) -> ExchangeResponse: ) -> ExchangeResponse:
""" """
Create a new exchange trade booking. Create a new exchange trade booking.
@ -415,7 +415,7 @@ trades_router = APIRouter(prefix="/api/trades", tags=["trades"])
@trades_router.get("", response_model=list[ExchangeResponse]) @trades_router.get("", response_model=list[ExchangeResponse])
async def get_my_trades( async def get_my_trades(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)), current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> list[ExchangeResponse]: ) -> list[ExchangeResponse]:
"""Get the current user's exchanges, sorted by date (newest first).""" """Get the current user's exchanges, sorted by date (newest first)."""
result = await db.execute( result = await db.execute(
@ -432,7 +432,7 @@ async def get_my_trades(
async def cancel_my_trade( async def cancel_my_trade(
exchange_id: int, exchange_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse: ) -> ExchangeResponse:
"""Cancel one of the current user's exchanges.""" """Cancel one of the current user's exchanges."""
# Get the exchange with eager loading of user relationship # Get the exchange with eager loading of user relationship
@ -512,7 +512,7 @@ def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse]) @admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
async def get_upcoming_trades( async def get_upcoming_trades(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
) -> list[AdminExchangeResponse]: ) -> list[AdminExchangeResponse]:
"""Get all upcoming booked trades, sorted by slot time ascending.""" """Get all upcoming booked trades, sorted by slot time ascending."""
now = datetime.now(UTC) now = datetime.now(UTC)
@ -539,7 +539,7 @@ async def get_past_trades(
end_date: date | None = None, end_date: date | None = None,
user_search: str | None = None, user_search: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
) -> list[AdminExchangeResponse]: ) -> list[AdminExchangeResponse]:
""" """
Get past trades with optional filters. Get past trades with optional filters.
@ -599,9 +599,7 @@ async def get_past_trades(
async def complete_trade( async def complete_trade(
exchange_id: int, exchange_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends( _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Mark a trade as completed. Only possible after slot time has passed.""" """Mark a trade as completed. Only possible after slot time has passed."""
@ -647,9 +645,7 @@ async def complete_trade(
async def mark_no_show( async def mark_no_show(
exchange_id: int, exchange_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends( _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Mark a trade as no-show. Only possible after slot time has passed.""" """Mark a trade as no-show. Only possible after slot time has passed."""
@ -693,9 +689,7 @@ async def mark_no_show(
async def admin_cancel_trade( async def admin_cancel_trade(
exchange_id: int, exchange_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_current_user: User = Depends( _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Cancel any trade (admin only).""" """Cancel any trade (admin only)."""

View file

@ -6,7 +6,6 @@ from typing import Generic, TypeVar
from pydantic import BaseModel, EmailStr, field_validator from pydantic import BaseModel, EmailStr, field_validator
from models import InviteStatus, Permission from models import InviteStatus, Permission
from shared_constants import NOTE_MAX_LENGTH
class UserCredentials(BaseModel): class UserCredentials(BaseModel):
@ -173,51 +172,6 @@ class CopyAvailabilityRequest(BaseModel):
# ============================================================================= # =============================================================================
class BookableSlot(BaseModel):
"""A bookable 15-minute slot."""
start_time: datetime
end_time: datetime
class AvailableSlotsResponse(BaseModel):
"""Response for available slots on a given date."""
date: date
slots: list[BookableSlot]
class BookingRequest(BaseModel):
"""Request to book an appointment."""
slot_start: datetime
note: str | None = None
@field_validator("note")
@classmethod
def validate_note_length(cls, v: str | None) -> str | None:
if v is not None and len(v) > NOTE_MAX_LENGTH:
raise ValueError(f"Note must be at most {NOTE_MAX_LENGTH} characters")
return v
class AppointmentResponse(BaseModel):
"""Response model for an appointment."""
id: int
user_id: int
user_email: str
slot_start: datetime
slot_end: datetime
note: str | None
status: str
created_at: datetime
cancelled_at: datetime | None
PaginatedAppointments = PaginatedResponse[AppointmentResponse]
# ============================================================================= # =============================================================================
# Price History Schemas # Price History Schemas
# ============================================================================= # =============================================================================

View file

@ -6,13 +6,10 @@ from pathlib import Path
_constants_path = Path(__file__).parent.parent / "shared" / "constants.json" _constants_path = Path(__file__).parent.parent / "shared" / "constants.json"
_constants = json.loads(_constants_path.read_text()) _constants = json.loads(_constants_path.read_text())
# Booking constants
SLOT_DURATION_MINUTES: int = _constants["booking"]["slotDurationMinutes"]
MIN_ADVANCE_DAYS: int = _constants["booking"]["minAdvanceDays"]
MAX_ADVANCE_DAYS: int = _constants["booking"]["maxAdvanceDays"]
NOTE_MAX_LENGTH: int = _constants["booking"]["noteMaxLength"]
# Exchange constants # Exchange constants
SLOT_DURATION_MINUTES: int = _constants["exchange"]["slotDurationMinutes"]
MIN_ADVANCE_DAYS: int = _constants["exchange"]["minAdvanceDays"]
MAX_ADVANCE_DAYS: int = _constants["exchange"]["maxAdvanceDays"]
EUR_TRADE_MIN: int = _constants["exchange"]["eurTradeMin"] EUR_TRADE_MIN: int = _constants["exchange"]["eurTradeMin"]
EUR_TRADE_MAX: int = _constants["exchange"]["eurTradeMax"] EUR_TRADE_MAX: int = _constants["exchange"]["eurTradeMax"]
EUR_TRADE_INCREMENT: int = _constants["exchange"]["eurTradeIncrement"] EUR_TRADE_INCREMENT: int = _constants["exchange"]["eurTradeIncrement"]

View file

@ -6,7 +6,6 @@ from pathlib import Path
from models import ( from models import (
ROLE_ADMIN, ROLE_ADMIN,
ROLE_REGULAR, ROLE_REGULAR,
AppointmentStatus,
ExchangeStatus, ExchangeStatus,
InviteStatus, InviteStatus,
TradeDirection, TradeDirection,
@ -42,15 +41,6 @@ def validate_shared_constants() -> None:
f"Invite status mismatch. Expected: {expected_invite_statuses}, Got: {got}" f"Invite status mismatch. Expected: {expected_invite_statuses}, Got: {got}"
) )
# Validate appointment statuses
expected_appointment_statuses = {s.name: s.value for s in AppointmentStatus}
if constants.get("appointmentStatuses") != expected_appointment_statuses:
got = constants.get("appointmentStatuses")
raise ValueError(
f"Appointment status mismatch. "
f"Expected: {expected_appointment_statuses}, Got: {got}"
)
# Validate exchange statuses # Validate exchange statuses
expected_exchange_statuses = {s.name: s.value for s in ExchangeStatus} expected_exchange_statuses = {s.name: s.value for s in ExchangeStatus}
if constants.get("exchangeStatuses") != expected_exchange_statuses: if constants.get("exchangeStatuses") != expected_exchange_statuses:
@ -69,21 +59,12 @@ def validate_shared_constants() -> None:
f"Expected: {expected_trade_directions}, Got: {got}" f"Expected: {expected_trade_directions}, Got: {got}"
) )
# Validate booking constants exist with required fields
booking = constants.get("booking", {})
required_booking_fields = [
"slotDurationMinutes",
"maxAdvanceDays",
"minAdvanceDays",
"noteMaxLength",
]
for field in required_booking_fields:
if field not in booking:
raise ValueError(f"Missing booking constant '{field}' in constants.json")
# Validate exchange constants exist with required fields # Validate exchange constants exist with required fields
exchange = constants.get("exchange", {}) exchange = constants.get("exchange", {})
required_exchange_fields = [ required_exchange_fields = [
"slotDurationMinutes",
"maxAdvanceDays",
"minAdvanceDays",
"eurTradeMin", "eurTradeMin",
"eurTradeMax", "eurTradeMax",
"eurTradeIncrement", "eurTradeIncrement",

View file

@ -8,11 +8,6 @@
"SPENT": "spent", "SPENT": "spent",
"REVOKED": "revoked" "REVOKED": "revoked"
}, },
"appointmentStatuses": {
"BOOKED": "booked",
"CANCELLED_BY_USER": "cancelled_by_user",
"CANCELLED_BY_ADMIN": "cancelled_by_admin"
},
"exchangeStatuses": { "exchangeStatuses": {
"BOOKED": "booked", "BOOKED": "booked",
"COMPLETED": "completed", "COMPLETED": "completed",
@ -24,13 +19,10 @@
"BUY": "buy", "BUY": "buy",
"SELL": "sell" "SELL": "sell"
}, },
"booking": { "exchange": {
"slotDurationMinutes": 15, "slotDurationMinutes": 15,
"maxAdvanceDays": 30, "maxAdvanceDays": 30,
"minAdvanceDays": 1, "minAdvanceDays": 1,
"noteMaxLength": 144
},
"exchange": {
"eurTradeMin": 100, "eurTradeMin": 100,
"eurTradeMax": 3000, "eurTradeMax": 3000,
"eurTradeIncrement": 20, "eurTradeIncrement": 20,