refactors
This commit is contained in:
parent
4e1a339432
commit
82c4d0168e
28 changed files with 1042 additions and 782 deletions
|
|
@ -1,395 +0,0 @@
|
||||||
import uuid
|
|
||||||
from datetime import UTC, date, datetime, time
|
|
||||||
from enum import Enum as PyEnum
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column,
|
|
||||||
Date,
|
|
||||||
DateTime,
|
|
||||||
Enum,
|
|
||||||
Float,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Table,
|
|
||||||
Time,
|
|
||||||
UniqueConstraint,
|
|
||||||
select,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class RoleConfig(TypedDict):
|
|
||||||
description: str
|
|
||||||
permissions: list["Permission"]
|
|
||||||
|
|
||||||
|
|
||||||
class Permission(str, PyEnum):
|
|
||||||
"""All available permissions in the system."""
|
|
||||||
|
|
||||||
# Audit permissions
|
|
||||||
VIEW_AUDIT = "view_audit"
|
|
||||||
FETCH_PRICE = "fetch_price"
|
|
||||||
|
|
||||||
# Profile permissions
|
|
||||||
MANAGE_OWN_PROFILE = "manage_own_profile"
|
|
||||||
|
|
||||||
# Invite permissions
|
|
||||||
MANAGE_INVITES = "manage_invites"
|
|
||||||
VIEW_OWN_INVITES = "view_own_invites"
|
|
||||||
|
|
||||||
# Exchange permissions (regular users)
|
|
||||||
CREATE_EXCHANGE = "create_exchange"
|
|
||||||
VIEW_OWN_EXCHANGES = "view_own_exchanges"
|
|
||||||
CANCEL_OWN_EXCHANGE = "cancel_own_exchange"
|
|
||||||
|
|
||||||
# Availability/Exchange permissions (admin)
|
|
||||||
MANAGE_AVAILABILITY = "manage_availability"
|
|
||||||
VIEW_ALL_EXCHANGES = "view_all_exchanges"
|
|
||||||
CANCEL_ANY_EXCHANGE = "cancel_any_exchange"
|
|
||||||
COMPLETE_EXCHANGE = "complete_exchange"
|
|
||||||
|
|
||||||
|
|
||||||
class InviteStatus(str, PyEnum):
|
|
||||||
"""Status of an invite."""
|
|
||||||
|
|
||||||
READY = "ready"
|
|
||||||
SPENT = "spent"
|
|
||||||
REVOKED = "revoked"
|
|
||||||
|
|
||||||
|
|
||||||
class ExchangeStatus(str, PyEnum):
|
|
||||||
"""Status of an exchange trade."""
|
|
||||||
|
|
||||||
BOOKED = "booked"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED_BY_USER = "cancelled_by_user"
|
|
||||||
CANCELLED_BY_ADMIN = "cancelled_by_admin"
|
|
||||||
NO_SHOW = "no_show"
|
|
||||||
|
|
||||||
|
|
||||||
class TradeDirection(str, PyEnum):
|
|
||||||
"""Direction of a trade from the user's perspective."""
|
|
||||||
|
|
||||||
BUY = "buy" # User buys BTC, gives EUR
|
|
||||||
SELL = "sell" # User sells BTC, gets EUR
|
|
||||||
|
|
||||||
|
|
||||||
class BitcoinTransferMethod(str, PyEnum):
|
|
||||||
"""Bitcoin transfer method for exchange trades."""
|
|
||||||
|
|
||||||
ONCHAIN = "onchain"
|
|
||||||
LIGHTNING = "lightning"
|
|
||||||
|
|
||||||
|
|
||||||
# Role name constants
|
|
||||||
ROLE_ADMIN = "admin"
|
|
||||||
ROLE_REGULAR = "regular"
|
|
||||||
|
|
||||||
# Role definitions with their permissions
|
|
||||||
ROLE_DEFINITIONS: dict[str, RoleConfig] = {
|
|
||||||
ROLE_ADMIN: {
|
|
||||||
"description": "Administrator with audit/invite/exchange access",
|
|
||||||
"permissions": [
|
|
||||||
Permission.VIEW_AUDIT,
|
|
||||||
Permission.FETCH_PRICE,
|
|
||||||
Permission.MANAGE_INVITES,
|
|
||||||
Permission.MANAGE_AVAILABILITY,
|
|
||||||
Permission.VIEW_ALL_EXCHANGES,
|
|
||||||
Permission.CANCEL_ANY_EXCHANGE,
|
|
||||||
Permission.COMPLETE_EXCHANGE,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
ROLE_REGULAR: {
|
|
||||||
"description": "Regular user with profile, invite, and exchange access",
|
|
||||||
"permissions": [
|
|
||||||
Permission.MANAGE_OWN_PROFILE,
|
|
||||||
Permission.VIEW_OWN_INVITES,
|
|
||||||
Permission.CREATE_EXCHANGE,
|
|
||||||
Permission.VIEW_OWN_EXCHANGES,
|
|
||||||
Permission.CANCEL_OWN_EXCHANGE,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Association table: Role <-> Permission (many-to-many)
|
|
||||||
role_permissions = Table(
|
|
||||||
"role_permissions",
|
|
||||||
Base.metadata,
|
|
||||||
Column(
|
|
||||||
"role_id",
|
|
||||||
Integer,
|
|
||||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
Column("permission", Enum(Permission), primary_key=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Association table: User <-> Role (many-to-many)
|
|
||||||
user_roles = Table(
|
|
||||||
"user_roles",
|
|
||||||
Base.metadata,
|
|
||||||
Column(
|
|
||||||
"user_id",
|
|
||||||
Integer,
|
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
"role_id",
|
|
||||||
Integer,
|
|
||||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Role(Base):
|
|
||||||
__tablename__ = "roles"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
|
||||||
description: Mapped[str] = mapped_column(String(255), nullable=True)
|
|
||||||
|
|
||||||
# Relationship to users
|
|
||||||
users: Mapped[list["User"]] = relationship(
|
|
||||||
"User",
|
|
||||||
secondary=user_roles,
|
|
||||||
back_populates="roles",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
|
|
||||||
"""Get all permissions for this role."""
|
|
||||||
query = select(role_permissions.c.permission).where(
|
|
||||||
role_permissions.c.role_id == self.id
|
|
||||||
)
|
|
||||||
result = await db.execute(query)
|
|
||||||
return {row[0] for row in result.fetchall()}
|
|
||||||
|
|
||||||
async def set_permissions(
|
|
||||||
self, db: AsyncSession, permissions: list[Permission]
|
|
||||||
) -> None:
|
|
||||||
"""Set all permissions for this role (replaces existing)."""
|
|
||||||
delete_query = role_permissions.delete().where(
|
|
||||||
role_permissions.c.role_id == self.id
|
|
||||||
)
|
|
||||||
await db.execute(delete_query)
|
|
||||||
for perm in permissions:
|
|
||||||
insert_query = role_permissions.insert().values(
|
|
||||||
role_id=self.id, permission=perm
|
|
||||||
)
|
|
||||||
await db.execute(insert_query)
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
email: Mapped[str] = mapped_column(
|
|
||||||
String(255), unique=True, nullable=False, index=True
|
|
||||||
)
|
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
|
|
||||||
# Contact details (all optional)
|
|
||||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
telegram: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
signal: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
||||||
nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
|
|
||||||
|
|
||||||
# Godfather (who invited this user) - null for seeded/admin users
|
|
||||||
godfather_id: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id"), nullable=True
|
|
||||||
)
|
|
||||||
godfather: Mapped["User | None"] = relationship(
|
|
||||||
"User",
|
|
||||||
remote_side="User.id",
|
|
||||||
foreign_keys=[godfather_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationship to roles
|
|
||||||
roles: Mapped[list[Role]] = relationship(
|
|
||||||
"Role",
|
|
||||||
secondary=user_roles,
|
|
||||||
back_populates="users",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
|
|
||||||
"""Get all permissions from all roles in a single query."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(role_permissions.c.permission)
|
|
||||||
.join(user_roles, role_permissions.c.role_id == user_roles.c.role_id)
|
|
||||||
.where(user_roles.c.user_id == self.id)
|
|
||||||
)
|
|
||||||
return {row[0] for row in result.fetchall()}
|
|
||||||
|
|
||||||
async def has_permission(self, db: AsyncSession, permission: Permission) -> bool:
|
|
||||||
"""Check if user has a specific permission through any of their roles."""
|
|
||||||
permissions = await self.get_permissions(db)
|
|
||||||
return permission in permissions
|
|
||||||
|
|
||||||
@property
|
|
||||||
def role_names(self) -> list[str]:
|
|
||||||
"""Get list of role names for API responses."""
|
|
||||||
return [role.name for role in self.roles]
|
|
||||||
|
|
||||||
|
|
||||||
class Invite(Base):
|
|
||||||
__tablename__ = "invites"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
identifier: Mapped[str] = mapped_column(
|
|
||||||
String(64), unique=True, nullable=False, index=True
|
|
||||||
)
|
|
||||||
status: Mapped[InviteStatus] = mapped_column(
|
|
||||||
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
|
|
||||||
)
|
|
||||||
|
|
||||||
# Godfather - the user who owns this invite
|
|
||||||
godfather_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
godfather: Mapped[User] = relationship(
|
|
||||||
"User",
|
|
||||||
foreign_keys=[godfather_id],
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
||||||
# User who used this invite (null until spent)
|
|
||||||
used_by_id: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id"), nullable=True
|
|
||||||
)
|
|
||||||
used_by: Mapped[User | None] = relationship(
|
|
||||||
"User",
|
|
||||||
foreign_keys=[used_by_id],
|
|
||||||
lazy="joined",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
||||||
)
|
|
||||||
spent_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Availability(Base):
|
|
||||||
"""Admin availability slots for booking."""
|
|
||||||
|
|
||||||
__tablename__ = "availability"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("date", "start_time", name="uq_availability_date_start"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
|
||||||
start_time: Mapped[time] = mapped_column(Time, nullable=False)
|
|
||||||
end_time: Mapped[time] = mapped_column(Time, nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
default=lambda: datetime.now(UTC),
|
|
||||||
onupdate=lambda: datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PriceHistory(Base):
|
|
||||||
"""Price history records from external exchanges."""
|
|
||||||
|
|
||||||
__tablename__ = "price_history"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
source: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
|
||||||
pair: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
||||||
price: Mapped[float] = mapped_column(Float, nullable=False)
|
|
||||||
timestamp: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, index=True
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Exchange(Base):
|
|
||||||
"""Bitcoin exchange trades booked by users."""
|
|
||||||
|
|
||||||
__tablename__ = "exchanges"
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined")
|
|
||||||
|
|
||||||
# Slot timing
|
|
||||||
slot_start: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False, index=True
|
|
||||||
)
|
|
||||||
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
|
|
||||||
# Trade details
|
|
||||||
direction: Mapped[TradeDirection] = mapped_column(
|
|
||||||
Enum(TradeDirection), nullable=False
|
|
||||||
)
|
|
||||||
bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column(
|
|
||||||
Enum(BitcoinTransferMethod),
|
|
||||||
nullable=False,
|
|
||||||
default=BitcoinTransferMethod.ONCHAIN,
|
|
||||||
)
|
|
||||||
eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents
|
|
||||||
sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis
|
|
||||||
|
|
||||||
# Price information (snapshot at booking time)
|
|
||||||
market_price_eur: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False
|
|
||||||
) # EUR per BTC
|
|
||||||
agreed_price_eur: Mapped[float] = mapped_column(
|
|
||||||
Float, nullable=False
|
|
||||||
) # EUR per BTC with premium
|
|
||||||
premium_percentage: Mapped[int] = mapped_column(
|
|
||||||
Integer, nullable=False
|
|
||||||
) # e.g. 5 for 5%
|
|
||||||
|
|
||||||
# Status
|
|
||||||
status: Mapped[ExchangeStatus] = mapped_column(
|
|
||||||
Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
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
|
|
||||||
)
|
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
43
backend/models/__init__.py
Normal file
43
backend/models/__init__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Export all enums
|
||||||
|
# Export association tables
|
||||||
|
from .associations import role_permissions, user_roles
|
||||||
|
|
||||||
|
# Export models
|
||||||
|
from .availability import Availability
|
||||||
|
from .enums import (
|
||||||
|
BitcoinTransferMethod,
|
||||||
|
ExchangeStatus,
|
||||||
|
InviteStatus,
|
||||||
|
Permission,
|
||||||
|
TradeDirection,
|
||||||
|
)
|
||||||
|
from .exchange import Exchange
|
||||||
|
from .invite import Invite
|
||||||
|
from .price_history import PriceHistory
|
||||||
|
|
||||||
|
# Export role configuration
|
||||||
|
from .role_config import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR
|
||||||
|
|
||||||
|
# Export types
|
||||||
|
from .types import RoleConfig
|
||||||
|
from .user import Role, User
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ROLE_ADMIN",
|
||||||
|
"ROLE_DEFINITIONS",
|
||||||
|
"ROLE_REGULAR",
|
||||||
|
"Availability",
|
||||||
|
"BitcoinTransferMethod",
|
||||||
|
"Exchange",
|
||||||
|
"ExchangeStatus",
|
||||||
|
"Invite",
|
||||||
|
"InviteStatus",
|
||||||
|
"Permission",
|
||||||
|
"PriceHistory",
|
||||||
|
"Role",
|
||||||
|
"RoleConfig",
|
||||||
|
"TradeDirection",
|
||||||
|
"User",
|
||||||
|
"role_permissions",
|
||||||
|
"user_roles",
|
||||||
|
]
|
||||||
36
backend/models/associations.py
Normal file
36
backend/models/associations.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from sqlalchemy import Column, Enum, ForeignKey, Integer, Table
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
from .enums import Permission
|
||||||
|
|
||||||
|
# Association table: Role <-> Permission (many-to-many)
|
||||||
|
role_permissions = Table(
|
||||||
|
"role_permissions",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"role_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column("permission", Enum(Permission), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Association table: User <-> Role (many-to-many)
|
||||||
|
user_roles = Table(
|
||||||
|
"user_roles",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"user_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"role_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
28
backend/models/availability.py
Normal file
28
backend/models/availability.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from datetime import UTC, date, datetime, time
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, Integer, Time, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Availability(Base):
|
||||||
|
"""Admin availability slots for booking."""
|
||||||
|
|
||||||
|
__tablename__ = "availability"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("date", "start_time", name="uq_availability_date_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
start_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||||
|
end_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
59
backend/models/enums.py
Normal file
59
backend/models/enums.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(str, PyEnum):
|
||||||
|
"""All available permissions in the system."""
|
||||||
|
|
||||||
|
# Audit permissions
|
||||||
|
VIEW_AUDIT = "view_audit"
|
||||||
|
FETCH_PRICE = "fetch_price"
|
||||||
|
|
||||||
|
# Profile permissions
|
||||||
|
MANAGE_OWN_PROFILE = "manage_own_profile"
|
||||||
|
|
||||||
|
# Invite permissions
|
||||||
|
MANAGE_INVITES = "manage_invites"
|
||||||
|
VIEW_OWN_INVITES = "view_own_invites"
|
||||||
|
|
||||||
|
# Exchange permissions (regular users)
|
||||||
|
CREATE_EXCHANGE = "create_exchange"
|
||||||
|
VIEW_OWN_EXCHANGES = "view_own_exchanges"
|
||||||
|
CANCEL_OWN_EXCHANGE = "cancel_own_exchange"
|
||||||
|
|
||||||
|
# Availability/Exchange permissions (admin)
|
||||||
|
MANAGE_AVAILABILITY = "manage_availability"
|
||||||
|
VIEW_ALL_EXCHANGES = "view_all_exchanges"
|
||||||
|
CANCEL_ANY_EXCHANGE = "cancel_any_exchange"
|
||||||
|
COMPLETE_EXCHANGE = "complete_exchange"
|
||||||
|
|
||||||
|
|
||||||
|
class InviteStatus(str, PyEnum):
|
||||||
|
"""Status of an invite."""
|
||||||
|
|
||||||
|
READY = "ready"
|
||||||
|
SPENT = "spent"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeStatus(str, PyEnum):
|
||||||
|
"""Status of an exchange trade."""
|
||||||
|
|
||||||
|
BOOKED = "booked"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED_BY_USER = "cancelled_by_user"
|
||||||
|
CANCELLED_BY_ADMIN = "cancelled_by_admin"
|
||||||
|
NO_SHOW = "no_show"
|
||||||
|
|
||||||
|
|
||||||
|
class TradeDirection(str, PyEnum):
|
||||||
|
"""Direction of a trade from the user's perspective."""
|
||||||
|
|
||||||
|
BUY = "buy" # User buys BTC, gives EUR
|
||||||
|
SELL = "sell" # User sells BTC, gets EUR
|
||||||
|
|
||||||
|
|
||||||
|
class BitcoinTransferMethod(str, PyEnum):
|
||||||
|
"""Bitcoin transfer method for exchange trades."""
|
||||||
|
|
||||||
|
ONCHAIN = "onchain"
|
||||||
|
LIGHTNING = "lightning"
|
||||||
83
backend/models/exchange.py
Normal file
83
backend/models/exchange.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
from .enums import BitcoinTransferMethod, ExchangeStatus, TradeDirection
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Exchange(Base):
|
||||||
|
"""Bitcoin exchange trades booked by users."""
|
||||||
|
|
||||||
|
__tablename__ = "exchanges"
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="joined")
|
||||||
|
|
||||||
|
# Slot timing
|
||||||
|
slot_start: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# Trade details
|
||||||
|
direction: Mapped[TradeDirection] = mapped_column(
|
||||||
|
Enum(TradeDirection), nullable=False
|
||||||
|
)
|
||||||
|
bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column(
|
||||||
|
Enum(BitcoinTransferMethod),
|
||||||
|
nullable=False,
|
||||||
|
default=BitcoinTransferMethod.ONCHAIN,
|
||||||
|
)
|
||||||
|
eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents
|
||||||
|
sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis
|
||||||
|
|
||||||
|
# Price information (snapshot at booking time)
|
||||||
|
market_price_eur: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False
|
||||||
|
) # EUR per BTC
|
||||||
|
agreed_price_eur: Mapped[float] = mapped_column(
|
||||||
|
Float, nullable=False
|
||||||
|
) # EUR per BTC with premium
|
||||||
|
premium_percentage: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False
|
||||||
|
) # e.g. 5 for 5%
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[ExchangeStatus] = mapped_column(
|
||||||
|
Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
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
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
55
backend/models/invite.py
Normal file
55
backend/models/invite.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
from .enums import InviteStatus
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(Base):
|
||||||
|
__tablename__ = "invites"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
identifier: Mapped[str] = mapped_column(
|
||||||
|
String(64), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
status: Mapped[InviteStatus] = mapped_column(
|
||||||
|
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Godfather - the user who owns this invite
|
||||||
|
godfather_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
godfather: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys=[godfather_id],
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
||||||
|
# User who used this invite (null until spent)
|
||||||
|
used_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
used_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys=[used_by_id],
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
spent_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
26
backend/models/price_history.py
Normal file
26
backend/models/price_history.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PriceHistory(Base):
|
||||||
|
"""Price history records from external exchanges."""
|
||||||
|
|
||||||
|
__tablename__ = "price_history"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
pair: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
price: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
32
backend/models/role_config.py
Normal file
32
backend/models/role_config.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from .enums import Permission
|
||||||
|
from .types import RoleConfig
|
||||||
|
|
||||||
|
# Role name constants
|
||||||
|
ROLE_ADMIN = "admin"
|
||||||
|
ROLE_REGULAR = "regular"
|
||||||
|
|
||||||
|
# Role definitions with their permissions
|
||||||
|
ROLE_DEFINITIONS: dict[str, RoleConfig] = {
|
||||||
|
ROLE_ADMIN: {
|
||||||
|
"description": "Administrator with audit/invite/exchange access",
|
||||||
|
"permissions": [
|
||||||
|
Permission.VIEW_AUDIT,
|
||||||
|
Permission.FETCH_PRICE,
|
||||||
|
Permission.MANAGE_INVITES,
|
||||||
|
Permission.MANAGE_AVAILABILITY,
|
||||||
|
Permission.VIEW_ALL_EXCHANGES,
|
||||||
|
Permission.CANCEL_ANY_EXCHANGE,
|
||||||
|
Permission.COMPLETE_EXCHANGE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ROLE_REGULAR: {
|
||||||
|
"description": "Regular user with profile, invite, and exchange access",
|
||||||
|
"permissions": [
|
||||||
|
Permission.MANAGE_OWN_PROFILE,
|
||||||
|
Permission.VIEW_OWN_INVITES,
|
||||||
|
Permission.CREATE_EXCHANGE,
|
||||||
|
Permission.VIEW_OWN_EXCHANGES,
|
||||||
|
Permission.CANCEL_OWN_EXCHANGE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
8
backend/models/types.py
Normal file
8
backend/models/types.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from .enums import Permission
|
||||||
|
|
||||||
|
|
||||||
|
class RoleConfig(TypedDict):
|
||||||
|
description: str
|
||||||
|
permissions: list[Permission]
|
||||||
98
backend/models/user.py
Normal file
98
backend/models/user.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
from .associations import role_permissions, user_roles
|
||||||
|
from .enums import Permission
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Relationship to users
|
||||||
|
users: Mapped[list["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
secondary=user_roles,
|
||||||
|
back_populates="roles",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
|
||||||
|
"""Get all permissions for this role."""
|
||||||
|
query = select(role_permissions.c.permission).where(
|
||||||
|
role_permissions.c.role_id == self.id
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return {row[0] for row in result.fetchall()}
|
||||||
|
|
||||||
|
async def set_permissions(
|
||||||
|
self, db: AsyncSession, permissions: list[Permission]
|
||||||
|
) -> None:
|
||||||
|
"""Set all permissions for this role (replaces existing)."""
|
||||||
|
delete_query = role_permissions.delete().where(
|
||||||
|
role_permissions.c.role_id == self.id
|
||||||
|
)
|
||||||
|
await db.execute(delete_query)
|
||||||
|
for perm in permissions:
|
||||||
|
insert_query = role_permissions.insert().values(
|
||||||
|
role_id=self.id, permission=perm
|
||||||
|
)
|
||||||
|
await db.execute(insert_query)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
email: Mapped[str] = mapped_column(
|
||||||
|
String(255), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Contact details (all optional)
|
||||||
|
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
telegram: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
signal: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
|
||||||
|
|
||||||
|
# Godfather (who invited this user) - null for seeded/admin users
|
||||||
|
godfather_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
godfather: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
remote_side="User.id",
|
||||||
|
foreign_keys=[godfather_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationship to roles
|
||||||
|
roles: Mapped[list[Role]] = relationship(
|
||||||
|
"Role",
|
||||||
|
secondary=user_roles,
|
||||||
|
back_populates="users",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
|
||||||
|
"""Get all permissions from all roles in a single query."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(role_permissions.c.permission)
|
||||||
|
.join(user_roles, role_permissions.c.role_id == user_roles.c.role_id)
|
||||||
|
.where(user_roles.c.user_id == self.id)
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.fetchall()}
|
||||||
|
|
||||||
|
async def has_permission(self, db: AsyncSession, permission: Permission) -> bool:
|
||||||
|
"""Check if user has a specific permission through any of their roles."""
|
||||||
|
permissions = await self.get_permissions(db)
|
||||||
|
return permission in permissions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_names(self) -> list[str]:
|
||||||
|
"""Get list of role names for API responses."""
|
||||||
|
return [role.name for role in self.roles]
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
"""Pydantic schemas for API request/response models."""
|
|
||||||
|
|
||||||
from datetime import date, datetime, time
|
|
||||||
from typing import Generic, TypeVar
|
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
|
||||||
|
|
||||||
from models import BitcoinTransferMethod, InviteStatus, Permission
|
|
||||||
|
|
||||||
|
|
||||||
class UserCredentials(BaseModel):
|
|
||||||
"""Base model for user email/password."""
|
|
||||||
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
UserCreate = UserCredentials
|
|
||||||
UserLogin = UserCredentials
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
"""Response model for authenticated user info."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
email: str
|
|
||||||
roles: list[str]
|
|
||||||
permissions: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterWithInvite(BaseModel):
|
|
||||||
"""Request model for registration with invite."""
|
|
||||||
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
invite_identifier: str
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
|
||||||
"""Minimal user info for admin dropdowns."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
email: str
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Availability Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Booking Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Exchange Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
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 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 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)."""
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Meta/Constants Schemas
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 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
|
|
||||||
88
backend/schemas/__init__.py
Normal file
88
backend/schemas/__init__.py
Normal 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
20
backend/schemas/auth.py
Normal 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
|
||||||
47
backend/schemas/availability.py
Normal file
47
backend/schemas/availability.py
Normal 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]
|
||||||
87
backend/schemas/exchange.py
Normal file
87
backend/schemas/exchange.py
Normal 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
48
backend/schemas/invite.py
Normal 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
16
backend/schemas/meta.py
Normal 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]
|
||||||
15
backend/schemas/pagination.py
Normal file
15
backend/schemas/pagination.py
Normal 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
44
backend/schemas/price.py
Normal 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
|
||||||
20
backend/schemas/profile.py
Normal file
20
backend/schemas/profile.py
Normal 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
24
backend/schemas/user.py
Normal 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
|
||||||
|
|
@ -65,9 +65,13 @@ async def seed_base_data(db: AsyncSession) -> None:
|
||||||
)
|
)
|
||||||
db.add(regular_user)
|
db.add(regular_user)
|
||||||
else:
|
else:
|
||||||
# Update existing user
|
# Update existing user - clear profile fields for test isolation
|
||||||
regular_user.hashed_password = get_password_hash(dev_user_password)
|
regular_user.hashed_password = get_password_hash(dev_user_password)
|
||||||
regular_user.roles = [regular_role]
|
regular_user.roles = [regular_role]
|
||||||
|
regular_user.contact_email = None
|
||||||
|
regular_user.telegram = None
|
||||||
|
regular_user.signal = None
|
||||||
|
regular_user.nostr_npub = None
|
||||||
|
|
||||||
# Create admin dev user
|
# Create admin dev user
|
||||||
admin_user_result = await db.execute(
|
admin_user_result = await db.execute(
|
||||||
|
|
@ -83,8 +87,12 @@ async def seed_base_data(db: AsyncSession) -> None:
|
||||||
)
|
)
|
||||||
db.add(admin_user)
|
db.add(admin_user)
|
||||||
else:
|
else:
|
||||||
# Update existing user
|
# Update existing user - clear profile fields for test isolation
|
||||||
admin_user.hashed_password = get_password_hash(dev_admin_password)
|
admin_user.hashed_password = get_password_hash(dev_admin_password)
|
||||||
admin_user.roles = [admin_role]
|
admin_user.roles = [admin_role]
|
||||||
|
admin_user.contact_email = None
|
||||||
|
admin_user.telegram = None
|
||||||
|
admin_user.signal = None
|
||||||
|
admin_user.nostr_npub = None
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
|
|
||||||
// Get the testid so we can find the same card later
|
// Get the testid so we can find the same card later
|
||||||
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
|
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
|
||||||
const targetCard = page.locator(`[data-testid="${testId}"]`);
|
if (!testId) {
|
||||||
|
throw new Error("Could not get testid from day card");
|
||||||
|
}
|
||||||
|
|
||||||
// First add availability
|
// First add availability
|
||||||
await dayCardWithNoAvailability.click();
|
await dayCardWithNoAvailability.click();
|
||||||
|
|
@ -83,8 +85,14 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
await saveGetPromise;
|
await saveGetPromise;
|
||||||
await expect(page.getByRole("heading", { name: /Edit Time Slots/ })).not.toBeVisible();
|
await expect(page.getByRole("heading", { name: /Edit Time Slots/ })).not.toBeVisible();
|
||||||
|
|
||||||
// Verify slot exists in the specific card we clicked
|
// Re-query the card after save to avoid stale element references
|
||||||
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible();
|
// React may have re-rendered the entire list, so we need a fresh reference
|
||||||
|
const targetCard = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
|
||||||
|
// Wait for "No availability" to disappear first, indicating slots have been loaded
|
||||||
|
await expect(targetCard.getByText("No availability")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
// Then verify the specific slot text appears - this ensures the component has re-rendered
|
||||||
|
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Now clear it - click on the same card using the testid
|
// Now clear it - click on the same card using the testid
|
||||||
await targetCard.click();
|
await targetCard.click();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getBackendUrl } from "./helpers/backend-url";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Set up availability for a date using the API
|
// Set up availability for a date using the API
|
||||||
|
// Includes retry logic to handle race conditions with database reset
|
||||||
async function setAvailability(page: Page, dateStr: string) {
|
async function setAvailability(page: Page, dateStr: string) {
|
||||||
const cookies = await page.context().cookies();
|
const cookies = await page.context().cookies();
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
@ -18,21 +19,63 @@ async function setAvailability(page: Page, dateStr: string) {
|
||||||
throw new Error("No auth cookie found when trying to set availability");
|
throw new Error("No auth cookie found when trying to set availability");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, {
|
const maxRetries = 3;
|
||||||
headers: {
|
let lastError: Error | null = null;
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
date: dateStr,
|
|
||||||
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok()) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
const body = await response.text();
|
try {
|
||||||
throw new Error(`Failed to set availability: ${response.status()} - ${body}`);
|
const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
date: dateStr,
|
||||||
|
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok()) {
|
||||||
|
// Verify the response indicates success
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.date === dateStr && body.slots?.length > 0) {
|
||||||
|
return; // Success
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected availability response: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
const error = new Error(`Failed to set availability: ${response.status()} - ${body}`);
|
||||||
|
|
||||||
|
// Don't retry on 4xx errors (client errors), only on 5xx (server errors)
|
||||||
|
if (response.status() >= 400 && response.status() < 500) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Don't retry on the last attempt
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
// Exponential backoff: 200ms, 400ms, 800ms
|
||||||
|
const delay = 200 * Math.pow(2, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Don't retry on the last attempt
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
// Exponential backoff: 200ms, 400ms, 800ms
|
||||||
|
const delay = 200 * Math.pow(2, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
throw new Error(`Failed to set availability after ${maxRetries} attempts: ${lastError?.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Exchange Page - Regular User Access", () => {
|
test.describe("Exchange Page - Regular User Access", () => {
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,41 @@ import { getBackendUrl } from "./backend-url";
|
||||||
/**
|
/**
|
||||||
* Reset the database for the current worker.
|
* Reset the database for the current worker.
|
||||||
* Truncates all tables and re-seeds base data.
|
* Truncates all tables and re-seeds base data.
|
||||||
|
* Retries up to 3 times with exponential backoff to handle transient failures.
|
||||||
*/
|
*/
|
||||||
export async function resetDatabase(request: APIRequestContext): Promise<void> {
|
export async function resetDatabase(request: APIRequestContext): Promise<void> {
|
||||||
const backendUrl = getBackendUrl();
|
const backendUrl = getBackendUrl();
|
||||||
const response = await request.post(`${backendUrl}/api/test/reset`);
|
const maxRetries = 3;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
if (!response.ok()) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
const text = await response.text();
|
try {
|
||||||
throw new Error(`Failed to reset database: ${response.status()} - ${text}`);
|
const response = await request.post(`${backendUrl}/api/test/reset`);
|
||||||
|
|
||||||
|
if (response.ok()) {
|
||||||
|
// Verify the response body indicates success
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.status === "reset") {
|
||||||
|
return; // Success
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected reset response: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to reset database: ${response.status()} - ${text}`);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Don't retry on the last attempt
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
// Exponential backoff: 100ms, 200ms, 400ms
|
||||||
|
const delay = 100 * Math.pow(2, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we get here, all retries failed
|
||||||
|
throw new Error(`Failed to reset database after ${maxRetries} attempts: ${lastError?.message}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,12 @@ test.beforeEach(async ({ context, request }, testInfo) => {
|
||||||
process.env.NEXT_PUBLIC_API_URL = backendUrl;
|
process.env.NEXT_PUBLIC_API_URL = backendUrl;
|
||||||
|
|
||||||
// Reset database before each test for isolation
|
// Reset database before each test for isolation
|
||||||
try {
|
// This must complete successfully before tests run to avoid race conditions
|
||||||
await resetDatabase(request);
|
await resetDatabase(request);
|
||||||
} catch (error) {
|
|
||||||
// If reset fails, log but don't fail the test
|
// Small delay to ensure database transaction commits are visible
|
||||||
// This allows tests to run even if reset endpoint is unavailable
|
// This prevents race conditions where tests start before reset completes
|
||||||
console.warn(`Failed to reset database: ${error}`);
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
|
||||||
|
|
||||||
// Add init script to set English language before any page loads
|
// Add init script to set English language before any page loads
|
||||||
// This must be called before any page.goto() calls
|
// This must be called before any page.goto() calls
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,43 @@ async function loginUser(page: Page, email: string, password: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to clear profile data via API
|
// Helper to clear profile data via API
|
||||||
|
// Verifies the operation succeeds to prevent race conditions
|
||||||
async function clearProfileData(page: Page) {
|
async function clearProfileData(page: Page) {
|
||||||
const cookies = await page.context().cookies();
|
const cookies = await page.context().cookies();
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (!authCookie) {
|
||||||
await page.request.put(`${getBackendUrl()}/api/profile`, {
|
throw new Error("No auth cookie found when trying to clear profile data");
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
contact_email: null,
|
|
||||||
telegram: null,
|
|
||||||
signal: null,
|
|
||||||
nostr_npub: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await page.request.put(`${getBackendUrl()}/api/profile`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
contact_email: null,
|
||||||
|
telegram: null,
|
||||||
|
signal: null,
|
||||||
|
nostr_npub: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to clear profile data: ${response.status()} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the response indicates fields were cleared
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.telegram !== null && body.telegram !== undefined && body.telegram !== "") {
|
||||||
|
throw new Error(
|
||||||
|
`Profile data not cleared properly. Telegram still has value: ${body.telegram}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure database commit is visible to subsequent operations
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Profile - Regular User Access", () => {
|
test.describe("Profile - Regular User Access", () => {
|
||||||
|
|
@ -122,14 +141,23 @@ test.describe("Profile - Form Behavior", () => {
|
||||||
});
|
});
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
// Clear any existing profile data
|
// Clear any existing profile data and verify it's cleared
|
||||||
await clearProfileData(page);
|
await clearProfileData(page);
|
||||||
|
// Navigate to profile page to verify it's actually cleared
|
||||||
|
await page.goto("/profile");
|
||||||
|
// Wait for page to load and verify fields are empty
|
||||||
|
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
||||||
|
await expect(page.getByLabel("Signal")).toHaveValue("");
|
||||||
|
await expect(page.getByLabel("Nostr (npub)")).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("form state management, save, persistence, and clearing fields", async ({ page }) => {
|
test("form state management, save, persistence, and clearing fields", async ({ page }) => {
|
||||||
|
// Page is already loaded in beforeEach, but ensure we're on it
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// All editable fields should be empty
|
// All editable fields should be empty (verified in beforeEach, but double-check)
|
||||||
await expect(page.getByLabel("Contact Email")).toHaveValue("");
|
await expect(page.getByLabel("Contact Email")).toHaveValue("");
|
||||||
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
||||||
await expect(page.getByLabel("Signal")).toHaveValue("");
|
await expect(page.getByLabel("Signal")).toHaveValue("");
|
||||||
|
|
@ -166,13 +194,17 @@ test.describe("Profile - Form Behavior", () => {
|
||||||
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
||||||
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
|
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Clear the field
|
// Clear the field - use clear() instead of fill("") for reliable clearing
|
||||||
await page.fill("#telegram", "");
|
await page.locator("#telegram").clear();
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save Changes")');
|
||||||
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Reload and verify it's cleared
|
// Reload and wait for page to fully load before checking
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
// Wait for the form to be loaded (check for a form field to ensure page is ready)
|
||||||
|
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
|
||||||
|
// Verify telegram field is cleared
|
||||||
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue