"""lg-rez / blocs / Gestion des données
Déclaration de toutes les tables et leurs colonnes, et connection à la BDD
"""
import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import psycopg2
from lgrez.blocs import env
#: Alias de :exc:`sqlalchemy.exc.SQLAlchemyError` : exception de BDD générale
SQLAlchemyError = sqlalchemy.exc.SQLAlchemyError
#: Alias de :exc:`psycopg2.SQLAlchemyError` : erreur levée en cas de perte de connection avec la BDD
#: Seul PostreSQL géré nativement, override DriverOperationalError avec l'équivalent pour un autre driver
DriverOperationalError = psycopg2.OperationalError
Base = declarative_base()
Base.__doc__ = "Classe de base des tables de données, renvoyée par :func:`sqlalchemy.ext.declarative.declarative_base`"
#: :class:`dict`\[:class:`str`, :class:`.Base`\]: Dictionnaire {nom de la base -> table}
Tables = {}
# Objets de connection (créés dans connect)
#: :class:`sqlalchemy.engine.Engine`: Moteur de connection à la BDD
#: Vaut ``None`` avant l'appel à :func:`connect`.
engine = None
#: :class:`sqlalchemy.orm.session.Session`: Session de transaction avec la BDD.
#: Vaut ``None`` avant l'appel à :func:`connect`.
session = None
class _ClassProperty(property): # https://stackoverflow.com/a/1383402
"""Permet d'utiliser des propriés comme des classmethods (reçoivent la classe comme seul argument)"""
def __get__(self, cls, owner):
return self.fget.__get__(None, owner)()
class _MyTable(object):
"""Classe permettant d'enregister les tables au fur et à mesure de leur déclaration et d'utiliser Table.query au lieu de session.query(Table)"""
def __init_subclass__(cls, **kwargs):
"""Définit __tablename__ et enregistre la table dans Tables"""
super().__init_subclass__(**kwargs)
cls.__tablename__ = cls.__name__.lower()
Tables[cls.__name__] = cls
cls.__doc__ += f"\n\nClasse de données (sous-classe de :class:`.Base`) représentant la table ``{cls.__tablename__}``.\n\nTous les attributs ci-dessous sont du type indiqué pour les instances (entrées de BDD), mais de type :class:`sqlalchemy.orm.attributes.InstrumentedAttribute` pour la classe elle-même." # On adore la doc vraiment
@classmethod
def get_query(cls):
return session.query(cls)
query = _ClassProperty(get_query) # Permet d'utiliser Table.query.<...> au lieu de bdd.session.query(Table).<...>
# Définition des tables
[docs]class Joueurs(_MyTable, Base): #, tablename="joueurs"
"""Table de données des joueurs inscrits
Les instances de cette classe correspondent aux lignes du Tableau de bord ; elles sont crées par l'inscription (:func:`.inscription.main`) et synchronisées par :meth:`\!sync <.sync.Sync.Sync.sync.callback>`.
"""
#: :class:`int` : ID Discord du joueur (clé primaire)
discord_id = sqlalchemy.Column(sqlalchemy.BigInteger(), primary_key=True)
#: :class:`int`: ID du chan privé Discord du joueur (NOT NULL)
#: Le ``_`` final indique que ce champ n'est pas synchnisé avec le Tableau de bord.
chan_id_ = sqlalchemy.Column(sqlalchemy.BigInteger(), nullable=False)
#: :class:`str`: nom du joueur (demandé à l'inscription) (NOT NULL, ``len <= 32``)
nom = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`str`: emplacement du joueur (demandé à l'inscription) (``len <= 200``)
chambre = sqlalchemy.Column(sqlalchemy.String(200), nullable=True)
#: :class:`str`: statut RP (généralement "vivant", "mort" ou "MV") (NOT NULL, ``len <= 32``)
statut = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`str`: rôle du joueur : doit correspondre à une valeur de :attr:`Roles.slug` (NOT NULL, ``len <= 32``)
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`str`: camp du joueur (généralement "village", "loups", "nécro") (NOT NULL, ``len <= 32``)
camp = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`bool`: si le joueur participe aux votes du village ou non
votant_village = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=False)
#: :class:`bool`: si le joueur participe au vote des loups ou non
votant_loups = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=False)
#: :class:`bool`: si le peut agir ou non (chatgarouté...) (NOT NULL)
role_actif = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=True)
#: :class:`str`: vote actuel au vote condamné (None si pas de vote en cours) (``len <= 200``)
#: Le ``_`` final indique que ce champ n'est pas synchnisé avec le Tableau de bord.
vote_condamne_ = sqlalchemy.Column(sqlalchemy.String(200), nullable=True)
#: :class:`str`: vote actuel au vote maire (None si pas de vote en cours) (``len <= 200``)
#: Le ``_`` final indique que ce champ n'est pas synchnisé avec le Tableau de bord.
vote_maire_ = sqlalchemy.Column(sqlalchemy.String(200), nullable=True)
#: :class:`str`: vote actuel au vote loups (None si pas de vote en cours) (``len <= 200``)
#: Le ``_`` final indique que ce champ n'est pas synchnisé avec le Tableau de bord.
vote_loups_ = sqlalchemy.Column(sqlalchemy.String(200), nullable=True)
def __repr__(self):
"""Return repr(self)."""
return f"<Joueurs #{self.discord_id} ({self.nom})>"
[docs]class Roles(_MyTable, Base): #, tablename="roles"
"""Table de données des rôles
Cette table est remplie automatiquement à partir du Google Sheet "Rôles et actions" par la commande :meth:`\!fillroles <.sync.Sync.Sync.fillroles.callback>`.
"""
#: :class:`str`: identifiant unique du rôle (clé primaire, ``len <= 32``)
slug = sqlalchemy.Column(sqlalchemy.String(32), primary_key=True)
#: :class:`str`: article du nom du rôle (``"Le"``, ``"La"``, ``"L'"``...) (NOT NULL, ``len <= 8``)
prefixe = sqlalchemy.Column(sqlalchemy.String(8), nullable=False)
#: :class:`str`: nom du rôle (NOT NULL, ``len <= 32``)
nom = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`str`: camp de base du rôle (NOT NULL, ``len <= 32``)
camp = sqlalchemy.Column(sqlalchemy.String(32), nullable=False) # loups, solitaire, nécro, village...
#: :class:`str`: description en une ligne (NOT NULL, ``len <= 140``)
description_courte = sqlalchemy.Column(sqlalchemy.String(140), nullable=False)
#: :class:`str`: règles et background complets (NOT NULL, ``len <= 2000``)
description_longue = sqlalchemy.Column(sqlalchemy.String(2000), nullable=False)
def __repr__(self):
"""Return repr(self)."""
return f"<Roles '{self.slug}' ({self.prefixe}{self.nom})>"
[docs]class BaseActions(_MyTable, Base): #, tablename="base_actions"
"""Table de données des actions définies de bases (non liées à un joueur)
Cette table est remplie automatiquement à partir du Google Sheet "Rôles et actions" par la commande :meth:`\!fillroles <.remplissage_bdd.RemplissageBDD.RemplissageBDD.fillroles.callback>`.
"""
#: :class:`str`: slug identifiant uniquement l'action (primary key, ``len <= 2000``)
action = sqlalchemy.Column(sqlalchemy.String(32), primary_key=True)
#: :class:`str`: type de déclencheur du début de l'action (``"temporel"``, ``"mort"``, ...) (NOT NULL, ``len <= 2000``)
trigger_debut = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`str`: type de déclencheur de la fin (NOT NULL, ``len <= 2000``)
trigger_fin = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`bool`: si l'action est instantannée (conséquences dès la prise de décision) ou non (conséquence à la fin du créneau d'action)
instant = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=True)
#: :class:`datetime.time`: si :attr:`.trigger_debut` vaut ``"temporel"`` ou ``"delta"``, l'horaire / la durée associée
heure_debut = sqlalchemy.Column(sqlalchemy.Time(), nullable=True)
#: :class:`datetime.time`: si :attr:`.trigger_fin` vaut ``"temporel"`` ou ``"delta"``, l'horaire / la durée associée
heure_fin = sqlalchemy.Column(sqlalchemy.Time(), nullable=True)
#: :class:`int`: temps de rechargement entre deux utilisations du pouvoir (``0`` si pas de cooldown) (NOT NULL)
base_cooldown = sqlalchemy.Column(sqlalchemy.Integer(), nullable=False)
#: :class:`int`: nombre de charges initiales du pouvoir (``None`` si illimitée)
base_charges = sqlalchemy.Column(sqlalchemy.Integer(), nullable=True)
#: :class:`str`: évènements pouvant recharger le pouvoir, séparés par des virgules (parmi ``"weekends"``, ``"forgeron"``, ...) (NOT NULL, ``len <= 32``)
refill = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`str`: attribut informatif (Distance/Physique/Lieu/Contact/Conditionnel/None/Public) (``len <= 32``)
lieu = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`str`: attribut informatif (Oui, Non, Conditionnel, Potion, Rapport; None si récursif) (``len <= 32``)
interaction_notaire = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`str`: attribut informatif (Oui, Non, Conditionnel, Taverne, Feu, MaisonClose, Précis, Cimetière, Loups, None si recursif) (``len <= 32``)
interaction_gardien = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
#: :class:`str`: attribut informatif (Oui, Non, changement de cible, etc) (``len <= 100`` 32)
mage = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
#: :class:`bool`: si la cible doit changer entre deux utilisations consécutives (informatif uniquement pour l'instant)
changement_cible = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=True)
def __repr__(self):
"""Return repr(self)."""
return f"<BaseActions '{self.action}'>"
[docs]class Actions(_MyTable, Base): #, tablename="actions"
"""Table de données des actions attribuées (liées à un joueur et actives)
Les instances doivent être enregistrées via :func:`.gestion_actions.open_action` et supprimées via :func:`.gestion_actions.close_action`.
"""
#: :class:`int`: identifiant unique de l'action, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key=True)
#: :class:`int`: clé étrangère liée à :attr:`.Joueurs.id` (NOT NULL)
player_id = sqlalchemy.Column(sqlalchemy.BigInteger(), nullable=False)
#: :class:`str`: clé étrangère liée à :attr:`.BaseActions.slug` (NOT NULL, ``len <= 32``)
action = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: et ``trigger_fin``, ``instant``, ``heure_debut``, ``heure_fin``, ``instant``, ``heure_debut``, ``heure_fin``, ``cooldown``, ``charges``, ``refill``, ``lieu``, ``interaction_notaire``, ``interaction_gardien``, ``mage``, ``changement_cible`` sont les mêmes que pour :class:`.Actions`.
trigger_debut = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
trigger_fin = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
instant = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=True)
heure_debut = sqlalchemy.Column(sqlalchemy.Time(), nullable=True)
heure_fin = sqlalchemy.Column(sqlalchemy.Time(), nullable=True)
cooldown = sqlalchemy.Column(sqlalchemy.Integer(), nullable=False)
charges = sqlalchemy.Column(sqlalchemy.Integer(), nullable=True)
refill = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
lieu = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
interaction_notaire = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
interaction_gardien = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
mage = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
changement_cible = sqlalchemy.Column(sqlalchemy.Boolean(), nullable=True)
#: Décision prise par le joueur pour l'action actuelle (``None`` si action non en cours). Le ``_`` final indique que ce champ n'est pas synchnisé avec le Tableau de bord. (``len <= 200``)
decision_ = sqlalchemy.Column(sqlalchemy.String(200), nullable=True)
def __repr__(self):
"""Return repr(self)."""
return f"<Actions #{self.id} ({self.action}/{self.player_id})>"
[docs]class BaseActionsRoles(_MyTable, Base): #, tablename="base_action_roles"
"""Table de données mettant en relation les rôles et les actions de base
Cette table est remplie automatiquement à partir du Google Sheet "Rôles et actions" par la commande :meth:`\!fillroles <.remplissage_bdd.RemplissageBDD.RemplissageBDD.fillroles.callback>`.
"""
#: :class:`int`: identifiant unique de l'action, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key=True)
#: :class:`str`: clé étrangère liée à :attr:`.Roles.slug` (NOT NULL, ``len <= 32``)
role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
#: :class:`str`: clé étrangère liée à :attr:`.BaseActions.slug` (NOT NULL, ``len <= 32``)
action = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
def __repr__(self):
"""Return repr(self)."""
return f"<BaseActionsRoles #{self.id} ({self.role}/{self.action})>"
[docs]class Taches(_MyTable, Base): #, tablename="taches"
"""Table de données des tâches planifiées du bot
Les instances doivent être enregistrées via :func:`.taches.add_task` et supprimées via :func:`.taches.cancel_task`.
"""
#: :class:`int`: identifiant unique de la tâche, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key=True)
#: :class:`datetime.datetime`: moment où sera exécutée la tâche (NOT NULL)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime(), nullable=False)
#: :class:`str`: texte à envoyer via le webhook (généralement une commande) (NOT NULL, ``len <= 200``)
commande = sqlalchemy.Column(sqlalchemy.String(200), nullable=False)
#: :class:`int`: si la tâche est liée à une action, clé étrangère liée à :attr:`.Actions.id`
action = sqlalchemy.Column(sqlalchemy.Integer(), nullable=True)
def __repr__(self):
"""Return repr(self)."""
return f"<Taches #{self.id} ({self.commande})>"
[docs]class Reactions(_MyTable, Base): #, tablename="reactions"
"""Table de données des réactions d'IA connues du bot
Les instances doivent être enregistrées via :meth:`\!addIA <.IA.GestionIA.GestionIA.addIA.callback>` et supprimées via :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`.
"""
#: :class:`int`: identifiant unique de la réaction, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key=True)
#: :class:`str`: réponse, suivant le format (mini-langage) personnalisé (``"txt <||> txt <&&> <##>react"``) (NOT NULL, <2000)
reponse = sqlalchemy.Column(sqlalchemy.String(2000), nullable=False)
def __repr__(self):
"""Return repr(self)."""
extract = self.reponse.replace('\n', ' ')[:15] + "..."
return f"<Reactions #{self.id} ({extract})>"
[docs]class Triggers(_MyTable, Base): #, tablename="triggers"
"""Table de données des mots et expressions déclenchant l'IA du bot
Les instances doivent être enregistrées via :meth:`\!addIA <.IA.GestionIA.GestionIA.addIA.callback>` et supprimées via :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`.
"""
#: :class:`int`: identifiant unique du trigger, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key=True)
#: :class:`str`: mots-clés / expressions, séparés par des ``;`` éventuellement (NOT NULL, ``len <= 500``)
trigger = sqlalchemy.Column(sqlalchemy.String(500), nullable=False)
#: :class:`int`: réaction associée, clé étrangère de :attr:`.Reactions.id` (NOT NULL)
reac_id = sqlalchemy.Column(sqlalchemy.Integer(), nullable=False)
def __repr__(self):
"""Return repr(self)."""
extract = self.trigger.replace('\n', ' ')[:15] + "..."
return f"<Triggers #{self.id} ({extract})>"
[docs]class CandidHaro(_MyTable, Base): #, tablename="candid_haro"
"""Table de données des candidatures et haros en cours #PhilippeCandidHaro
Les instances doivent être enregistrées via :meth:`\!haro <.actions_publiques.ActionsPubliques.ActionsPubliques.haro.callback>` / :meth:`\!candid <.actions_publiques.ActionsPubliques.ActionsPubliques.candid.callback>` et supprimées via :meth:`\!wipe <.actions_publiques.ActionsPubliques.ActionsPubliques.wipe.callback>`.
"""
#: :class:`int`: identifiant unique de la réaction, sans signification (auto-incrémental) (primary key)
id = sqlalchemy.Column(sqlalchemy.Integer(), primary_key = True)
#: :class:`int`: joueur associé, clé étrangère de :attr:`.Joueurs.discord_id` (NOT NULL)
player_id = sqlalchemy.Column(sqlalchemy.BigInteger(), nullable=False)
#: :class:`str`: ``"haro"`` ou ``"candidature"`` (flemme de ``enum`` mais faudrait, vraiment) (NOT NULL, ``len <= 11``)
type = sqlalchemy.Column(sqlalchemy.String(11), nullable=False)
def __repr__(self):
"""Return repr(self)."""
return f"<CandidHaro #{self.id} ({self.player_id/self.type})>"
[docs]def connect():
"""Se connecte à la base de données (variable d'environment ``LGREZ_DATABASE_URI``), crée les tables si nécessaire, construit :attr:`.bdd.engine` et ouvre :attr:`.bdd.session`"""
global engine, session
LGREZ_DATABASE_URI = env.load("LGREZ_DATABASE_URI")
engine = sqlalchemy.create_engine(LGREZ_DATABASE_URI, pool_pre_ping=True) # Moteur SQL : connexion avec le serveur
# Création des tables si elles n'existent pas déjà
Base.metadata.create_all(engine)
# Ouverture de la session
Session = sessionmaker(bind=engine)
session = Session()