first commit
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.env
|
BIN
__pycache__/family.cpython-311.pyc
Normal file
BIN
__pycache__/tree.cpython-311.pyc
Normal file
BIN
avatars/1111230921954304000.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
avatars/436978132105560064.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
avatars/506016482300395520.png
Normal file
After Width: | Height: | Size: 91 KiB |
BIN
avatars/567371662165803009.png
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
avatars/583588548431708162.png
Normal file
After Width: | Height: | Size: 487 KiB |
BIN
avatars/700651700477886504.png
Normal file
After Width: | Height: | Size: 127 KiB |
BIN
avatars/769178722838118400.png
Normal file
After Width: | Height: | Size: 97 KiB |
BIN
avatars/901473267104223232.png
Normal file
After Width: | Height: | Size: 394 KiB |
BIN
avatars/processed_1111230921954304000.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
avatars/processed_436978132105560064.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
avatars/processed_583588548431708162.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
avatars/processed_901473267104223232.png
Normal file
After Width: | Height: | Size: 18 KiB |
244
bot.py
Normal file
@ -0,0 +1,244 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import json
|
||||
import aiohttp
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from family import FamilyManager
|
||||
from tree import generate_tree
|
||||
from typing import Optional
|
||||
|
||||
# Configuration initiale
|
||||
load_dotenv()
|
||||
TOKEN = os.getenv("DISCORD_TOKEN")
|
||||
|
||||
with open("config.json") as f:
|
||||
config = json.load(f)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.members = True
|
||||
intents.message_content = True
|
||||
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
family = FamilyManager("family.json")
|
||||
|
||||
async def download_avatar(member: discord.Member) -> Optional[str]:
|
||||
"""Télécharge et enregistre l'avatar d'un membre"""
|
||||
if not member.avatar:
|
||||
return None
|
||||
|
||||
avatar_dir = "avatars"
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
avatar_path = f"{avatar_dir}/{member.id}.png"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(str(member.avatar.url)) as resp:
|
||||
if resp.status == 200:
|
||||
with open(avatar_path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
return avatar_path
|
||||
except Exception as e:
|
||||
print(f"Erreur de téléchargement avatar {member.id}: {e}")
|
||||
return None
|
||||
|
||||
async def update_member(member: discord.Member):
|
||||
"""Met à jour les infos d'un membre dans la famille"""
|
||||
avatar_url = str(member.avatar.url) if member.avatar else None
|
||||
family.add_member(
|
||||
str(member.id),
|
||||
member.display_name,
|
||||
avatar_url
|
||||
)
|
||||
await download_avatar(member)
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
print(f"Connecté en tant que {bot.user} (ID: {bot.user.id})")
|
||||
try:
|
||||
synced = await bot.tree.sync()
|
||||
print(f"Commandes synchronisées ({len(synced)}): {[c.name for c in synced]}")
|
||||
except Exception as e:
|
||||
print(f"Erreur synchronisation commandes: {e}")
|
||||
|
||||
@bot.tree.command(name="enregistrer", description="Enregistre ou met à jour son profil")
|
||||
async def enregistrer(interaction: discord.Interaction):
|
||||
"""Commande pour que les membres enregistrent leurs infos"""
|
||||
await update_member(interaction.user)
|
||||
await interaction.response.send_message(
|
||||
"✅ Profil mis à jour avec succès !",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@bot.tree.command(name="adopter", description="Adopter un membre")
|
||||
async def adopter(interaction: discord.Interaction, enfant: discord.Member):
|
||||
parent = interaction.user
|
||||
|
||||
# Validations
|
||||
if parent.id == enfant.id:
|
||||
await interaction.response.send_message("❌ Auto-adoption impossible !", ephemeral=True)
|
||||
return
|
||||
|
||||
if family.get_generation(str(enfant.id)) < family.get_generation(str(parent.id)):
|
||||
await interaction.response.send_message(
|
||||
"❌ Structure familiale invalide !",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Met à jour les infos des membres
|
||||
await update_member(parent)
|
||||
await update_member(enfant)
|
||||
|
||||
# Crée le lien familial
|
||||
if not family.add_child(str(parent.id), str(enfant.id)):
|
||||
await interaction.response.send_message(
|
||||
"❌ Erreur lors de l'adoption",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Attribue le rôle approprié
|
||||
generation = family.get_generation(str(enfant.id))
|
||||
role_id = config["ROLE_IDS"].get(
|
||||
"ENFANT" if generation == 1 else
|
||||
"PETIT_ENFANT" if generation == 2 else
|
||||
"ARRIERE_PETIT_ENFANT"
|
||||
)
|
||||
|
||||
role = interaction.guild.get_role(role_id)
|
||||
if role:
|
||||
try:
|
||||
await enfant.add_roles(role)
|
||||
message = f"✅ {parent.mention} a adopté {enfant.mention} !"
|
||||
|
||||
# Mentionne le partenaire si existe
|
||||
partner_id = family.get_partner(str(parent.id))
|
||||
if partner_id:
|
||||
partner = interaction.guild.get_member(int(partner_id))
|
||||
if partner:
|
||||
message += f"\n👫 Partenaire : {partner.mention}"
|
||||
|
||||
await interaction.response.send_message(message)
|
||||
except discord.Forbidden:
|
||||
await interaction.response.send_message(
|
||||
"❌ Permission refusée pour attribuer le rôle !",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"❌ Rôle introuvable ! Contactez un administrateur.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@bot.tree.command(name="couple", description="Officialiser une relation")
|
||||
async def couple(interaction: discord.Interaction, partenaire: discord.Member):
|
||||
membre = interaction.user
|
||||
|
||||
# Validations
|
||||
if membre.id == partenaire.id:
|
||||
await interaction.response.send_message(
|
||||
"❌ Impossible de former un couple avec soi-même !",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Met à jour les infos
|
||||
await update_member(membre)
|
||||
await update_member(partenaire)
|
||||
|
||||
if family.add_couple(str(membre.id), str(partenaire.id)):
|
||||
await interaction.response.send_message(
|
||||
f"💑 {membre.mention} et {partenaire.mention} sont maintenant partenaires !"
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"❌ Ces membres sont déjà en couple !",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@bot.tree.command(name="renier", description="Renier un enfant")
|
||||
async def renier(interaction: discord.Interaction, enfant: discord.Member):
|
||||
parent = interaction.user
|
||||
success = family.remove_child(str(parent.id), str(enfant.id))
|
||||
|
||||
if success:
|
||||
# Retire le rôle
|
||||
generation = family.get_generation(str(enfant.id))
|
||||
role_id = config["ROLE_IDS"].get(
|
||||
"ENFANT" if generation == 1 else
|
||||
"PETIT_ENFANT" if generation == 2 else
|
||||
"ARRIERE_PETIT_ENFANT"
|
||||
)
|
||||
|
||||
if role_id:
|
||||
role = interaction.guild.get_role(role_id)
|
||||
if role:
|
||||
try:
|
||||
await enfant.remove_roles(role)
|
||||
except discord.Forbidden:
|
||||
pass # On continue même si échec du retrait de rôle
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"❌ {parent.mention} a renié {enfant.mention} !"
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"❌ Aucun lien parental trouvé.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@bot.tree.command(name="arbre", description="Afficher l'arbre généalogique")
|
||||
async def arbre(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
# Télécharge les avatars des membres
|
||||
members = family.get_all_members()
|
||||
for member_id in members:
|
||||
try:
|
||||
member = await interaction.guild.fetch_member(int(member_id))
|
||||
await download_avatar(member)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Génère l'arbre
|
||||
generate_tree("family.json", "arbre.png", config["ROOT_MEMBER_ID"])
|
||||
|
||||
# Envoie le résultat
|
||||
try:
|
||||
await interaction.followup.send(file=discord.File("arbre.png"))
|
||||
except:
|
||||
await interaction.followup.send(
|
||||
"❌ Erreur lors de la génération de l'arbre",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@bot.tree.command(name="parent", description="Voir les parents d'un membre")
|
||||
async def parent(interaction: discord.Interaction, membre: Optional[discord.Member] = None):
|
||||
target = membre or interaction.user
|
||||
parents = family.get_parents(str(target.id))
|
||||
|
||||
if not parents:
|
||||
await interaction.response.send_message(
|
||||
f"ℹ️ {target.display_name} n'a pas de parent enregistré.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
parent_mentions = [f"<@{p['id']}>" for p in parents]
|
||||
await interaction.response.send_message(
|
||||
f"👪 Parents de {target.mention} : {' '.join(parent_mentions)}"
|
||||
)
|
||||
|
||||
@bot.command()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def init(ctx):
|
||||
"""Initialise tous les membres du serveur (admin seulement)"""
|
||||
count = 0
|
||||
for member in ctx.guild.members:
|
||||
await update_member(member)
|
||||
count += 1
|
||||
|
||||
await ctx.send(f"✅ {count} membres initialisés !")
|
||||
|
||||
bot.run(TOKEN)
|
8
config.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"ROOT_MEMBER_ID": "1111230921954304000",
|
||||
"ROLE_IDS": {
|
||||
"ENFANT": "1336247473718558773",
|
||||
"PETIT_ENFANT": "1336835041170292941",
|
||||
"ARRIERE_PETIT_ENFANT": "1354256104519110748"
|
||||
}
|
||||
}
|
81
family.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"members": {
|
||||
"1111230921954304000": {
|
||||
"name": "NYM N'EST PAS UN CODE BARRE",
|
||||
"parents": [],
|
||||
"avatar": "https://cdn.discordapp.com/avatars/1111230921954304000/4be28a9b89433961805e84b98c2ddb72.png?size=1024"
|
||||
},
|
||||
"583588548431708162": {
|
||||
"name": "gruy\u00e8re de l'esprit",
|
||||
"parents": [
|
||||
{
|
||||
"id": "1111230921954304000",
|
||||
"name": "Nymnym"
|
||||
}
|
||||
]
|
||||
},
|
||||
"901473267104223232": {
|
||||
"name": "Gaka",
|
||||
"parents": []
|
||||
},
|
||||
"436978132105560064": {
|
||||
"name": "Uzurka",
|
||||
"parents": [
|
||||
{
|
||||
"id": "583588548431708162",
|
||||
"name": "gruy\u00e8re de l'esprit"
|
||||
},
|
||||
{
|
||||
"id": "901473267104223232",
|
||||
"name": "Partenaire"
|
||||
}
|
||||
]
|
||||
},
|
||||
"567371662165803009": {
|
||||
"name": "Je suis g\u00e9niale.ment on fire",
|
||||
"avatar": "https://cdn.discordapp.com/avatars/567371662165803009/3e30d235ef87f77efe67df4ce13eff47.png?size=1024",
|
||||
"parents": [
|
||||
{
|
||||
"id": "1111230921954304000",
|
||||
"name": "NYM N'EST PAS UN CODE BARRE"
|
||||
}
|
||||
]
|
||||
},
|
||||
"506016482300395520": {
|
||||
"name": ": \u0317\u0300\u279b\u275dDaddysseus\u275e\u2727 \u0cc3\u0f04 zzz",
|
||||
"avatar": "https://cdn.discordapp.com/avatars/506016482300395520/df6f4a8220465743c45f437d2d656e8a.png?size=1024",
|
||||
"parents": [
|
||||
{
|
||||
"id": "1111230921954304000",
|
||||
"name": "NYM N'EST PAS UN CODE BARRE"
|
||||
}
|
||||
]
|
||||
},
|
||||
"700651700477886504": {
|
||||
"name": "Dada serveur favorite",
|
||||
"avatar": "https://cdn.discordapp.com/avatars/700651700477886504/6294d31329de1c1ecbf1bd3cdaa8deb9.png?size=1024",
|
||||
"parents": [
|
||||
{
|
||||
"id": "1111230921954304000",
|
||||
"name": "NYM N'EST PAS UN CODE BARRE"
|
||||
}
|
||||
]
|
||||
},
|
||||
"769178722838118400": {
|
||||
"name": "\u26e7\u263e\u0f3a Abysseus \u0f3b\u263d\u26e7",
|
||||
"avatar": "https://cdn.discordapp.com/avatars/769178722838118400/f54364f4736f7537370cf768779a866a.png?size=1024",
|
||||
"parents": [
|
||||
{
|
||||
"id": "1111230921954304000",
|
||||
"name": "NYM N'EST PAS UN CODE BARRE"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"couples": [
|
||||
[
|
||||
"583588548431708162",
|
||||
"901473267104223232"
|
||||
]
|
||||
]
|
||||
}
|
162
family.py
Normal file
@ -0,0 +1,162 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
class FamilyManager:
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = file_path
|
||||
if not os.path.exists(file_path):
|
||||
self._init_empty_file()
|
||||
|
||||
def _init_empty_file(self):
|
||||
"""Initialise un fichier JSON vide avec la structure attendue"""
|
||||
with open(self.file_path, "w") as f:
|
||||
json.dump({
|
||||
"members": {},
|
||||
"couples": []
|
||||
}, f, indent=2)
|
||||
|
||||
def _load(self) -> Dict:
|
||||
"""Charge les données depuis le fichier JSON"""
|
||||
with open(self.file_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _save(self, data: Dict):
|
||||
"""Sauvegarde les données dans le fichier JSON"""
|
||||
with open(self.file_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def add_member(self, member_id: str, member_name: str, avatar_url: str = None):
|
||||
"""
|
||||
Ajoute ou met à jour un membre
|
||||
:param member_id: ID Discord du membre
|
||||
:param member_name: Nom d'affichage
|
||||
:param avatar_url: URL de l'avatar (optionnel)
|
||||
"""
|
||||
data = self._load()
|
||||
|
||||
if str(member_id) not in data["members"]:
|
||||
data["members"][str(member_id)] = {
|
||||
"name": member_name,
|
||||
"avatar": avatar_url,
|
||||
"parents": []
|
||||
}
|
||||
else:
|
||||
# Mise à jour des informations existantes
|
||||
data["members"][str(member_id)]["name"] = member_name
|
||||
if avatar_url:
|
||||
data["members"][str(member_id)]["avatar"] = avatar_url
|
||||
|
||||
self._save(data)
|
||||
|
||||
def add_child(self, parent_id: str, child_id: str):
|
||||
"""
|
||||
Ajoute un lien parent-enfant
|
||||
:param parent_id: ID du parent
|
||||
:param child_id: ID de l'enfant
|
||||
"""
|
||||
data = self._load()
|
||||
|
||||
# Vérifie que les membres existent
|
||||
if str(child_id) not in data["members"] or str(parent_id) not in data["members"]:
|
||||
return False
|
||||
|
||||
# Ajoute le parent principal
|
||||
parent_name = data["members"][str(parent_id)]["name"]
|
||||
data["members"][str(child_id)]["parents"].append({
|
||||
"id": str(parent_id),
|
||||
"name": parent_name
|
||||
})
|
||||
|
||||
# Ajoute le partenaire comme parent secondaire si existe
|
||||
partner_id = self.get_partner(parent_id)
|
||||
if partner_id:
|
||||
partner_name = data["members"].get(partner_id, {}).get("name", "Partenaire")
|
||||
data["members"][str(child_id)]["parents"].append({
|
||||
"id": partner_id,
|
||||
"name": partner_name
|
||||
})
|
||||
|
||||
self._save(data)
|
||||
return True
|
||||
|
||||
def add_couple(self, member1_id: str, member2_id: str) -> bool:
|
||||
"""
|
||||
Crée un lien de couple entre deux membres
|
||||
:return: True si le couple a été ajouté, False s'il existait déjà
|
||||
"""
|
||||
data = self._load()
|
||||
couple = sorted([str(member1_id), str(member2_id)])
|
||||
|
||||
if couple not in data["couples"]:
|
||||
data["couples"].append(couple)
|
||||
self._save(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_partner(self, member_id: str) -> Optional[str]:
|
||||
"""Trouve l'ID du partenaire d'un membre"""
|
||||
data = self._load()
|
||||
for couple in data["couples"]:
|
||||
if str(member_id) in couple:
|
||||
return couple[0] if couple[1] == str(member_id) else couple[1]
|
||||
return None
|
||||
|
||||
def remove_child(self, parent_id: str, child_id: str) -> bool:
|
||||
"""
|
||||
Supprime un lien parent-enfant
|
||||
:return: True si suppression effectuée, False si lien introuvable
|
||||
"""
|
||||
data = self._load()
|
||||
changed = False
|
||||
|
||||
if str(child_id) in data["members"]:
|
||||
original_count = len(data["members"][str(child_id)]["parents"])
|
||||
data["members"][str(child_id)]["parents"] = [
|
||||
p for p in data["members"][str(child_id)]["parents"]
|
||||
if p["id"] != str(parent_id)
|
||||
]
|
||||
changed = len(data["members"][str(child_id)]["parents"]) < original_count
|
||||
|
||||
if changed:
|
||||
self._save(data)
|
||||
return changed
|
||||
|
||||
def get_member(self, member_id: str) -> Optional[Dict]:
|
||||
"""Récupère les informations d'un membre"""
|
||||
data = self._load()
|
||||
return data["members"].get(str(member_id))
|
||||
|
||||
def get_parents(self, member_id: str) -> List[Dict]:
|
||||
"""Liste les parents d'un membre"""
|
||||
data = self._load()
|
||||
return data["members"].get(str(member_id), {}).get("parents", [])
|
||||
|
||||
def get_children(self, member_id: str) -> List[str]:
|
||||
"""Liste les enfants d'un membre"""
|
||||
data = self._load()
|
||||
return [
|
||||
user_id for user_id, info in data["members"].items()
|
||||
if any(p["id"] == str(member_id) for p in info.get("parents", []))
|
||||
]
|
||||
|
||||
def get_generation(self, member_id: str) -> int:
|
||||
"""
|
||||
Calcule la génération d'un membre
|
||||
:return: 0 pour la racine, 1 pour ses enfants, etc.
|
||||
"""
|
||||
data = self._load()
|
||||
generation = 0
|
||||
current_id = str(member_id)
|
||||
|
||||
while current_id in data["members"] and data["members"][current_id].get("parents"):
|
||||
current_id = data["members"][current_id]["parents"][0]["id"]
|
||||
generation += 1
|
||||
if generation > 10: # Sécurité contre les boucles infinies
|
||||
break
|
||||
return generation
|
||||
|
||||
def get_all_members(self) -> List[str]:
|
||||
"""Liste tous les IDs des membres enregistrés"""
|
||||
data = self._load()
|
||||
return list(data["members"].keys())
|
144
tree.py
Normal file
@ -0,0 +1,144 @@
|
||||
import pygraphviz as pgv
|
||||
import json
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
def create_avatar_with_name(user_id, name, avatar_path, output_path):
|
||||
"""Crée une image combinant avatar rond et pseudo en dessous"""
|
||||
try:
|
||||
# Charge l'avatar original
|
||||
avatar = Image.open(avatar_path).convert("RGBA")
|
||||
|
||||
# Redimensionne et arrondit l'avatar
|
||||
avatar = avatar.resize((150, 150))
|
||||
mask = Image.new('L', (150, 150), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.ellipse((0, 0, 150, 150), fill=255)
|
||||
rounded_avatar = ImageOps.fit(avatar, mask.size, centering=(0.5, 0.5))
|
||||
rounded_avatar.putalpha(mask)
|
||||
|
||||
# Crée une nouvelle image avec espace pour le texte
|
||||
combined = Image.new('RGBA', (200, 200), (0, 0, 0, 0))
|
||||
combined.paste(rounded_avatar, (25, 0), rounded_avatar)
|
||||
|
||||
# Ajoute le texte
|
||||
draw = ImageDraw.Draw(combined)
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 14)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text_width = draw.textlength(name, font=font)
|
||||
draw.text(((200 - text_width) / 2, 160), name, font=font, fill="white")
|
||||
|
||||
combined.save(output_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Erreur création avatar+texte {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def generate_tree(input_file, output_file, root_id):
|
||||
with open(input_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Configuration du graphe
|
||||
G = pgv.AGraph(
|
||||
directed=True,
|
||||
rankdir="TB",
|
||||
nodesep="0.8",
|
||||
ranksep="1.5",
|
||||
bgcolor="#000000",
|
||||
splines="true"
|
||||
)
|
||||
|
||||
# Style minimal pour les nœuds
|
||||
node_style = {
|
||||
"shape": "none",
|
||||
"imagescale": "false",
|
||||
"labelloc": "b",
|
||||
"width": "2",
|
||||
"height": "2",
|
||||
"fixedsize": "true"
|
||||
}
|
||||
|
||||
# Style des flèches
|
||||
edge_style = {
|
||||
"color": "#FFFFFF",
|
||||
"penwidth": "3",
|
||||
"arrowsize": "1.2"
|
||||
}
|
||||
|
||||
# Dossier temporaire pour les images combinées
|
||||
os.makedirs("temp_avatars", exist_ok=True)
|
||||
|
||||
# Traitement de tous les membres
|
||||
for user_id, info in data["members"].items():
|
||||
original_avatar = f"avatars/{user_id}.png"
|
||||
combined_avatar = f"temp_avatars/combined_{user_id}.png"
|
||||
|
||||
if os.path.exists(original_avatar):
|
||||
create_avatar_with_name(
|
||||
user_id,
|
||||
info["name"],
|
||||
original_avatar,
|
||||
combined_avatar
|
||||
)
|
||||
G.add_node(
|
||||
user_id,
|
||||
image=combined_avatar,
|
||||
**node_style
|
||||
)
|
||||
else:
|
||||
# Fallback si pas d'avatar
|
||||
G.add_node(
|
||||
user_id,
|
||||
label=info["name"],
|
||||
**{**node_style, "fontcolor": "white"}
|
||||
)
|
||||
|
||||
# Organisation hiérarchique
|
||||
levels = {0: [root_id], 1: [], 2: []}
|
||||
|
||||
for user_id, info in data["members"].items():
|
||||
if user_id == root_id:
|
||||
continue
|
||||
|
||||
parents = [p["id"] for p in info.get("parents", [])]
|
||||
if root_id in parents:
|
||||
levels[1].append(user_id)
|
||||
elif any(p in levels[1] for p in parents):
|
||||
levels[2].append(user_id)
|
||||
|
||||
# Ajout des partenaires au bon niveau
|
||||
for couple in data.get("couples", []):
|
||||
if all(m in data["members"] for m in couple):
|
||||
for member in couple:
|
||||
if member not in levels[1] and any(m in levels[1] for m in couple):
|
||||
levels[1].append(member)
|
||||
|
||||
# Création des sous-graphes par niveau
|
||||
for level, members in levels.items():
|
||||
if members:
|
||||
G.add_subgraph(members, name=f"rank{level}", rank="same")
|
||||
|
||||
# Connexions parent-enfant
|
||||
for user_id, info in data["members"].items():
|
||||
for parent in info.get("parents", []):
|
||||
if parent["id"] in data["members"]:
|
||||
G.add_edge(parent["id"], user_id, **edge_style)
|
||||
|
||||
# Connexions des couples
|
||||
for couple in data.get("couples", []):
|
||||
if all(m in data["members"] for m in couple):
|
||||
G.add_edge(couple[0], couple[1],
|
||||
style="dashed",
|
||||
color="#FF69B4",
|
||||
penwidth="2.5",
|
||||
dir="none")
|
||||
|
||||
G.layout(prog="dot")
|
||||
G.draw(output_file)
|
||||
|
||||
# Nettoyage des fichiers temporaires
|
||||
for f in os.listdir("temp_avatars"):
|
||||
os.remove(f"temp_avatars/{f}")
|