Skip to content

Models

The model layer lives in website/models/ and contains SQLAlchemy models only — no business logic.

Models define:

  • Database columns and types
  • Relationships and foreign keys
  • Constraints (unique, not null, etc.)
  • Serialization via SerializableMixin

Overview

Model Description
Game A tabletop RPG game (one-shot or campaign)
GameSession A scheduled session belonging to a game
GameEvent Audit trail entry for game lifecycle events
User A Discord-authenticated user
Trophy An achievement definition
UserTrophy A trophy awarded to a user
System A tabletop RPG system (e.g. D&D 5e, Pathfinder)
Channel A Discord channel category managed by the app
SpecialEvent A special community event (e.g. Halloween, Christmas)
Vtt A virtual tabletop tool (e.g. Foundry, Roll20)

API Reference

SQLAlchemy model definitions for QuestMaster.

Channel

Bases: Model, SerializableMixin

Discord channel category used for game organization.

Attributes:

Name Type Description
id

Discord channel ID.

type

Game type this category serves (oneshot or campaign).

size

Current number of channels in this category.

Source code in website/models/channel.py
class Channel(db.Model, SerializableMixin):
    """Discord channel category used for game organization.

    Attributes:
        id: Discord channel ID.
        type: Game type this category serves (oneshot or campaign).
        size: Current number of channels in this category.
    """

    __tablename__ = "channel"

    _exclude_fields = []
    _relationship_fields = []

    id = db.Column(db.String(), primary_key=True)
    type = db.Column(ENUM(*GAME_TYPES, name="game_type_enum", create_type=False), nullable=False)
    size = db.Column(db.Integer(), nullable=False, default=0)

    @classmethod
    def from_dict(cls, data):
        """Create a Channel instance from a Python dict."""
        return cls(
            id=data.get("id"),
            type=data.get("type"),
            size=data.get("size", 0),
        )

    def update_from_dict(self, data):
        """Update the Channel instance from a dict (in place)."""
        super().update_from_dict(data)
        return self

    def __repr__(self):
        return f"<Channel id='{self.id}' type='{self.type}' size={self.size}>"

    def __eq__(self, other):
        if not isinstance(other, Channel):
            return NotImplemented
        return self.id == other.id and self.type == other.type and self.size == other.size

    def __ne__(self, other):
        return not self.__eq__(other)

from_dict(data) classmethod

Create a Channel instance from a Python dict.

Source code in website/models/channel.py
@classmethod
def from_dict(cls, data):
    """Create a Channel instance from a Python dict."""
    return cls(
        id=data.get("id"),
        type=data.get("type"),
        size=data.get("size", 0),
    )

update_from_dict(data)

Update the Channel instance from a dict (in place).

Source code in website/models/channel.py
def update_from_dict(self, data):
    """Update the Channel instance from a dict (in place)."""
    super().update_from_dict(data)
    return self

Game

Bases: Model

Represents a tabletop RPG game (oneshot or campaign).

Source code in website/models/game.py
class Game(db.Model):
    """
    Represents a tabletop RPG game (oneshot or campaign).
    """

    __tablename__ = "game"
    COLORS = {"oneshot": 0x198754, "campaign": 0x0D6EFD}

    id = db.Column(db.BigInteger(), primary_key=True)
    slug = db.Column(db.String(), unique=True, index=True)
    name = db.Column(db.String(), nullable=False)
    type = db.Column("type", Enum(*GAME_TYPES, name="game_type_enum"), nullable=False)
    length = db.Column(db.String(), nullable=False)
    gm_id = db.Column(db.String(), db.ForeignKey("user.id"), nullable=False)
    gm = db.relationship("User", back_populates="games_gm", foreign_keys=[gm_id])
    system_id = db.Column(db.Integer(), db.ForeignKey("system.id"), nullable=False)
    vtt_id = db.Column(db.Integer(), db.ForeignKey("vtt.id"), nullable=True)
    description = db.Column(db.Text(), nullable=False)
    restriction = db.Column(
        "restriction", Enum(*RESTRICTIONS, name="restriction_enum"), nullable=False
    )
    restriction_tags = db.Column(db.String())
    party_size = db.Column(db.Integer(), nullable=False, default=4)
    party_selection = db.Column(db.Boolean(), nullable=False, default=False)
    players = db.relationship("User", secondary=players_table, backref="games")
    xp = db.Column("experience", Enum(*GAME_XP, name="game_xp_enum"), default="all")
    date = db.Column(db.DateTime, nullable=False)
    session_length = db.Column(db.DECIMAL(2, 1), nullable=False)
    frequency = db.Column("frequency", Enum(*GAME_FREQUENCIES, name="game_frequency_enum"))
    characters = db.Column("characters", Enum(*GAME_CHAR, name="game_char_enum"))
    classification = db.Column(MutableDict.as_mutable(JSONB))
    ambience = db.Column(pg.ARRAY(Enum(*AMBIENCES, name="game_ambience_enum")))
    complement = db.Column(db.Text())
    img = db.Column(db.String())
    sessions = db.relationship("GameSession", backref="game")
    channel = db.Column(db.String())
    msg_id = db.Column(db.String())
    role = db.Column(db.String())
    status = db.Column(
        "status",
        Enum(*GAME_STATUS, name="game_status_enum"),
        nullable=False,
        server_default="draft",
    )
    special_event_id = db.Column(db.Integer, db.ForeignKey("special_event.id"), nullable=True)
    special_event = db.relationship("SpecialEvent", back_populates="games")

    @orm.validates("classification")
    def validate_classification(self, key, value):
        """Validate the classification JSON against the expected schema."""
        try:
            if value:
                CLASSIFICATION_SCHEMA.validate(value)
            return value
        except SchemaError:
            raise ValidationError(
                "Invalid classification format.",
                field="classification",
                details={"value": value},
            )

    @orm.validates("party_size")
    def validate_party_size(self, key, value):
        """Ensure party size is at least one."""
        if int(value) < 1:
            raise ValidationError(
                "Number of players must be at least one.",
                field="party_size",
                details={"value": value},
            )
        return value

    def _serialize_relation(self, obj):
        """Helper to serialize a single related object."""
        if obj and hasattr(obj, "to_dict"):
            return obj.to_dict()
        return None

    def _serialize_relation_list(self, objects):
        """Helper to serialize a list of related objects."""
        return [obj.to_dict() for obj in objects if hasattr(obj, "to_dict")]

    def _add_relationships_to_dict(self, data):
        """Add relationship data to the dictionary."""
        data["gm"] = self._serialize_relation(getattr(self, "gm", None))
        data["system"] = self._serialize_relation(getattr(self, "system", None))
        data["vtt"] = self._serialize_relation(getattr(self, "vtt", None))
        data["players"] = self._serialize_relation_list(getattr(self, "players", []))
        data["sessions"] = self._serialize_relation_list(getattr(self, "sessions", []))
        data["special_event"] = self._serialize_relation(getattr(self, "special_event", None))

    def to_dict(self, include_relationships: bool = False):
        """
        Serialize the Game instance into a Python dict.

        Args:
            include_relationships: If True, includes nested objects
                (gm, system, vtt, players, sessions). If False, only
                includes IDs.
        """
        data = {
            "id": self.id,
            "slug": self.slug,
            "name": self.name,
            "type": self.type,
            "length": self.length,
            "gm_id": self.gm_id,
            "system_id": self.system_id,
            "vtt_id": self.vtt_id,
            "description": self.description,
            "restriction": self.restriction,
            "restriction_tags": self.restriction_tags,
            "party_size": self.party_size,
            "party_selection": self.party_selection,
            "xp": self.xp,
            "date": self.date.isoformat() if self.date else None,
            "session_length": (float(self.session_length) if self.session_length else None),
            "frequency": self.frequency,
            "characters": self.characters,
            "classification": self.classification,
            "ambience": list(self.ambience) if self.ambience else None,
            "complement": self.complement,
            "img": self.img,
            "channel": self.channel,
            "msg_id": self.msg_id,
            "role": self.role,
            "status": self.status,
            "special_event_id": self.special_event_id,
        }

        if include_relationships:
            self._add_relationships_to_dict(data)
        else:
            data["player_ids"] = [p.id for p in self.players]

        return data

    def to_json(self, include_relationships=False):
        """
        Alias for to_dict() for API compatibility.
        """
        return self.to_dict(include_relationships=include_relationships)

    @property
    def json(self):
        """
        Property alias for JSON serialization.
        """
        return self.to_dict()

    @classmethod
    def from_dict(cls, data):
        """
        Create a Game instance from a Python dict.
        Note: This does not handle relationships (gm, players, sessions, etc.).
        Those should be set separately after creation.
        """
        from datetime import datetime
        from decimal import Decimal

        # Convert date string to datetime if needed
        date_value = data.get("date")
        if isinstance(date_value, str):
            date_value = datetime.fromisoformat(date_value)

        # Convert session_length to Decimal if needed
        session_length_value = data.get("session_length")
        if session_length_value is not None and not isinstance(session_length_value, Decimal):
            session_length_value = Decimal(str(session_length_value))

        return cls(
            id=data.get("id"),
            slug=data.get("slug"),
            name=data.get("name"),
            type=data.get("type"),
            length=data.get("length"),
            gm_id=data.get("gm_id"),
            system_id=data.get("system_id"),
            vtt_id=data.get("vtt_id"),
            description=data.get("description"),
            restriction=data.get("restriction"),
            restriction_tags=data.get("restriction_tags"),
            party_size=data.get("party_size"),
            party_selection=data.get("party_selection"),
            xp=data.get("xp"),
            date=date_value,
            session_length=session_length_value,
            frequency=data.get("frequency"),
            characters=data.get("characters"),
            classification=data.get("classification"),
            ambience=data.get("ambience"),
            complement=data.get("complement"),
            img=data.get("img"),
            channel=data.get("channel"),
            msg_id=data.get("msg_id"),
            role=data.get("role"),
            status=data.get("status"),
            special_event_id=data.get("special_event_id"),
        )

    @classmethod
    def from_json(cls, data):
        """
        Alias for from_dict() for API compatibility.
        """
        return cls.from_dict(data)

    def update_from_dict(self, data):
        """
        Update the Game instance from a dict (in place).
        Protected fields (id, slug) are excluded from updates.
        Relationships must be handled separately.
        """
        from datetime import datetime
        from decimal import Decimal

        # Fields that should not be updated via this method
        protected_fields = {"id", "slug"}

        for key, value in data.items():
            if key in protected_fields:
                continue
            if hasattr(self, key) and key not in [
                "gm",
                "players",
                "sessions",
                "system",
                "vtt",
                "special_event",
            ]:
                # Handle special conversions
                if key == "date" and isinstance(value, str):
                    value = datetime.fromisoformat(value)
                elif (
                    key == "session_length"
                    and value is not None
                    and not isinstance(value, Decimal)
                ):
                    value = Decimal(str(value))

                setattr(self, key, value)
        return self

    def __repr__(self):
        return (
            f"<Game id={self.id} slug='{self.slug}' name='{self.name}' "
            f"type='{self.type}' status='{self.status}'>"
        )

    def __eq__(self, other):
        if not isinstance(other, Game):
            return NotImplemented
        return (
            self.id == other.id
            and self.slug == other.slug
            and self.name == other.name
            and self.type == other.type
            and self.status == other.status
        )

    def __ne__(self, other):
        result = self.__eq__(other)
        if result is NotImplemented:
            return NotImplemented
        return not result

json property

Property alias for JSON serialization.

validate_classification(key, value)

Validate the classification JSON against the expected schema.

Source code in website/models/game.py
@orm.validates("classification")
def validate_classification(self, key, value):
    """Validate the classification JSON against the expected schema."""
    try:
        if value:
            CLASSIFICATION_SCHEMA.validate(value)
        return value
    except SchemaError:
        raise ValidationError(
            "Invalid classification format.",
            field="classification",
            details={"value": value},
        )

validate_party_size(key, value)

Ensure party size is at least one.

Source code in website/models/game.py
@orm.validates("party_size")
def validate_party_size(self, key, value):
    """Ensure party size is at least one."""
    if int(value) < 1:
        raise ValidationError(
            "Number of players must be at least one.",
            field="party_size",
            details={"value": value},
        )
    return value

to_dict(include_relationships=False)

Serialize the Game instance into a Python dict.

Parameters:

Name Type Description Default
include_relationships bool

If True, includes nested objects (gm, system, vtt, players, sessions). If False, only includes IDs.

False
Source code in website/models/game.py
def to_dict(self, include_relationships: bool = False):
    """
    Serialize the Game instance into a Python dict.

    Args:
        include_relationships: If True, includes nested objects
            (gm, system, vtt, players, sessions). If False, only
            includes IDs.
    """
    data = {
        "id": self.id,
        "slug": self.slug,
        "name": self.name,
        "type": self.type,
        "length": self.length,
        "gm_id": self.gm_id,
        "system_id": self.system_id,
        "vtt_id": self.vtt_id,
        "description": self.description,
        "restriction": self.restriction,
        "restriction_tags": self.restriction_tags,
        "party_size": self.party_size,
        "party_selection": self.party_selection,
        "xp": self.xp,
        "date": self.date.isoformat() if self.date else None,
        "session_length": (float(self.session_length) if self.session_length else None),
        "frequency": self.frequency,
        "characters": self.characters,
        "classification": self.classification,
        "ambience": list(self.ambience) if self.ambience else None,
        "complement": self.complement,
        "img": self.img,
        "channel": self.channel,
        "msg_id": self.msg_id,
        "role": self.role,
        "status": self.status,
        "special_event_id": self.special_event_id,
    }

    if include_relationships:
        self._add_relationships_to_dict(data)
    else:
        data["player_ids"] = [p.id for p in self.players]

    return data

to_json(include_relationships=False)

Alias for to_dict() for API compatibility.

Source code in website/models/game.py
def to_json(self, include_relationships=False):
    """
    Alias for to_dict() for API compatibility.
    """
    return self.to_dict(include_relationships=include_relationships)

from_dict(data) classmethod

Create a Game instance from a Python dict. Note: This does not handle relationships (gm, players, sessions, etc.). Those should be set separately after creation.

Source code in website/models/game.py
@classmethod
def from_dict(cls, data):
    """
    Create a Game instance from a Python dict.
    Note: This does not handle relationships (gm, players, sessions, etc.).
    Those should be set separately after creation.
    """
    from datetime import datetime
    from decimal import Decimal

    # Convert date string to datetime if needed
    date_value = data.get("date")
    if isinstance(date_value, str):
        date_value = datetime.fromisoformat(date_value)

    # Convert session_length to Decimal if needed
    session_length_value = data.get("session_length")
    if session_length_value is not None and not isinstance(session_length_value, Decimal):
        session_length_value = Decimal(str(session_length_value))

    return cls(
        id=data.get("id"),
        slug=data.get("slug"),
        name=data.get("name"),
        type=data.get("type"),
        length=data.get("length"),
        gm_id=data.get("gm_id"),
        system_id=data.get("system_id"),
        vtt_id=data.get("vtt_id"),
        description=data.get("description"),
        restriction=data.get("restriction"),
        restriction_tags=data.get("restriction_tags"),
        party_size=data.get("party_size"),
        party_selection=data.get("party_selection"),
        xp=data.get("xp"),
        date=date_value,
        session_length=session_length_value,
        frequency=data.get("frequency"),
        characters=data.get("characters"),
        classification=data.get("classification"),
        ambience=data.get("ambience"),
        complement=data.get("complement"),
        img=data.get("img"),
        channel=data.get("channel"),
        msg_id=data.get("msg_id"),
        role=data.get("role"),
        status=data.get("status"),
        special_event_id=data.get("special_event_id"),
    )

from_json(data) classmethod

Alias for from_dict() for API compatibility.

Source code in website/models/game.py
@classmethod
def from_json(cls, data):
    """
    Alias for from_dict() for API compatibility.
    """
    return cls.from_dict(data)

update_from_dict(data)

Update the Game instance from a dict (in place). Protected fields (id, slug) are excluded from updates. Relationships must be handled separately.

Source code in website/models/game.py
def update_from_dict(self, data):
    """
    Update the Game instance from a dict (in place).
    Protected fields (id, slug) are excluded from updates.
    Relationships must be handled separately.
    """
    from datetime import datetime
    from decimal import Decimal

    # Fields that should not be updated via this method
    protected_fields = {"id", "slug"}

    for key, value in data.items():
        if key in protected_fields:
            continue
        if hasattr(self, key) and key not in [
            "gm",
            "players",
            "sessions",
            "system",
            "vtt",
            "special_event",
        ]:
            # Handle special conversions
            if key == "date" and isinstance(value, str):
                value = datetime.fromisoformat(value)
            elif (
                key == "session_length"
                and value is not None
                and not isinstance(value, Decimal)
            ):
                value = Decimal(str(value))

            setattr(self, key, value)
    return self

GameEvent

Bases: Model, SerializableMixin

Audit log entry recording an action on a game.

Attributes:

Name Type Description
id

Primary key.

timestamp

When the event occurred (UTC).

action

Event action type (create, edit, delete, etc.).

game_id

Foreign key to the related game.

description

Optional human-readable description.

Source code in website/models/game_event.py
class GameEvent(db.Model, SerializableMixin):
    """Audit log entry recording an action on a game.

    Attributes:
        id: Primary key.
        timestamp: When the event occurred (UTC).
        action: Event action type (create, edit, delete, etc.).
        game_id: Foreign key to the related game.
        description: Optional human-readable description.
    """

    __tablename__ = "game_event"

    _exclude_fields = []
    _relationship_fields = ["game", "user_id"]

    id = db.Column(db.Integer, primary_key=True)
    timestamp = db.Column(
        db.DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        nullable=False,
    )
    action = db.Column(Enum(*EVENT_ACTIONS, name="action_type_enum"), nullable=False)
    game_id = db.Column(db.Integer, db.ForeignKey("game.id", ondelete="CASCADE"), nullable=False)
    description = db.Column(db.Text)
    game = db.relationship("Game", backref="events", cascade="all,delete")
    user_id = db.Column(db.String(), db.ForeignKey("user.id"), nullable=True)
    user = db.relationship("User")

    __table_args__ = (
        db.Index("ix_gameevent_timestamp", "timestamp"),
        db.Index("ix_gameevent_game", "game_id"),
    )

    @classmethod
    def from_dict(cls, data):
        """Create a GameEvent instance from a Python dict."""
        return cls(
            id=data.get("id"),
            timestamp=data.get("timestamp"),
            action=data.get("action"),
            game_id=data.get("game_id"),
            description=data.get("description"),
            user_id=data.get("user_id"),
        )

    def update_from_dict(self, data):
        """Update the GameEvent instance from a dict (in place)."""
        super().update_from_dict(data)
        return self

    def __repr__(self):
        return f"<GameEvent id={self.id} action='{self.action}' game_id={self.game_id}>"

    def __eq__(self, other):
        if not isinstance(other, GameEvent):
            return NotImplemented
        return (
            self.id == other.id
            and self.timestamp == other.timestamp
            and self.action == other.action
            and self.game_id == other.game_id
            and self.description == other.description
            and self.user_id == other.user_id
        )

    def __ne__(self, other):
        return not self.__eq__(other)

from_dict(data) classmethod

Create a GameEvent instance from a Python dict.

Source code in website/models/game_event.py
@classmethod
def from_dict(cls, data):
    """Create a GameEvent instance from a Python dict."""
    return cls(
        id=data.get("id"),
        timestamp=data.get("timestamp"),
        action=data.get("action"),
        game_id=data.get("game_id"),
        description=data.get("description"),
        user_id=data.get("user_id"),
    )

update_from_dict(data)

Update the GameEvent instance from a dict (in place).

Source code in website/models/game_event.py
def update_from_dict(self, data):
    """Update the GameEvent instance from a dict (in place)."""
    super().update_from_dict(data)
    return self

GameSession

Bases: Model, SerializableMixin

A scheduled play session belonging to a Game.

Attributes:

Name Type Description
id

Primary key.

game_id

Foreign key to the parent game.

start

Session start datetime.

end

Session end datetime.

Source code in website/models/game_session.py
class GameSession(db.Model, SerializableMixin):
    """A scheduled play session belonging to a Game.

    Attributes:
        id: Primary key.
        game_id: Foreign key to the parent game.
        start: Session start datetime.
        end: Session end datetime.
    """

    __tablename__ = "game_session"

    _exclude_fields = []
    _relationship_fields = []

    id = db.Column(db.BigInteger, primary_key=True)
    game_id = db.Column(db.Integer, db.ForeignKey("game.id"))
    start = db.Column(db.DateTime, nullable=False)
    end = db.Column(db.DateTime, nullable=False)

    @classmethod
    def from_dict(cls, data):
        return cls(
            id=data.get("id"),
            game_id=data.get("game_id"),
            start=data.get("start"),
            end=data.get("end"),
        )

    def update_from_dict(self, data):
        super().update_from_dict(data)
        return self

SpecialEvent

Bases: Model, SerializableMixin

A themed event that groups related games (e.g. Halloween, conventions).

Attributes:

Name Type Description
id

Primary key.

name

Unique event name.

emoji

Optional emoji displayed alongside the event.

color

Optional color as integer for Discord embeds.

active

Whether the event is currently running.

Source code in website/models/special_event.py
class SpecialEvent(db.Model, SerializableMixin):
    """A themed event that groups related games (e.g. Halloween, conventions).

    Attributes:
        id: Primary key.
        name: Unique event name.
        emoji: Optional emoji displayed alongside the event.
        color: Optional color as integer for Discord embeds.
        active: Whether the event is currently running.
    """

    __tablename__ = "special_event"

    _exclude_fields = []
    _relationship_fields = ["games"]

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String, unique=True, nullable=False)
    emoji = db.Column(db.String, nullable=True)
    color = db.Column(db.Integer, nullable=True)
    active = db.Column(db.Boolean, default=False, nullable=False)

    games = db.relationship("Game", back_populates="special_event")

    @classmethod
    def from_dict(cls, data):
        return cls(
            id=data.get("id"),
            name=data.get("name"),
            emoji=data.get("emoji"),
            color=data.get("color"),
            active=data.get("active", False),
        )

    def update_from_dict(self, data):
        super().update_from_dict(data)
        return self

    def __str__(self):
        hex_color = f"#{self.color:06x}" if isinstance(self.color, int) else (self.color or "")
        return f"{self.emoji} {self.name} ({hex_color})"

System

Bases: Model, SerializableMixin

Represents a game system (e.g., D&D 5e, Call of Cthulhu).

Source code in website/models/system.py
class System(db.Model, SerializableMixin):
    """
    Represents a game system (e.g., D&D 5e, Call of Cthulhu).
    """

    __tablename__ = "system"

    _exclude_fields = []
    _relationship_fields = ["games_system"]

    id = db.Column(db.BigInteger, primary_key=True)
    name = db.Column(db.String(), nullable=False, unique=True)
    icon = db.Column(db.String(), nullable=True)

    games_system = db.relationship("Game", backref="system")

    @staticmethod
    def get_systems():
        """Return a list of all Systems, ordered by name."""
        return System.query.order_by("name").all()

    @classmethod
    def from_dict(cls, data):
        """
        Create a System instance from a Python dict.
        """
        return cls(
            id=data.get("id"),
            name=data.get("name"),
            icon=data.get("icon"),
        )

    def update_from_dict(self, data):
        """
        Update the System instance from a dict (in place).
        """
        super().update_from_dict(data)
        return self

    def __repr__(self):
        return f"<System id={self.id} name='{self.name}' icon='{self.icon}'>"

    def __eq__(self, other):
        if not isinstance(other, System):
            return NotImplemented
        return self.id == other.id and self.name == other.name and self.icon == other.icon

    def __ne__(self, other):
        result = self.__eq__(other)
        if result is NotImplemented:
            return NotImplemented
        return not result

get_systems() staticmethod

Return a list of all Systems, ordered by name.

Source code in website/models/system.py
@staticmethod
def get_systems():
    """Return a list of all Systems, ordered by name."""
    return System.query.order_by("name").all()

from_dict(data) classmethod

Create a System instance from a Python dict.

Source code in website/models/system.py
@classmethod
def from_dict(cls, data):
    """
    Create a System instance from a Python dict.
    """
    return cls(
        id=data.get("id"),
        name=data.get("name"),
        icon=data.get("icon"),
    )

update_from_dict(data)

Update the System instance from a dict (in place).

Source code in website/models/system.py
def update_from_dict(self, data):
    """
    Update the System instance from a dict (in place).
    """
    super().update_from_dict(data)
    return self

Trophy

Bases: Model, SerializableMixin

Trophy definition (badge template).

Attributes:

Name Type Description
id

Primary key.

name

Unique trophy name.

unique

If True, a user can only earn this trophy once.

icon

Path or URL to the trophy icon.

Source code in website/models/trophy.py
class Trophy(db.Model, SerializableMixin):
    """Trophy definition (badge template).

    Attributes:
        id: Primary key.
        name: Unique trophy name.
        unique: If True, a user can only earn this trophy once.
        icon: Path or URL to the trophy icon.
    """

    __tablename__ = "trophy"

    _exclude_fields = []
    _relationship_fields = []

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), nullable=False, unique=True)
    unique = db.Column(db.Boolean, default=False)
    icon = db.Column(db.String(), nullable=True)

    @classmethod
    def from_dict(cls, data):
        return cls(
            id=data.get("id"),
            name=data.get("name"),
            unique=data.get("unique", False),
            icon=data.get("icon"),
        )

    def update_from_dict(self, data):
        super().update_from_dict(data)
        return self

    def __str__(self):
        return self.name

UserTrophy

Bases: Model, SerializableMixin

Association between a User and a Trophy.

Attributes:

Name Type Description
user_id

Foreign key to User.

trophy_id

Foreign key to Trophy.

quantity

Number of times this trophy was awarded.

Source code in website/models/trophy.py
class UserTrophy(db.Model, SerializableMixin):
    """Association between a User and a Trophy.

    Attributes:
        user_id: Foreign key to User.
        trophy_id: Foreign key to Trophy.
        quantity: Number of times this trophy was awarded.
    """

    __tablename__ = "user_trophy"

    _exclude_fields = []
    _relationship_fields = ["user", "trophy"]

    user_id = db.Column(db.String(), db.ForeignKey("user.id"), primary_key=True)
    trophy_id = db.Column(db.Integer, db.ForeignKey("trophy.id"), primary_key=True)
    quantity = db.Column(db.Integer, nullable=False, default=1)

    user = db.relationship("User", back_populates="trophies")
    trophy = db.relationship("Trophy")

    @classmethod
    def from_dict(cls, data):
        return cls(
            user_id=data.get("user_id"),
            trophy_id=data.get("trophy_id"),
            quantity=data.get("quantity", 1),
        )

    def update_from_dict(self, data):
        super().update_from_dict(data)
        return self

User

Bases: Model, SerializableMixin

Discord-authenticated user.

Attributes:

Name Type Description
id

Discord user ID (17-21 digit string).

name

Display name (nick or global_name), refreshed from Discord.

username

Stable Discord username, used for slug generation.

games_gm

Games where this user is the GM.

trophies

User trophy associations.

Source code in website/models/user.py
class User(db.Model, SerializableMixin):
    """Discord-authenticated user.

    Attributes:
        id: Discord user ID (17-21 digit string).
        name: Display name (nick or global_name), refreshed from Discord.
        username: Stable Discord username, used for slug generation.
        games_gm: Games where this user is the GM.
        trophies: User trophy associations.
    """

    __tablename__ = "user"

    _exclude_fields = []
    _relationship_fields = ["games_gm", "trophies"]

    id = db.Column(db.String(), primary_key=True)
    name = db.Column(db.String(), nullable=False, index=True)
    username = db.Column(db.String(), nullable=True)
    not_player_as_of = db.Column(db.DateTime, nullable=True)
    games_gm = db.relationship("Game", back_populates="gm")
    trophies = db.relationship("UserTrophy", back_populates="user", cascade="all, delete-orphan")

    def __init__(self, id, name="Inconnu", username=None):
        if not re.fullmatch(r"\d{17,21}", id):
            raise ValidationError("Invalid Discord UID.", field="id", details={"value": id})
        self.id = id
        self.name = name
        self.username = username

    @property
    def slug_name(self) -> str:
        """Return the stable name for slug generation.

        Uses the Discord username (stable, lowercase) when available,
        falling back to the display name.
        """
        return self.username or self.name

    @property
    def display_name(self):
        """Display-friendly name, fetching from Discord if necessary."""
        if not self.name or self.name == "Inconnu":
            try:
                profile = get_user_profile(self.id)
                self.name = profile["name"]
            except Exception:
                return f"<@{self.id}>"
        return f"{self.name} <@{self.id}>"

    @property
    def trophy_summary(self):
        """Return a list summarizing all trophies of the user."""
        summary = []
        for ut in self.trophies:
            summary.append(
                {
                    "name": ut.trophy.name,
                    "icon": ut.trophy.icon,
                    "quantity": ut.quantity,
                }
            )
        return summary

    @orm.reconstructor
    def init_on_load(self):
        """Initialize user data after loading from the database.

        Skips expensive Discord lookups when in admin context.
        """
        self.avatar = getattr(self, "avatar", DEFAULT_AVATAR)
        self.is_gm = False
        self.is_admin = False
        self.is_player = False

        if not has_request_context():
            profile = get_user_profile(self.id)
            self.name = profile["name"]
            self.avatar = profile["avatar"]
            if profile.get("username") and not self.username:
                self.username = profile["username"]
            return

        if request.path.startswith("/admin"):
            if not getattr(self, "name", None):
                self.name = "Inconnu"
            return

        try:
            profile = get_user_profile(self.id)
            self.name = profile["name"]
            self.avatar = profile["avatar"]
            if profile.get("username") and not self.username:
                self.username = profile["username"]
        except Exception:
            if not getattr(self, "name", None):
                self.name = "Inconnu"
            self.avatar = DEFAULT_AVATAR

    def refresh_roles(self):
        """Refresh role info from Discord (cached for 5 minutes)."""
        try:
            roles = get_user_roles(self.id)
            self.is_gm = current_app.config["DISCORD_GM_ROLE_ID"] in roles
            self.is_admin = current_app.config["DISCORD_ADMIN_ROLE_ID"] in roles
            self.is_player = current_app.config["DISCORD_PLAYER_ROLE_ID"] in roles
        except Exception:
            self.is_gm = False
            self.is_admin = False
            self.is_player = False

    def _serialize_relationship(self, rel_value):
        """Helper to serialize a relationship value (single object or list)."""
        if rel_value is None:
            return None
        if isinstance(rel_value, list):
            return [
                item.to_dict() if hasattr(item, "to_dict") else str(item) for item in rel_value
            ]
        if hasattr(rel_value, "to_dict"):
            return rel_value.to_dict()
        return str(rel_value)

    def _add_relationships_to_dict(self, data):
        """Add relationship data to the dictionary."""
        for rel_name in self._relationship_fields:
            rel_value = getattr(self, rel_name, None)
            data[rel_name] = self._serialize_relationship(rel_value)

    def to_dict(self, include_relationships=False):
        """Serialize the User instance into a Python dict.

        Includes dynamic attributes (avatar, roles) not stored in the database.
        """
        data = {
            "id": self.id,
            "name": self.name,
            "username": self.username,
            "not_player_as_of": (
                self.not_player_as_of.isoformat() if self.not_player_as_of else None
            ),
            "avatar": getattr(self, "avatar", DEFAULT_AVATAR),
            "is_gm": getattr(self, "is_gm", False),
            "is_admin": getattr(self, "is_admin", False),
            "is_player": getattr(self, "is_player", False),
        }

        if include_relationships:
            self._add_relationships_to_dict(data)

        return data

    @classmethod
    def from_dict(cls, data: dict):
        """Create a User object from a dictionary.

        Expected keys: 'id' (required), 'name' (optional).
        Additional keys (avatar/is_gm/is_admin/is_player) will be set as attributes
        on the created instance if present.
        """
        if "id" not in data:
            raise ValidationError("Missing id when creating User from dict.", field="id")
        user = cls(
            id=str(data["id"]),
            name=data.get("name", "Inconnu"),
            username=data.get("username"),
        )

        # Optional attrs that are convenient to set from API payloads
        if "avatar" in data:
            user.avatar = data["avatar"]
        if "is_gm" in data:
            user.is_gm = bool(data["is_gm"])
        if "is_admin" in data:
            user.is_admin = bool(data["is_admin"])
        if "is_player" in data:
            user.is_player = bool(data["is_player"])
        if "not_player_as_of" in data:
            value = data["not_player_as_of"]
            if isinstance(value, str):
                user.not_player_as_of = datetime.fromisoformat(value)
            else:
                user.not_player_as_of = value

        return user

    @classmethod
    def from_json(cls, data):
        """Alias for from_dict() for API compatibility."""
        return cls.from_dict(data)

    def update_from_dict(self, data: dict):
        """Update the user from a dictionary of fields."""
        for field in ["name", "username", "not_player_as_of"]:
            if field in data:
                setattr(self, field, data[field])

    def __repr__(self):
        return f"{self.name} <{self.id}>"

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.id == other.id and self.name == other.name

    def __ne__(self, other):
        return not self.__eq__(other)

slug_name property

Return the stable name for slug generation.

Uses the Discord username (stable, lowercase) when available, falling back to the display name.

display_name property

Display-friendly name, fetching from Discord if necessary.

trophy_summary property

Return a list summarizing all trophies of the user.

init_on_load()

Initialize user data after loading from the database.

Skips expensive Discord lookups when in admin context.

Source code in website/models/user.py
@orm.reconstructor
def init_on_load(self):
    """Initialize user data after loading from the database.

    Skips expensive Discord lookups when in admin context.
    """
    self.avatar = getattr(self, "avatar", DEFAULT_AVATAR)
    self.is_gm = False
    self.is_admin = False
    self.is_player = False

    if not has_request_context():
        profile = get_user_profile(self.id)
        self.name = profile["name"]
        self.avatar = profile["avatar"]
        if profile.get("username") and not self.username:
            self.username = profile["username"]
        return

    if request.path.startswith("/admin"):
        if not getattr(self, "name", None):
            self.name = "Inconnu"
        return

    try:
        profile = get_user_profile(self.id)
        self.name = profile["name"]
        self.avatar = profile["avatar"]
        if profile.get("username") and not self.username:
            self.username = profile["username"]
    except Exception:
        if not getattr(self, "name", None):
            self.name = "Inconnu"
        self.avatar = DEFAULT_AVATAR

refresh_roles()

Refresh role info from Discord (cached for 5 minutes).

Source code in website/models/user.py
def refresh_roles(self):
    """Refresh role info from Discord (cached for 5 minutes)."""
    try:
        roles = get_user_roles(self.id)
        self.is_gm = current_app.config["DISCORD_GM_ROLE_ID"] in roles
        self.is_admin = current_app.config["DISCORD_ADMIN_ROLE_ID"] in roles
        self.is_player = current_app.config["DISCORD_PLAYER_ROLE_ID"] in roles
    except Exception:
        self.is_gm = False
        self.is_admin = False
        self.is_player = False

to_dict(include_relationships=False)

Serialize the User instance into a Python dict.

Includes dynamic attributes (avatar, roles) not stored in the database.

Source code in website/models/user.py
def to_dict(self, include_relationships=False):
    """Serialize the User instance into a Python dict.

    Includes dynamic attributes (avatar, roles) not stored in the database.
    """
    data = {
        "id": self.id,
        "name": self.name,
        "username": self.username,
        "not_player_as_of": (
            self.not_player_as_of.isoformat() if self.not_player_as_of else None
        ),
        "avatar": getattr(self, "avatar", DEFAULT_AVATAR),
        "is_gm": getattr(self, "is_gm", False),
        "is_admin": getattr(self, "is_admin", False),
        "is_player": getattr(self, "is_player", False),
    }

    if include_relationships:
        self._add_relationships_to_dict(data)

    return data

from_dict(data) classmethod

Create a User object from a dictionary.

Expected keys: 'id' (required), 'name' (optional). Additional keys (avatar/is_gm/is_admin/is_player) will be set as attributes on the created instance if present.

Source code in website/models/user.py
@classmethod
def from_dict(cls, data: dict):
    """Create a User object from a dictionary.

    Expected keys: 'id' (required), 'name' (optional).
    Additional keys (avatar/is_gm/is_admin/is_player) will be set as attributes
    on the created instance if present.
    """
    if "id" not in data:
        raise ValidationError("Missing id when creating User from dict.", field="id")
    user = cls(
        id=str(data["id"]),
        name=data.get("name", "Inconnu"),
        username=data.get("username"),
    )

    # Optional attrs that are convenient to set from API payloads
    if "avatar" in data:
        user.avatar = data["avatar"]
    if "is_gm" in data:
        user.is_gm = bool(data["is_gm"])
    if "is_admin" in data:
        user.is_admin = bool(data["is_admin"])
    if "is_player" in data:
        user.is_player = bool(data["is_player"])
    if "not_player_as_of" in data:
        value = data["not_player_as_of"]
        if isinstance(value, str):
            user.not_player_as_of = datetime.fromisoformat(value)
        else:
            user.not_player_as_of = value

    return user

from_json(data) classmethod

Alias for from_dict() for API compatibility.

Source code in website/models/user.py
@classmethod
def from_json(cls, data):
    """Alias for from_dict() for API compatibility."""
    return cls.from_dict(data)

update_from_dict(data)

Update the user from a dictionary of fields.

Source code in website/models/user.py
def update_from_dict(self, data: dict):
    """Update the user from a dictionary of fields."""
    for field in ["name", "username", "not_player_as_of"]:
        if field in data:
            setattr(self, field, data[field])

Vtt

Bases: Model, SerializableMixin

A virtual tabletop platform (e.g. Foundry VTT, Roll20).

Attributes:

Name Type Description
id

Primary key.

name

Unique VTT name.

icon

Path or URL to the VTT icon.

Source code in website/models/vtt.py
class Vtt(db.Model, SerializableMixin):
    """A virtual tabletop platform (e.g. Foundry VTT, Roll20).

    Attributes:
        id: Primary key.
        name: Unique VTT name.
        icon: Path or URL to the VTT icon.
    """

    __tablename__ = "vtt"

    _exclude_fields = []
    _relationship_fields = ["games_vtt"]

    id = db.Column(db.BigInteger, primary_key=True)
    name = db.Column(db.String(), nullable=False, unique=True)
    icon = db.Column(db.String(), nullable=True)

    games_vtt = db.relationship("Game", backref="vtt")

    @staticmethod
    def get_vtts():
        """Return a list of all Vtts, ordered by name."""
        return Vtt.query.order_by("name").all()

    @classmethod
    def from_dict(cls, data):
        """Create a Vtt instance from a Python dict."""
        return cls(
            id=data.get("id"),
            name=data.get("name"),
            icon=data.get("icon"),
        )

    def update_from_dict(self, data):
        """Update the Vtt instance from a dict (in place)."""
        super().update_from_dict(data)
        return self

    def __repr__(self):
        return f"<Vtt id={self.id} name='{self.name}' icon='{self.icon}'>"

    def __eq__(self, other):
        if not isinstance(other, Vtt):
            return NotImplemented
        return (self.id, self.name, self.icon) == (other.id, other.name, other.icon)

get_vtts() staticmethod

Return a list of all Vtts, ordered by name.

Source code in website/models/vtt.py
@staticmethod
def get_vtts():
    """Return a list of all Vtts, ordered by name."""
    return Vtt.query.order_by("name").all()

from_dict(data) classmethod

Create a Vtt instance from a Python dict.

Source code in website/models/vtt.py
@classmethod
def from_dict(cls, data):
    """Create a Vtt instance from a Python dict."""
    return cls(
        id=data.get("id"),
        name=data.get("name"),
        icon=data.get("icon"),
    )

update_from_dict(data)

Update the Vtt instance from a dict (in place).

Source code in website/models/vtt.py
def update_from_dict(self, data):
    """Update the Vtt instance from a dict (in place)."""
    super().update_from_dict(data)
    return self