"""lg-rez / features / Synchronisation GSheets
Récupération et application des données des GSheets : modifications décidées via le Tableau de bord et rôles
"""
import traceback
import time
from discord import Embed
from discord.ext import commands
from lgrez.blocs import tools, bdd, bdd_tools, env, gsheets
from lgrez.blocs.bdd import engine, Tables, Joueurs, Actions, BaseActions, BaseActionsRoles, Roles, Taches
from lgrez.features import gestion_actions
[docs]async def get_sync():
"""Récupère les modifications en attente sur le TDB
Charge les données du Tableau de bord (variable d'environment ``LGREZ_TDB_SHEET_ID``), compare les informations qui y figurent avec celles de la base de données (:class:`.bdd.Joueurs`)
Pour les informations différant, met à jour le Tableau de bord (ligne plus en rouge)
Returns:
:class:`dict`\[:attr:`.bdd.Joueurs.id`, :class:`dict`\[:class:`str`, :class:`object`\]\]: Le dictionnaire des modifications pour chaque joueur (repéré par son ID Discord)
"""
cols = [col for col in bdd_tools.get_cols(Joueurs) if not col.endswith('_')] # On élimine les colonnes locales
cols_SQL_types = bdd_tools.get_SQL_types(Joueurs)
cols_SQL_nullable = bdd_tools.get_SQL_nullable(Joueurs)
### RÉCUPÉRATION INFOS GSHEET
SHEET_ID = env.load("LGREZ_TDB_SHEET_ID")
workbook = gsheets.connect(SHEET_ID) # Tableau de bord
sheet = workbook.worksheet("Journée en cours")
values = sheet.get_all_values() # Liste de liste des valeurs des cellules
(NL, NC) = (len(values), len(values[0]))
head = values[2] # Ligne d'en-têtes (noms des colonnes) = 3e ligne du TDB
TDB_index = {col: head.index(col) for col in cols} # Dictionnaire des indices des colonnes GSheet pour chaque colonne de la table
TDB_tampon_index = {col: head.index(f"tampon_{col}") for col in cols if col != 'discord_id'} # Idem pour la partie « tampon »
# CONVERSION INFOS GSHEET EN UTILISATEURS
joueurs_TDB = [] # Liste des joueurs tels qu'actuellement dans le TDB
ids_TDB = [] # discord_ids des différents joueurs du TDB
rows_TDB = {} # Indices des lignes ou sont les différents joueurs du TDB
for l in range(NL):
L = values[l] # On parcourt les lignes du TDB
id_cell = L[TDB_index["discord_id"]]
if id_cell.isdigit(): # Si la cellule contient bien un ID (que des chiffres, et pas vide)
id = int(id_cell)
joueur_TDB = {col: bdd_tools.transtype(L[TDB_index[col]], col, cols_SQL_types[col], cols_SQL_nullable[col]) for col in cols}
# Dictionnaire correspondant à l'utilisateur
joueurs_TDB.append(joueur_TDB)
ids_TDB.append(id)
rows_TDB[id] = l
### RÉCUPÉRATION UTILISATEURS CACHE
joueurs_BDD = Joueurs.query.all() # Liste des joueurs tels qu'actuellement en cache
ids_BDD = [joueur_BDD.discord_id for joueur_BDD in joueurs_BDD]
### COMPARAISON
modifs = [] # modifs à porter au TDB : tuple (id - colonne (nom) - valeur)
modified_ids = []
for joueur_BDD in joueurs_BDD.copy(): ## Joueurs dans le cache supprimés du TDB
if joueur_BDD.discord_id not in ids_TDB:
joueurs_BDD.remove(joueur_BDD)
bdd.session.delete(joueur_BDD)
for joueur_TDB in joueurs_TDB: ## Différences
id = joueur_TDB["discord_id"]
if id not in ids_BDD: # Si joueur dans le cache pas dans le TDB
raise ValueError(f"Joueur {joueur_TDB['nom']} hors BDD : vérifier processus d'inscription")
joueur_BDD = [joueur for joueur in joueurs_BDD if joueur.discord_id == id][0] # joueur correspondant dans le cache
for col in cols:
if getattr(joueur_BDD, col) != joueur_TDB[col]: # Si <col> diffère entre TDB et cache
modifs.append( (id, col, joueur_TDB[col]) ) # On ajoute les modifs
if id not in modified_ids:
modified_ids.append(id)
### APPLICATION DES MODIFICATIONS SUR LE TDB
if modifs:
modifs_lc = [(rows_TDB[id], TDB_tampon_index[col], v) for (id, col, v) in modifs]
# On transforme les infos en coordonnées dans le TDB : ID -> ligne et col -> colonne,
gsheets.update(sheet, modifs_lc)
### RETOURNAGE DES RÉSULTATS
return {id: {col: v for (idM, col, v) in modifs if idM == id} for id in modified_ids}
[docs]async def modif_joueur(ctx, joueur_id, modifs, silent=False):
"""Attribue les modifications demandées au joueur
Args:
ctx (:class:`~discord.ext.commands.Context`): contexte quelconque du bot
modifs (:class:`dict`\[:class:`str`, :class:`object`\]\]): dictionnaire {colonne BDD: nouvelle valeur}
silent (:class:`bool`): si ``True``, no notifie pas le joueur des modifications
Pour chaque modifications de ``modif``, applique les conséquences adéquates (rôles, nouvelles actions, tâches planifiées...) et informe le joueur si ``silent`` vaut ``False``.
"""
joueur = Joueurs.query.get(int(joueur_id))
assert joueur, f"!sync : joueur d'ID {joueur_id} introuvable"
member = ctx.guild.get_member(joueur.discord_id)
assert member, f"!sync : member {joueur} introuvable"
chan = ctx.guild.get_channel(joueur.chan_id_)
assert chan, f"!sync : chan privé de {member} introuvable"
changelog = f"\n- {member.display_name} (@{member.name}#{member.discriminator}) :\n"
notif = ""
for col, val in modifs.items():
changelog += f" - {col} : {val}\n"
if col == "nom": # Renommage joueur
await chan.edit(name=f"conv-bot-{val}")
await member.edit(nick=val)
if not silent:
notif += f":arrow_forward: Tu t'appelles maintenant {tools.bold(val)}.\n"
elif col == "chambre" and not silent: # Modification chambre
notif += f":arrow_forward: Tu habites maintenant en chambre {tools.bold(val)}.\n"
elif col == "statut":
if val == "vivant": # Statut = vivant
await member.add_roles(tools.role(ctx, "Joueur en vie"))
await member.remove_roles(tools.role(ctx, "Joueur mort"))
if not silent:
notif += f":arrow_forward: Tu es maintenant en vie. EN VIE !!!\n"
elif val == "mort": # Statut = mort
await member.add_roles(tools.role(ctx, "Joueur mort"))
await member.remove_roles(tools.role(ctx, "Joueur en vie"))
if not silent:
notif += f":arrow_forward: Tu es malheureusement décédé(e) :cry:\nÇa arrive même aux meilleurs, en espérant que ta mort ait été belle !\n"
# Actions à la mort
for action in Actions.query.filter_by(player_id=joueur.discord_id, trigger_debut="mort"):
await gestion_actions.open_action(ctx, action, chan)
elif val == "MV": # Statut = MV
await member.add_roles(tools.role(ctx, "Joueur en vie"))
await member.remove_roles(tools.role(ctx, "Joueur mort"))
if not silent:
notif += f":arrow_forward: Te voilà maintenant réduit(e) au statut de mort-vivant... Un MJ viendra te voir très vite, si ce n'est déjà fait, mais retient que la partie n'est pas finie pour toi !\n"
elif not silent: # Statut = autre
notif += f":arrow_forward: Nouveau statut : {tools.bold(val)} !\n"
elif col == "role": # Modification rôle
old_bars = Joueurs.query.filter_by(role=joueur.role).all()
old_actions = []
for bar in old_bars:
old_actions.extend(Joueurs.query.filter_by(action=bar.action, player_id=joueur.discord_id).all())
for action in old_actions:
gestion_actions.delete_action(ctx, action) # On supprime les anciennes actions de rôle (et les tâches si il y en a)
new_bars = Joueurs.query.filter_by(role=val).all() # Actions associées au nouveau rôle
new_bas = [Joueurs.query.get(bar.action) for bar in new_bars] # Nouvelles BaseActions
cols = [col for col in bdd_tools.get_cols(BaseActions) if not col.startswith("base")]
new_actions = [Actions(player_id=joueur.discord_id, **{col: getattr(ba, col) for col in cols},
cooldown=0, charges=ba.base_charges) for ba in new_bas]
await tools.log(ctx, str(new_actions))
for action in new_actions:
gestion_actions.add_action(ctx, action) # Ajout et création des tâches si trigger temporel
role = tools.nom_role(val)
if not role: # role <val> pas en base : Error!
role = f"« {val} »"
await tools.log(ctx, f"{tools.mention_MJ(ctx)} ALED : rôle \"{val}\" attribué à {joueur.nom} inconnu en base !")
if not silent:
notif += f":arrow_forward: Ton nouveau rôle, si tu l'acceptes : {tools.bold(role)} !\nQue ce soit pour un jour ou pour le reste de la partie, renseigne toi en tapant {tools.code(f'!roles {val}')}.\n"
elif col == "camp" and not silent: # Modification camp
notif += f":arrow_forward: Tu fais maintenant partie du camp « {tools.bold(val)} ».\n"
elif col == "votant_village" and not silent:
if val: # votant_village = True
notif += f":arrow_forward: Tu peux maintenant participer aux votes du village !\n"
else: # votant_village = False
notif += f":arrow_forward: Tu ne peux maintenant plus participer aux votes du village.\n"
elif col == "votant_loups" and not silent:
if val: # votant_loups = True
notif += f":arrow_forward: Tu peux maintenant participer aux votes des loups ! Amuse-toi bien :wolf:\n"
else: # votant_loups = False
notif += f":arrow_forward: Tu ne peux maintenant plus participer aux votes des loups.\n"
elif col == "role_actif" and not silent:
if val: # role_actif = True
notif += f":arrow_forward: Tu peux maintenant utiliser tes pouvoirs !\n"
else: # role_actif = False
notif += f":arrow_forward: Tu ne peux maintenant plus utiliser aucun pouvoir.\n"
bdd_tools.modif(joueur, col, val) # Dans tous les cas, on modifie en base (après, pour pouvoir accéder aux vieux attribus plus haut)
if not silent:
await chan.send(f":zap: {member.mention} Une action divine vient de modifier ton existence ! :zap:\n"
+ f"\n{notif}\n"
+ tools.ital(":warning: Si tu penses qu'il y a erreur, appelle un MJ au plus vite !"))
return changelog
[docs]class Sync(commands.Cog):
"""Sync - Commandes de synchronisation des GSheets vers la BDD et les joueurs"""
@commands.command()
@tools.mjs_only
async def sync(self, ctx, silent=False):
"""Récupère et applique les modifications du Tableau de bord (COMMANDE MJ)
Args:
silent: si spécifié (quelque soit sa valeur), les joueurs ne sont pas notifiés des modifications.
Cette commande va récupérer les modifications en attente sur le Tableau de bord (lignes en rouge), modifer la BDD Joueurs, et appliquer les modificatons dans Discord le cas échéant : renommage des utilisateurs, modification des rôles...
"""
try:
await ctx.send("Récupération des modifications...")
async with ctx.typing():
dic = await get_sync() # Récupération du dictionnaire {joueur_id: modified_attrs}
silent = bool(silent)
changelog = f"Synchronisation TDB (silencieux = {silent}) :"
if dic:
nb_modifs = sum(len(modifs) for modifs in dic.values())
await ctx.send(f"{nb_modifs} modification(s) trouvée(s) pour {len(dic)} joueur(s), application...")
async with ctx.typing():
for joueur_id, modifs in dic.items(): # Joueurs dont au moins un attribut a été modifié
try:
changelog += await modif_joueur(ctx, joueur_id, modifs, silent)
except Exception as e:
changelog += traceback.format_exc()
await ctx.send(f"Erreur joueur {joueur_id}, passage au suivant, voir logs pour les détails")
bdd.session.commit()
await tools.log(ctx, changelog, code=True)
await ctx.send(f"Fait (voir {tools.channel(ctx, 'logs')} pour le détail)")
else:
await ctx.send("Pas de nouvelles modificatons.")
except Exception:
await tools.log(ctx, traceback.format_exc(), code=True)
@commands.command()
@tools.mjs_only
async def fillroles(self, ctx):
"""Remplit les tables des rôles / actions et #roles depuis le GSheet ad hoc (COMMANDE MJ)
- Remplit les tables :class:`.bdd.Roles`, :class:`.bdd.BaseActions` et :class:`.bdd.BaseActionsRoles` avec les informations du Google Sheets "Rôles et actions" (variable d'environnement ``LGREZ_ROLES_SHEET_ID``) ;
- Vide le chan ``#roles`` puis le remplit avec les descriptifs de chaque rôle.
Utile à chaque début de saison / changement dans les rôles/actions. Écrase toutes les entrées déjà en base, mais ne supprime pas celles obsolètes.
"""
SHEET_ID = env.load("LGREZ_ROLES_SHEET_ID")
workbook = gsheets.connect(SHEET_ID) # Tableau de bord
for table_name in ["Roles", "BaseActions", "BaseActionsRoles"]:
await ctx.send(f"Remplissage de la table {tools.code(table_name)}...")
async with ctx.typing():
sheet = workbook.worksheet(table_name)
values = sheet.get_all_values() # Liste de liste des valeurs des cellules
table = Tables[table_name]
cols = bdd_tools.get_cols(table)
SQL_types = bdd_tools.get_SQL_types(table)
SQL_nullable = bdd_tools.get_SQL_nullable(table)
primary_col = bdd_tools.get_primary_col(table)
cols_index = {col: values[0].index(col) for col in cols} # Dictionnaire des indices des colonnes GSheet pour chaque colonne de la table
existants = {getattr(item, primary_col):item for item in table.query.all()}
for L in values[1:]:
args = {col: bdd_tools.transtype(L[cols_index[col]], col, SQL_types[col], SQL_nullable[col]) for col in cols}
id = args[primary_col]
if id in existants:
for col in cols:
if getattr(existants[id], col) != args[col]:
bdd_tools.modif(existants[id], col, args[col])
else:
bdd.session.add(table(**args))
bdd.session.commit()
await ctx.send(f"Table {tools.code(table_name)} remplie !")
await tools.log(ctx, f"Table {tools.code(table_name)} remplie !")
chan_roles = tools.channel(ctx, "rôles")
await ctx.send(f"Vidage de {chan_roles.mention}...")
async with ctx.typing():
await chan_roles.purge(limit=1000)
roles = {camp: Roles.query.filter_by(camp=camp).all() for camp in ["village", "loups", "nécro", "solitaire", "autre"]}
await ctx.send(f"Remplissage... (temps estimé : {sum([len(v) + 2 for v in roles.values()]) + 1} secondes)")
t0 = time.time()
await chan_roles.send(f"Voici la liste des rôles : (accessible en faisant {tools.code('!roles')}, mais on l'a mis là parce que pourquoi pas)\n\n——————————————————————————")
async with ctx.typing():
for camp, roles_camp in roles.items():
if roles_camp:
await chan_roles.send(embed=Embed(title=f"Camp : {camp}").set_image(url=tools.emoji_camp(ctx, camp).url))
await chan_roles.send(f"——————————————————————————")
for role in roles_camp:
await chan_roles.send(f"{tools.emoji_camp(ctx, role.camp)} {tools.bold(role.prefixe + role.nom)} – {role.description_courte} (camp : {role.camp})\n\n{role.description_longue}\n\n——————————————————————————")
await ctx.send(f"{chan_roles.mention} rempli ! (en {(time.time() - t0):.4} secondes)")