class Bouderie(base.TableBase): """Table de données des appartenances aux boudoirs du serveur. Table d'association entre :class:`.Joueur` et :class:`.Boudoir`. Les instances de cette classe sont crées, modifiées et supprimées par :meth:`\!boudoir <.chans.GestionChans.GestionChans.boudoir.callback>`. """ id = autodoc_Column(sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique de la bouderie, sans signification") _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"), nullable=False) joueur = autodoc_ManyToOne("Joueur", back_populates="bouderies", doc="Joueur concerné") _boudoir_id = sqlalchemy.Column(sqlalchemy.ForeignKey("boudoirs.chan_id"), nullable=False) boudoir = autodoc_ManyToOne("Boudoir", back_populates="bouderies", doc="Boudoir concerné") gerant = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=False, doc="Si le joueur a les droits de gestion du boudoir") ts_added = autodoc_Column(sqlalchemy.DateTime(), nullable=False, doc="Timestamp de l'ajout du joueur au boudoir") ts_promu = autodoc_Column(sqlalchemy.DateTime(), doc="Timestamp de la promotion en gérant, le cas échéant") def __repr__(self): """Return repr(self).""" return f"<Bouderie #{self.id} ({self.joueur}/{self.boudoir})>"
class Reaction(base.TableBase): """Table de données des réactions d'IA connues du bot. Les instances sont enregistrées via :meth:`\!addIA <.IA.GestionIA.GestionIA.addIA.callback>` et supprimées via :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`. """ id = autodoc_Column(sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique de la réaction, sans signification") reponse = autodoc_Column(sqlalchemy.String(2000), nullable=False, doc="Réponse, suivant le format (mini-langage) personnalisé " "(``\"txt <||> txt <&&> <##>react\"``)") # One-to-manys triggers = autodoc_OneToMany("Trigger", back_populates="reaction", doc="Déclencheurs associés") def __repr__(self): """Return repr(self).""" extract = self.reponse.replace('\n', ' ')[:15] + "..." return f"<Reaction #{self.id} ({extract})>"
class Trigger(base.TableBase): """Table de données des mots et expressions déclenchant l'IA du bot. Les instances sont enregistrées via :meth:`\!addIA <.IA.GestionIA.GestionIA.addIA.callback>` ou :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>` et supprimées via :meth:`\!modifIA <.IA.GestionIA.GestionIA.modifIA.callback>`. """ id = autodoc_Column(sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique du déclencheur, sans signification") trigger = autodoc_Column(sqlalchemy.String(500), nullable=False, doc="Mots-clés / expressions") _reac_id = sqlalchemy.Column(sqlalchemy.ForeignKey("reactions.id"), nullable=False) reaction = autodoc_ManyToOne("Reaction", back_populates="triggers", doc="Réaction associée") def __repr__(self): """Return repr(self).""" extract = self.trigger.replace('\n', ' ')[:15] + "..." return f"<Trigger #{self.id} ({extract})>"
class BaseCiblage(base.TableBase): """Table de données des modèles de ciblages des actions de base. [TODO] Cette table est remplie automatiquement à partir du Google Sheet "Rôles et actions" par la commande :meth:`\!fillroles <.remplissage_bdd.RemplissageBDD.RemplissageBDD.fillroles.callback>`. """ _id = autodoc_Column(sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique du modèle de ciblage, sans signification") _baseaction_slug = sqlalchemy.Column(sqlalchemy.ForeignKey( "baseactions.slug"), nullable=False) base_action = autodoc_ManyToOne("BaseAction", back_populates="base_ciblages", doc="Modèle d'action définissant ce ciblage") slug = autodoc_Column(sqlalchemy.String(32), nullable=False, default="unique", doc="Identifiant de la cible dans le modèle d'action") type = autodoc_Column(sqlalchemy.Enum(CibleType), nullable=False, default=CibleType.texte, doc="Message d'interaction au joueur au moment de choisir la cible") prio = autodoc_Column(sqlalchemy.Integer(), nullable=False, default=1, doc="Ordre (relatif) d'apparition du ciblage lors du ``!action`` " "\n\nSi deux ciblages ont la même priorité, ils seront considérés " "comme ayant une signification symmétrique (notamment, si " ":attr:`doit_changer` vaut ``True``, tous les membres du groupe " "devront changer) ; l'ordre d'apparition dépend alors de leur " ":attr:`slug`, par ordre alphabétique (``cible1`` < ``cible2``).") phrase = autodoc_Column(sqlalchemy.String(1000), nullable=False, default="Cible ?", doc="Message d'interaction au joueur au moment de choisir la cible") obligatoire = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True, doc="Si le ciblage doit obligatoirement être renseigné") doit_changer = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=False, doc="Si la cible doit changer d'une utilisation à l'autre.\n\n" "Si la dernière utilisation est ignorée ou contrée, il n'y a " "pas de contrainte.") # one-to-manys ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="base", doc="Ciblages déroulant de cette base") def __repr__(self): """Return repr(self).""" return f"<BaseCiblage #{self._id} ({self.base_action}/{self.slug})>"
class CandidHaro(base.TableBase): """Table de données des candidatures et haros en cours #PhilippeCandidHaro. Les instances sont 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>`. """ id = autodoc_Column(sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique du candidharo, sans signification") _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"), nullable=False) joueur = autodoc_ManyToOne("Joueur", back_populates="candidharos", doc="Joueur concerné (candidat ou haroté)") type = autodoc_Column(sqlalchemy.Enum(CandidHaroType), nullable=False, doc="Haro ou candidature ?") def __repr__(self): """Return repr(self).""" return f"<CandidHaro #{self.id} ({self.joueur}/{self.type})>"
class Boudoir(base.TableBase): """Table de données des boudoirs sur le serveur. Les instances de cette classe sont crées, modifiées et supprimées par :meth:`\!boudoir <.chans.GestionChans.GestionChans.boudoir.callback>`. """ chan_id = autodoc_Column(sqlalchemy.BigInteger(), primary_key=True, autoincrement=False, doc="ID Discord du salon") nom = autodoc_Column(sqlalchemy.String(32), nullable=False, doc="Nom du boudoir (demandé à la création)") ts_created = autodoc_Column(sqlalchemy.DateTime(), nullable=False, doc="Timestamp de la création") # One-to-manys bouderies = autodoc_OneToMany("Bouderie", back_populates="boudoir", doc="Appartenances à ce boudoir") def __repr__(self): """Return repr(self).""" return f"<Boudoir #{self.chan_id} ({self.nom})>" @property def chan(self): """discord.TextChannel: Salon Discord correspondant à ce boudoir. Raises: ValueError: pas de membre correspondant ~ready_check.NotReadyError: bot non connecté (:obj:`.config.guild` vaut ``None``) """ result = config.guild.get_channel(self.chan_id) if not result: raise ValueError(f"Boudoir.chan : pas de chan pour {self} !") return result @property def joueurs(self): """Sequence[.Joueur]: Joueurs présents dans ce boudoir (read-only)""" return [boud.joueur for boud in self.bouderies] @property def gerant(self): """.bdd.Joueur: Membre du boudoir ayant les droits de gestion. Raises: ValueError: pas de membre avec les droits de gestion """ try: return next(boud.joueur for boud in self.bouderies if boud.gerant) except StopIteration: raise ValueError(f"Pas de membre gérant le boudoir *{self.name}*") async def add_joueur(self, joueur, gerant=False): """Ajoute un joueur sur le boudoir. Crée la :class:`.Bouderie` correspondante et modifie les permissions du salon. Args: joueur (.Joueur): Le joueur à ajouter. gerant (bool): Si le joueur doit être ajouté avec les permissions de gérant. """ now = datetime.datetime.now() Bouderie(boudoir=self, joueur=joueur, gerant=gerant, ts_added=now, ts_promu=now if gerant else None).add() await self.chan.set_permissions(joueur.member, read_messages=True) async def remove_joueur(self, joueur): """Retire un joueur du boudoir. Supprime la :class:`.Bouderie` correspondante et modifie les permissions du salon. Args: joueur (.Joueur): Le joueur à ajouter. """ Bouderie.query.filter_by(boudoir=self, joueur=joueur).one().delete() await self.chan.set_permissions(joueur.member, overwrite=None) @classmethod def from_channel(cls, channel): """Récupère le Boudoir (instance de BDD) lié à un salon Discord. Args: channel (discord.TextChannel): le salon concerné Returns: .Boudoir: Le salon correspondant. Raises: ValueError: boudoir introuvable en base ~ready_check.NotReadyError: session non initialisée (:obj:`.config.session` vaut ``None``) """ boudoir = cls.query.get(channel.id) if not boudoir: raise ValueError("Boudoir.from_channel : " f"pas de boudoir en base pour `{channel.mention}` !") return boudoir
class Joueur(base.TableBase): """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>`. """ discord_id = autodoc_Column(sqlalchemy.BigInteger(), primary_key=True, autoincrement=False, doc="ID Discord du joueur") chan_id_ = autodoc_Column(sqlalchemy.BigInteger(), nullable=False, doc="ID du chan privé Discord du joueur. *(le ``_`` final indique " "que ce champ n'est pas synchnisé avec le Tableau de bord)*") nom = autodoc_Column(sqlalchemy.String(32), nullable=False, doc="Nom du joueur (demandé à l'inscription)") chambre = autodoc_Column(sqlalchemy.String(200), doc="Emplacement du joueur (demandé à l'inscription)") statut = autodoc_Column(sqlalchemy.Enum(Statut), nullable=False, default=Statut.vivant, doc="Statut RP") _role_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("roles.slug"), nullable=False, default=lambda: config.default_role_slug) role = autodoc_ManyToOne("Role", back_populates="joueurs", doc="Rôle du joueur (défaut :meth:`.bdd.Role.default`)") _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"), nullable=False, default=lambda: config.default_camp_slug) camp = autodoc_ManyToOne("Camp", back_populates="joueurs", doc="Camp du joueur (défaut :meth:`.bdd.Camp.default`)") votant_village = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True, doc="Le joueur participe aux votes du village ?") votant_loups = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=False, doc="Le joueur participe au vote des loups ?") role_actif = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True, doc="Le joueur peut agir ? (pas chatgarouté...)") # One-to-manys actions = autodoc_OneToMany("Action", back_populates="joueur", doc="Actions pour ce joueur") candidharos = autodoc_OneToMany("CandidHaro", back_populates="joueur", doc="Candidatures et haros de/contre ce joueur") bouderies = autodoc_OneToMany("Bouderie", back_populates="joueur", doc="Appartenances aux boudoirs de ce joueur") ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="joueur", doc="Ciblages prenant ce joueur pour cible") def __repr__(self): """Return repr(self).""" return f"<Joueur #{self.discord_id} ({self.nom})>" def __str__(self): """Return str(self).""" return str(self.nom) @property def member(self): """discord.Member: Membre Discord correspondant à ce Joueur. Raises: ValueError: pas de membre correspondant ~ready_check.NotReadyError: bot non connecté (:obj:`.config.guild` vaut ``None``) """ result = config.guild.get_member(self.discord_id) if not result: raise ValueError(f"Joueur.member : pas de membre pour `{self}` !") return result @property def private_chan(self): """discord.TextChannel: Channel privé (conversation bot) du joueur. Raises: ValueError: pas de channel correspondant ~ready_check.NotReadyError: bot non connecté (:obj:`.config.guild` vaut ``None``) """ result = config.guild.get_channel(self.chan_id_) if not result: raise ValueError("Joueur.private_chan : " f"pas de chan pour `{self}` !") return result @property def actions_actives(self): """Sequence[.bdd.Action]: Sous-ensemble de :attr:`actions` restreint aux actions actives. Élimine aussi les actions de vote (sans base). """ return [ac for ac in self.actions if ac.active and not ac.vote] @property def boudoirs(self): """Sequence[.Boudoir]: Boudoirs où est ce jour (read-only)""" return [boud.boudoir for boud in self.bouderies] @hybrid_property def est_vivant(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): Le joueur est en vie ou MVtisé ? Raccourci pour ``joueur.statut in {Statut.vivant, Statut.MV}`` Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos) """ return (self.statut in {Statut.vivant, Statut.MV}) @est_vivant.expression def est_vivant(cls): return cls.statut.in_({Statut.vivant, Statut.MV}) @hybrid_property def est_mort(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): Le joueur est mort ? Raccourci pour ``joueur.statut == Statut.mort`` Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos) """ return (self.statut == Statut.mort) @classmethod def from_member(cls, member): """Récupère le Joueur (instance de BDD) lié à un membre Discord. Args: member (discord.Member): le membre concerné Returns: Joueur: Le joueur correspondant. Raises: ValueError: membre introuvable en base ~ready_check.NotReadyError: session non initialisée (:obj:`.config.session` vaut ``None``) """ joueur = cls.query.get(member.id) if not joueur: raise ValueError("Joueur.from_member : " f"pas de joueur en base pour `{member}` !") return joueur def action_vote(self, vote): """Retourne l'"action de vote" voulue pour ce joueur. Args: vote (.bdd.Vote): vote pour lequel récupérer l'action Returns: :class:`~bdd.Action` Raises: RuntimeError: action non existante """ if not isinstance(vote, Vote): vote = Vote[vote] try: return next(act for act in self.actions if act.vote == vote) except StopIteration: raise RuntimeError(f"{self} : pas d'action de vote {vote.name} ! " "!cparti a bien été appelé ?") from None
class Tache(base.TableBase): """Table de données des tâches planifiées du bot. Les instances doivent être enregistrées via :meth:`.add` et supprimées via :func:`.delete`. """ id = autodoc_Column( sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique de la tâche, sans signification") timestamp = autodoc_Column(sqlalchemy.DateTime(), nullable=False, doc="Moment où exécuter la tâche") commande = autodoc_Column( sqlalchemy.String(2000), nullable=False, doc="Texte à envoyer via le webhook (généralement une commande)") _action_id = sqlalchemy.Column(sqlalchemy.ForeignKey("actions.id"), nullable=True) action = autodoc_ManyToOne( "Action", back_populates="taches", nullable=True, doc="Si la tâche est liée à une action, action concernée") def __repr__(self): """Return repr(self).""" return f"<Tache #{self.id} ({self.commande})>" @property def handler(self): """asyncio.TimerHandle: Représentation dans le bot de la tâche. Proxy pour :attr:`config.bot.tasks[self.id] <.LGBot.tasks>`, en lecture, écriture et suppression (``del``). Raises: RuntimeError: tâche non enregistrée dans le bot. """ try: return config.bot.tasks[self.id] except KeyError: raise RuntimeError(f"Tâche {self} non enregistrée dans le bot !") @handler.setter def handler(self, value): if self.id is None: raise RuntimeError("Tache.handler: Tache.id non défini (commit ?)") config.bot.tasks[self.id] = value @handler.deleter def handler(self): try: del config.bot.tasks[self.id] except KeyError: pass async def send_webhook(self, tries=0): """Exécute la tâche (coroutine programmée par :meth:`execute`). Envoie un webhook (:obj:`.config.webhook`) de contenu :attr:`commande`. Si une exception quelconque est levée par l'envoi du webhook, re-programme l'exécution de la tâche (:meth:`execute`) 2 secondes après ; ce jusqu'à 5 fois, après quoi un message d'alerte est envoyé dans :attr:`.config.Channel.logs`. Si aucune exception n'est levée (succès), supprime la tâche. Args: tries (int): Numéro de l'essai d'envoi actuellement en cours """ try: await config.webhook.send(self.commande) except Exception as exc: if tries < 5: # On réessaie config.loop.call_later(2, self.execute, tries + 1) else: await config.Channel.logs.send( f"{config.Role.mj.mention} ALERT: impossible " f"d'envoyer un webhook (5 essais, erreur : " f"```{type(exc).__name__}: {exc})```\n" f"Commande non envoyée : `{self.commande}`") else: self.delete() def execute(self, tries=0): """Exécute la tâche planifiée (méthode appellée par la loop). Programme :meth:`send_webhook` pour exécution immédiate. Args: tries (int): Numéro de l'essai d'envoi actuellement en cours, passé à :meth:`send_webhook`. """ asyncio.create_task(self.send_webhook(tries=tries)) # programme la coroutine pour exécution immédiate def register(self): """Programme l'exécution de la tâche dans la loop du bot.""" now = datetime.datetime.now() delay = (self.timestamp - now).total_seconds() TH = config.loop.call_later(delay, self.execute) # Programme la tâche (appellera tache.execute() à timestamp) self.handler = TH # TimerHandle, pour pouvoir cancel def cancel(self): """Annule et nettoie la tâche planifiée (sans la supprimer en base). Si la tâche a déjà été exécutée, ne fait que nettoyer le handler. """ try: self.handler.cancel() # Annule la task (objet TimerHandle) # (pas d'effet si la tâche a déjà été exécutée) except RuntimeError: # Tache non enregistrée pass else: del self.handler def add(self, *other): """Enregistre la tâche sur le bot et en base. Globalement équivalent à un appel à :meth:`.register` (pour chaque élément le cas échéant) avant l'ajout en base habituel (:meth:`TableBase.add <.bdd.base.TableBase.add>`). Args: \*other: autres instances à ajouter dans le même commit, éventuellement. """ super().add(*other) # Enregistre tout en base self.register() # Enregistre sur le bot for item in other: # Les autres aussi item.register() def delete(self, *other): """Annule la tâche planifiée et la supprime en base. Globalement équivalent à un appel à :meth:`.cancel` (pour chaque élément le cas échéant) avant la suppression en base habituelle (:meth:`TableBase.add <.bdd.base.TableBase.add>`). Args: \*other: autres instances à supprimer dans le même commit, éventuellement. """ self.cancel() # Annule la tâche for item in other: # Les autres aussi item.cancel() super().delete(*other) # Supprime tout en base
class Ciblage(base.TableBase): """Table de données des cibles désignées dans les utilisations d'actions. Les instances sont enregistrées via :meth:`\!action <.voter_agir.VoterAgir.VoterAgir.action.callback>` ; elles n'ont pas vocation à être supprimées. """ id = autodoc_Column( sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique du ciblage, sans signification") _base_id = sqlalchemy.Column(sqlalchemy.ForeignKey("baseciblages._id")) base = autodoc_ManyToOne( "BaseCiblage", back_populates="ciblages", nullable=True, doc="Modèle de ciblage (lié au modèle d'action). Vaut ``None`` pour " "un ciblage de vote") _utilisation_id = sqlalchemy.Column( sqlalchemy.ForeignKey("utilisations.id"), nullable=False) utilisation = autodoc_ManyToOne("Utilisation", back_populates="ciblages", doc="Utilisation où ce ciblage a été fait") _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"), nullable=True) joueur = autodoc_ManyToOne( "Joueur", back_populates="ciblages", nullable=True, doc="Joueur désigné, si ``base.type`` vaut " ":attr:`~.bdd.CibleType.joueur`, :attr:`~.bdd.CibleType.vivant` " "ou :attr:`~.bdd.CibleType.mort`") _role_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("roles.slug"), nullable=True) role = autodoc_ManyToOne("Role", back_populates="ciblages", nullable=True, doc="Rôle désigné, si ``base.type`` vaut " ":attr:`~.bdd.CibleType.role`") _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"), nullable=True) camp = autodoc_ManyToOne("Camp", back_populates="ciblages", nullable=True, doc="Camp désigné, si ``base.type`` vaut " ":attr:`~.bdd.CibleType.camp`") booleen = autodoc_Column( sqlalchemy.Boolean(), doc="Valeur, si ``base.type`` vaut :attr:`~.bdd.CibleType.booleen`") texte = autodoc_Column( sqlalchemy.String(1000), doc="Valeur, si ``base.type`` vaut :attr:`~.bdd.CibleType.texte`") def __repr__(self): """Return repr(self).""" return f"<Ciblage #{self.id} ({self.base}/{self.utilisation})>" @property def _val_attr(self): """Nom de l'attribut stockant la valeur du ciblage""" if (not self.base # vote or self.base.type in {CibleType.joueur, CibleType.vivant, CibleType.mort}): return "joueur" elif self.base.type == CibleType.role: return "role" elif self.base.type == CibleType.camp: return "camp" elif self.base.type == CibleType.booleen: return "booleen" elif self.base.type == CibleType.texte: return "texte" else: raise ValueError(f"Ciblage de type inconnu : {self.base.type}") @property def valeur(self): """:class:`~bdd.Joueur` | :class:`~bdd.Role`| :class:`~bdd.Camp` | :class:`bool` | :class:`str`: Valeur du ciblage, selon son type. Propriété en lecture et écriture. Raises: ValueError: ciblage de type inconnu """ return getattr(self, self._val_attr) @valeur.setter def valeur(self, value): setattr(self, self._val_attr, value) @property def valeur_descr(self): """str: Description de la valeur du ciblage. Si :attr:`valeur` vaut ``None``, renvoie ``<N/A>`` Raises: ValueError: ciblage de type inconnu """ if self.valeur is None: return "<N/A>" if (not self.base # vote or self.base.type in {CibleType.joueur, CibleType.vivant, CibleType.mort}): return self.joueur.nom elif self.base.type == CibleType.role: return self.role.nom_complet elif self.base.type == CibleType.camp: return self.camp.nom elif self.base.type == CibleType.booleen: return "Oui" if self.booleen else "Non" else: return self.texte
class Action(base.TableBase): """Table de données des actions attribuées (liées à un joueur). Les instances doivent être enregistrées via :func:`.gestion_actions.add_action` et supprimées via :func:`.gestion_actions.delete_action`. """ id = autodoc_Column( sqlalchemy.Integer(), primary_key=True, doc="Identifiant unique de l'action, sans signification") _joueur_id = sqlalchemy.Column(sqlalchemy.ForeignKey("joueurs.discord_id"), nullable=False) joueur = autodoc_ManyToOne("Joueur", back_populates="actions", doc="Joueur concerné") _base_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("baseactions.slug")) base = autodoc_ManyToOne("BaseAction", back_populates="actions", nullable=True, doc="Action de base (``None`` si action de vote)") vote = autodoc_Column(sqlalchemy.Enum(Vote), doc="Si action de vote, vote concerné") active = autodoc_Column( sqlalchemy.Boolean(), nullable=False, default=True, doc="Si l'action est actuellement utilisable (False = archives)") cooldown = autodoc_Column( sqlalchemy.Integer(), nullable=False, default=0, doc="Nombre d'ouvertures avant disponiblité de l'action") charges = autodoc_Column( sqlalchemy.Integer(), doc="Nombre de charges restantes (``None`` si illimité)") # One-to-manys taches = autodoc_OneToMany("Tache", back_populates="action", doc="Tâches liées à cette action") utilisations = autodoc_DynamicOneToMany("Utilisation", back_populates="action", doc="Utilisations de cette action") def __init__(self, *args, **kwargs): """Initialize self.""" n_args = (("base" in kwargs) + ("_base_slug" in kwargs) + ("vote" in kwargs)) if not n_args: raise ValueError("bdd.Action: 'base'/'_base_slug' or 'vote' " "keyword-only argument must be specified") elif n_args > 1: raise ValueError("bdd.Action: 'base'/'_base_slug' and 'vote' " "keyword-only argument cannot both be specified") super().__init__(*args, **kwargs) def __repr__(self): """Return repr(self).""" return f"<Action #{self.id} ({self.base or self.vote}/{self.joueur})>" @property def utilisation_ouverte(self): """:class:`~bdd.Utilisation` | ``None``: Utilisation de l'action actuellement ouverte. Vaut ``None`` si aucune action n'a actuellement l'état :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`. Raises: RuntimeError: plus d'une action a actuellement l'état :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`. """ filtre = Utilisation.etat.in_({UtilEtat.ouverte, UtilEtat.remplie}) try: return self.utilisations.filter(filtre).one_or_none() except sqlalchemy.orm.exc.MultipleResultsFound: raise ValueError( f"Plusieurs utilisations ouvertes pour `{self}` !") @property def derniere_utilisation(self): """:class:`~bdd.Utilisation` | ``None``:: Dernière utilisation de cette action (temporellement). Considère l'utilisation ouverte le cas échéant, sinon la dernière utilisation par timestamp de fermeture descendant (quelque soit son état, y comprs :attr:`~.bdd.UtilEtat.contree`). Vaut ``None`` si l'action n'a jamais été utilisée. Raises: RuntimeError: plus d'une action a actuellement l'état :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`. """ return (self.utilisation_ouverte or self.utilisations.order_by( Utilisation.ts_close.desc()).first()) @property def decision(self): """str: Description de la décision de la dernière utilisation. Considère l'utilisation ouverte le cas échéant, sinon la dernière utilisation par timestamp de fermeture descendant. Vaut :attr:`.Utilisation.decision`, ou ``"<N/A>"`` si il n'y a aucune utilisation de cette action. Raises: RuntimeError: plus d'une action a actuellement l'état :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.remplie`. """ util = self.derniere_utilisation if util: return util.decision else: return "<N/A>" @hybrid_property def is_open(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): L'action est ouverte (l'utilisateur peut interagir) ? *I.e.* l'action a au moins une utilisation :attr:`~.bdd.UtilEtat.ouverte` ou :attr:`~.bdd.UtilEtat.remplie`. Propriété hybride (:class:`sqlalchemy.ext.hybrid.hybrid_property`) : - Sur l'instance, renvoie directement la valeur booléenne ; - Sur la classe, renvoie la clause permettant de déterminer si l'action est en attente. Examples:: action.is_open # bool Joueur.query.filter(Joueur.actions.any(Action.is_open)).all() """ return bool(self.utilisations.filter(Utilisation.is_open).all()) @is_open.expression def is_open(cls): return cls.utilisations.any(Utilisation.is_open) @hybrid_property def is_waiting(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): L'action est ouverte et aucune décision n'a été prise ? *I.e.* la clause a au moins une utilisation :attr:`~.bdd.UtilEtat.ouverte`. Propriété hybride (voir :attr:`.is_open` pour plus d'infos) """ return bool(self.utilisations.filter(Utilisation.is_waiting).all()) @is_waiting.expression def is_waiting(cls): return cls.utilisations.any(Utilisation.is_waiting)
class Utilisation(base.TableBase): """Table de données des utilisations des actions. Les instances sont enregistrées via :meth:`\!open <.open_close.OpenClose.OpenClose.open.callback>` ; elles n'ont pas vocation à être supprimées. """ id = autodoc_Column( sqlalchemy.BigInteger(), primary_key=True, doc="Identifiant unique de l'utilisation, sans signification") _action_id = sqlalchemy.Column(sqlalchemy.ForeignKey("actions.id"), nullable=False) action = autodoc_ManyToOne("Action", back_populates="utilisations", doc="Action utilisée") etat = autodoc_Column(sqlalchemy.Enum(UtilEtat), nullable=False, default=UtilEtat.ouverte, doc="État de l'utilisation") ts_open = autodoc_Column(sqlalchemy.DateTime(), doc="Timestamp d'ouverture de l'utilisation") ts_close = autodoc_Column(sqlalchemy.DateTime(), doc="Timestamp de fermeture de l'utilisation") ts_decision = autodoc_Column( sqlalchemy.DateTime(), doc="Timestamp du dernier remplissage de l'utilisation") # One-to-manys ciblages = autodoc_OneToMany("Ciblage", back_populates="utilisation", doc="Cibles désignées dans cette utilisation") def __repr__(self): """Return repr(self).""" return f"<Utilisation #{self.id} ({self.action}/{self.etat})>" def open(self): """Ouvre cette utilisation. Modifie son :attr:`etat`, définit :attr:`ts_open` au temps actuel, et update. """ self.etat = UtilEtat.ouverte self.ts_open = datetime.datetime.now() self.update() def close(self): """Clôture cette utilisation. Modifie son :attr:`etat`, définit :attr:`ts_close` au temps actuel, et update. """ if self.etat == UtilEtat.remplie: self.etat = UtilEtat.validee else: self.etat = UtilEtat.ignoree self.ts_close = datetime.datetime.now() self.update() def ciblage(self, slug): """Renvoie le ciblage de base de slug voulu. Args: slug (str): Doit correspondre à un des slugs des bases des :attr:`ciblages` de l'utilisation. Returns: :class:`.bdd.Ciblage` Raises: ValueError: slug non trouvé dans les :attr:`ciblages` """ try: return next(cib for cib in self.ciblages if cib.base.slug == slug) except StopIteration: raise ValueError( f"{self} : pas de ciblage de slug '{slug}'") from None @property def cible(self): """:class:`~bdd.Joueur` | ``None``: Joueur ciblé par l'utilisation, si applicable. Cet attribut n'est accessible que si l'utilisation est d'un vote ou d'une action définissant un et une seul ciblage de type :attr:`~bdd.CibleType.joueur`, :attr:`~bdd.CibleType.vivant` ou :attr:`~bdd.CibleType.mort`. Vaut ``None`` si l'utilisation a l'état :attr:`~bdd.UtilEtat.ouverte` ou :attr:`~bdd.UtilEtat.ignoree`. Raises: ValueError: l'action ne remplit pas les critères évoqués ci-dessus """ if self.action.vote: # vote : un BaseCiblage implicite de type CibleType.vivants return self.ciblages[0].joueur if self.ciblages else None else: base_ciblages = self.action.base.base_ciblages bc_joueurs = [ bc for bc in base_ciblages if bc.type in [CibleType.joueur, CibleType.vivant, CibleType.mort] ] if len(bc_joueurs) != 1: raise ValueError(f"L'utilisation {self} n'a pas une et " "une seule cible de type joueur") base_ciblage = bc_joueurs[0] try: ciblage = next(cib for cib in self.ciblages if cib.base == base_ciblage) except StopIteration: return None # Pas de ciblage fait return ciblage.joueur @property def decision(self): """str: Description de la décision de cette utilisation. Complète le template de :.bdd.BaseAction.decision_format` avec les valeurs des ciblages de l'utilisation. Vaut ``"Ne rien faire"`` si l'utilisation n'a pas de ciblages, et :attr:`.cible` dans le cas d'un vote. """ if not self.action.base: return str(self.cible) if not self.ciblages: return "Ne rien faire" template = self.action.base.decision_format data = { ciblage.base.slug: ciblage.valeur_descr for ciblage in self.ciblages } try: return template.format(**data) except KeyError: return template @hybrid_property def is_open(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): L'utilisation est ouverte (l'utilisateur peut interagir) ? Raccourci pour ``utilisation.etat in {UtilEtat.ouverte, UtilEtat.remplie}`` Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos) """ return (self.etat in {UtilEtat.ouverte, UtilEtat.remplie}) @is_open.expression def is_open(cls): return cls.etat.in_({UtilEtat.ouverte, UtilEtat.remplie}) @hybrid_property def is_waiting(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): L'utilisation est ouverte et aucune décision n'a été prise ? Raccourci pour ``utilisation.etat == UtilEtat.ouverte`` Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos) """ return (self.etat == UtilEtat.ouverte) @hybrid_property def is_filled(self): """:class:`bool` (instance) / :class:`sqlalchemy.sql.selectable.Exists` (classe): L'utilisation est remplie (l'utilisateur a interagi avec) ? Raccourci pour ``utilisation.etat in {UtilEtat.remplie, UtilEtat.validee, UtilEtat.contree}`` Propriété hybride (voir :attr:`.Action.is_open` pour plus d'infos) """ return (self.etat in {UtilEtat.remplie, UtilEtat.validee, UtilEtat.contree}) @is_filled.expression def is_filled(cls): return cls.etat.in_( {UtilEtat.remplie, UtilEtat.validee, UtilEtat.contree})
class Role(base.TableBase): """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>`. """ slug = autodoc_Column(sqlalchemy.String(32), primary_key=True, doc="Identifiant unique du rôle") prefixe = autodoc_Column(sqlalchemy.String(8), nullable=False, default="", doc="Article du nom du rôle (``\"Le \"``, ``\"La \"``, ``\"L'\"``...)") nom = autodoc_Column(sqlalchemy.String(32), nullable=False, doc="Nom (avec casse et accents) du rôle") _camp_slug = sqlalchemy.Column(sqlalchemy.ForeignKey("camps.slug"), nullable=False) camp = autodoc_ManyToOne("Camp", back_populates="roles", doc="Camp auquel ce rôle est affilié à l'origine\n\n (On peut avoir " "``joueur.camp != joueur.role.camp`` si damnation, passage MV...)") actif = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True, doc="Rôle actif ? (affiché dans la liste des rôles, etc)") description_courte = autodoc_Column(sqlalchemy.String(140), nullable=False, default="", doc="Description du rôle en une ligne") description_longue = autodoc_Column(sqlalchemy.String(1800), nullable=False, default="", doc="Règles et background complets du rôle") # to-manys joueurs = autodoc_OneToMany("Joueur", back_populates="role", doc="Joueurs ayant ce rôle") ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="role", doc="Ciblages prenant ce rôle pour cible") base_actions = autodoc_ManyToMany("BaseAction", secondary=_baseaction_role, back_populates="roles", doc="Modèles d'actions associées") def __repr__(self): """Return repr(self).""" return f"<Role '{self.slug}' ({self.prefixe}{self.nom})>" def __str__(self): """Return str(self).""" return self.nom_complet @property def nom_complet(self): """str: Préfixe + nom du rôle""" return f"{self.prefixe}{self.nom}" @property def embed(self): """discord.Embed: Embed Discord présentant le rôle et ses actions.""" emb = discord.Embed( title=f"**{self.nom_complet}** – {self.description_courte}", description=self.description_longue ) if (emoji := self.camp.discord_emoji_or_none): emb.set_thumbnail(url=emoji.url) for ba in self.base_actions: emb.add_field(name=f"{config.Emoji.action} Action : {ba.slug}", value=ba.temporalite) return emb
class BaseAction(base.TableBase): """Table de données des actions définies de base (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>`. """ slug = autodoc_Column(sqlalchemy.String(32), primary_key=True, doc="Identifiant unique de l'action") trigger_debut = autodoc_Column(sqlalchemy.Enum(ActionTrigger), nullable=False, default=ActionTrigger.perma, doc="Mode de déclenchement de l'ouverture de l'action") trigger_fin = autodoc_Column(sqlalchemy.Enum(ActionTrigger), nullable=False, default=ActionTrigger.perma, doc="Mode de déclenchement de la clôture de l'action") instant = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=False, doc="L'action est instantannée (conséquence dès la prise de décision)" " ou non (conséquence à la fin du créneau d'action)") heure_debut = autodoc_Column(sqlalchemy.Time(), doc="Si :attr:`.trigger_debut` vaut " ":attr:`~ActionTrigger.temporel`, l'horaire associé") heure_fin = autodoc_Column(sqlalchemy.Time(), doc="Si :attr:`.trigger_fin` vaut\n" "- :attr:`~ActionTrigger.temporel` : l'horaire associé ;\n" "- :attr:`~ActionTrigger.delta`, l'intervalle associé") base_cooldown = autodoc_Column(sqlalchemy.Integer(), nullable=False, default=0, doc="Temps de rechargement entre deux utilisations du pouvoir " "(``0`` si pas de cooldown)") base_charges = autodoc_Column(sqlalchemy.Integer(), doc="Nombre de charges initiales du pouvoir (``None`` si illimité)") refill = autodoc_Column(sqlalchemy.String(32), nullable=False, default="", doc="Évènements pouvant recharger l'action, séparés par des virgules " "(``\"weekends\"``, ``\"forgeron\"``, ``\"rebouteux\"``...)") lieu = autodoc_Column(sqlalchemy.String(100), doc="*Attribut informatif, non exploité dans la version actuelle " "(Distance/Physique/Lieu/Contact/Conditionnel/None/Public)*") interaction_notaire = autodoc_Column(sqlalchemy.String(100), doc="*Attribut informatif, non exploité dans la version actuelle " "(Oui/Non/Conditionnel/Potion/Rapport ; None si récursif)*") interaction_gardien = autodoc_Column(sqlalchemy.String(100), doc="*Attribut informatif, non exploité dans la version actuelle " "(Oui/Non/Conditionnel/Taverne/Feu/MaisonClose/Précis/" "Cimetière/Loups ; None si récursif)*") mage = autodoc_Column(sqlalchemy.String(100), doc="*Attribut informatif, non exploité dans la version actuelle " "(Oui/Non/Changement de cible/...)*") decision_format = autodoc_Column(sqlalchemy.String(200), nullable=False, default="", doc="Description des utilisations de ces action, sous forme de " "texte formaté avec les noms des :attr:`.BaseCiblage.slug` " "entre accolades (exemple : ``Tuer {cible}``)") # -to-manys actions = autodoc_OneToMany("Action", back_populates="base", doc="Actions déroulant de cette base") base_ciblages = autodoc_OneToMany("BaseCiblage", back_populates="base_action", cascade="all, delete-orphan", order_by="BaseCiblage.prio", doc="Ciblages de ce modèle d'action (triés par priorité)") roles = autodoc_ManyToMany("Role", secondary=_baseaction_role, back_populates="base_actions", doc="Rôles ayant cette action de base") def __repr__(self): """Return repr(self).""" return f"<BaseAction '{self.slug}'>" def __str__(self): """Return str(self).""" return str(self.slug) @property def temporalite(self): """str: Phrase décrivant le mode d'utilisation / timing de l'action.""" def _time_to_heure(tps): if not tps: return "" if tps.hour == 0: return f"{tps.minute} min" if tps.minute > 0: return f"{tps.hour}h{tps.minute:02}" return f"{tps.hour}h" rep = "" # Périodicté if self.trigger_debut == ActionTrigger.perma: rep += "N'importe quand" elif self.trigger_debut == ActionTrigger.start: rep += "Au lancement de la partie" elif self.trigger_debut == ActionTrigger.mort: rep += "À la mort" else: if self.base_cooldown: rep += f"Tous les {self.base_cooldown + 1} jours " else: rep += "Tous les jours " # Fenêtre if self.trigger_debut == ActionTrigger.mot_mjs: rep += "à l'annonce des résultats du vote" elif self.trigger_debut == ActionTrigger.open_cond: rep += "pendant le vote condamné" elif self.trigger_debut == ActionTrigger.open_maire: rep += "pendant le vote pour le maire" elif self.trigger_debut == ActionTrigger.open_loups: rep += "pendant le vote des loups" elif self.trigger_debut == ActionTrigger.close_cond: rep += "à la fermeture du vote condamné" elif self.trigger_debut == ActionTrigger.close_maire: rep += "à la fermeture du vote pour le maire" elif self.trigger_debut == ActionTrigger.close_loups: rep += "à la fermeture du vote des loups" elif self.trigger_debut == ActionTrigger.temporel: if self.trigger_fin == ActionTrigger.temporel: rep += f"de {_time_to_heure(self.heure_debut)}" else: rep += f"à {_time_to_heure(self.heure_debut)}" # Fermeture if self.trigger_fin == ActionTrigger.delta: rep += f" – {_time_to_heure(self.heure_fin)} pour agir" elif self.trigger_fin == ActionTrigger.temporel: rep += f" à {_time_to_heure(self.heure_fin)}" # Autres caractères if self.instant: rep += f" (conséquence instantanée)" if self.base_charges: rep += f" – {self.base_charges} fois" if "weekends" in self.refill: rep += f" par semaine" return rep
class Camp(base.TableBase): """Table de données des camps, publics et secrets. Cette table est remplie automatiquement à partir du Google Sheet "Rôles et actions" par la commande :meth:`\!fillroles <.sync.Sync.Sync.fillroles.callback>`. """ slug = autodoc_Column(sqlalchemy.String(32), primary_key=True, doc="Identifiant unique du camp") nom = autodoc_Column(sqlalchemy.String(32), nullable=False, doc="Nom (affiché) du camp") description = autodoc_Column(sqlalchemy.String(1000), nullable=False, default="", doc="Description (courte) du camp") public = autodoc_Column(sqlalchemy.Boolean(), nullable=False, default=True, doc="L'existance du camp (et des rôles liés) est connue de tous ?") emoji = autodoc_Column(sqlalchemy.String(32), doc="Nom de l'emoji associé au camp (doit être le nom d'un " "emoji existant sur le serveur)") # One-to-manys joueurs = autodoc_OneToMany("Joueur", back_populates="camp", doc="Joueurs appartenant à ce camp") roles = autodoc_OneToMany("Role", back_populates="camp", doc="Rôles affiliés à ce camp de base") ciblages = autodoc_DynamicOneToMany("Ciblage", back_populates="camp", doc="Ciblages prenant ce camp pour cible") def __repr__(self): """Return repr(self).""" return f"<Camp '{self.slug}' ({self.nom})>" def __str__(self): """Return str(self).""" return str(self.nom) @property def discord_emoji(self): """discord.Emoji: Emoji Discord correspondant à ce camp Raises: ValueError: :attr:`.emoji` non défini ou manquant sur le serveur ~ready_check.NotReadyError: bot non connecté (:obj:`.config.guild` vaut ``None``) """ if not self.emoji: raise ValueError(f"{self}.emoji non défini !") try: return next(e for e in config.guild.emojis if e.name == self.emoji) except StopIteration: raise ValueError(f"Pas d'emoji :{self.emoji}: " "sur le serveur !") from None @property def discord_emoji_or_none(self): """:class:`discord.Emoji` | ``None``: :attr:`.discord_emoji` si défini Raises: ~ready_check.NotReadyError: bot non connecté (:obj:`.config.guild` vaut ``None``) """ try: return self.discord_emoji except ValueError: return None @classmethod def default(cls): """Retourne le camp par défaut (celui avant attribution). Warning: Un camp de :attr:`.slug` :obj:`.config.default_camp_slug` doit être défini en base. Returns: ~bdd.Camp Raises: ValueError: camp introuvable en base RuntimeError: session non initialisée (:obj:`.config.session` vaut ``None``) """ slug = config.default_camp_slug camp = cls.query.get(slug) if not camp: raise ValueError( "Camp par défaut (de slug " f"lgrez.config.default_camp_slug = \"{slug}\") non " "défini (dans le GSheet Rôles et actions) ou non " f"chargé (`!fillroles`) !" ) return camp