refactors

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

View file

@ -0,0 +1,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",
]

View 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,
),
)

View 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
View 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"

View 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
View 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
)

View 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)
)

View 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
View 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
View 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]