refactors
This commit is contained in:
parent
4e1a339432
commit
82c4d0168e
28 changed files with 1042 additions and 782 deletions
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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue