Compare commits

...

3 commits

5 changed files with 398 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*.pyc
*.swp
*.swo
*~
*#
venv

4
choristes-example.csv Normal file
View file

@ -0,0 +1,4 @@
Nom,Prénom
Dupond,Jean Eudes
Dupont,Jean Amaury
Duponx,Jeanne Michèle
1 Nom Prénom
2 Dupond Jean Eudes
3 Dupont Jean Amaury
4 Duponx Jeanne Michèle

287
repartir_taches.py Normal file
View file

@ -0,0 +1,287 @@
import yaml
import argparse
import csv
from collections import defaultdict
import typing as t
from dataclasses import dataclass
import random
@dataclass
class Task:
name: str
qualified_name: str
descr: str
notes: str
time: str
nb_groups: int
assigned: t.Optional[list[int]] = None
class Category(t.NamedTuple):
name: str
depth: int
time: str
tasks: list # of Category|Task, but mypy doesn't support recursive types
intro: str
def levenshtein_distance(s1, s2):
"""Shamelessly stolen from https://stackoverflow.com/a/32558749"""
if len(s1) > len(s2):
s1, s2 = s2, s1
distances = range(len(s1) + 1)
for i2, c2 in enumerate(s2):
distances_ = [i2 + 1]
for i1, c1 in enumerate(s1):
if c1 == c2:
distances_.append(distances[i1])
else:
distances_.append(
1 + min((distances[i1], distances[i1 + 1], distances_[-1]))
)
distances = distances_
return distances[-1]
class UnionFind:
parent_of: list[int]
_group_size: list[int]
def __init__(self, elt_count: int):
self.parent_of = list(range(elt_count))
self._group_size = [1] * elt_count
def root(self, elt: int) -> int:
if self.parent_of[elt] == elt:
return elt
self.parent_of[elt] = self.root(self.parent_of[elt])
return self.parent_of[elt]
def union(self, elt1: int, elt2: int) -> None:
elt1 = self.root(elt1)
elt2 = self.root(elt2)
if elt1 == elt2:
return
if self._group_size[elt1] > self._group_size[elt2]:
self.union(elt2, elt1)
else:
self._group_size[elt2] += self._group_size[elt1]
self._group_size[elt1] = 0
self.parent_of[self.root(elt1)] = self.root(elt2)
def group_size(self, elt: int) -> int:
return self._group_size[self.root(elt)]
class Config:
tasks_path: str
people_path: str
choristes: list[str]
ca: list[str]
taches: Category
env: dict[str, str]
def __init__(self, tasks_path: str, people_path: str):
self.tasks_path = tasks_path
self.people_path = people_path
self.choristes = []
self.ca = []
self.env = {}
self._load_tasks()
self._load_people()
def _load_tasks(self) -> None:
with open(self.tasks_path, "r") as h:
raw_tasks = yaml.safe_load(h)
assert "taches" in raw_tasks
self.env = raw_tasks["env"]
self.taches = Category(
name="",
depth=0,
time="",
intro="",
tasks=list(map(self._load_task_cat, raw_tasks["taches"])),
)
self.ca = raw_tasks["CA"]
def _load_task_cat(
self, cat: dict[str, t.Any], depth: int = 1, qual: str = ""
) -> Task | Category:
if "cat" not in cat:
return self._load_task(cat, qual)
assert "taches" in cat
nqual = cat["cat"]
if qual:
nqual = f"{qual} - {nqual}"
return Category(
name=cat["cat"],
depth=depth,
time=cat.get("heure", ""),
intro=cat.get("intro", ""),
tasks=list(
map(
lambda x: self._load_task_cat(x, depth=depth + 1, qual=nqual),
cat["taches"],
)
),
)
def _load_task(self, task: dict[str, t.Any], qual: str) -> Task:
for label in ("nom", "descr"):
assert label in task
qual_name = f'{qual}{" - " if qual else ""}{task["nom"]}'
return Task(
name=task["nom"],
qualified_name=qual_name,
descr=task["descr"].format(**self.env),
notes=task.get("notes", ""),
time=task.get("heure", ""),
nb_groups=int(task.get("nb_groups", 1)),
)
def _load_people(self) -> None:
with open(self.people_path, "r") as h:
raw_people: list[dict[str, str]] = list(csv.DictReader(h))
for key in "Nom", "Prénom":
assert key in raw_people[0]
raw_people.sort(key=lambda x: x["Prénom"])
# Normalize
def normalize(x: str) -> str:
x = x.strip()
if " " in x:
return " ".join(map(normalize, x.split()))
return x[0].upper() + x[1:].lower()
for pers in raw_people:
pers["Nom"] = normalize(pers["Nom"])
pers["Prénom"] = normalize(pers["Prénom"])
# Group by name proximity
name_uf = UnionFind(len(raw_people))
for id1, pers1 in enumerate(raw_people):
for id2, pers2 in enumerate(raw_people):
if (
id1 < id2
and levenshtein_distance(pers1["Prénom"], pers2["Prénom"]) <= 2
):
name_uf.union(id1, id2)
_name_groups: dict[int, list[dict]] = defaultdict(list)
for pers_id, pers in enumerate(raw_people):
_name_groups[name_uf.root(pers_id)].append(pers)
name_groups: list[list[dict]] = list(_name_groups.values())
# Disambiguate names
def make_short_name(pers: dict, disamb: int = 0) -> str:
if disamb:
return f"{pers['Prénom']} {pers['Nom'][:disamb]}."
else:
return pers["Prénom"]
self.choristes = []
for grp in name_groups:
if len(grp) == 1:
self.choristes.append(make_short_name(grp[0]))
else:
req_letters = 1
while req_letters < 100: # safeguard
short_names = list(
map(lambda x: make_short_name(x, req_letters), grp)
)
if len(set(short_names)) == len(short_names):
# No clashes
self.choristes += short_names
break
req_letters += 1
self.choristes.sort()
def constituer_groupes(choristes: list[str]) -> list[list[str]]:
"""Répartir aléatoirement les gens en groupes"""
TAILLE_GROUPE: int = 4
nb_choristes = len(choristes)
groupes: list[list[str]] = []
random.shuffle(choristes)
pos = 0
for _ in range(nb_choristes // TAILLE_GROUPE):
groupes.append(choristes[pos : pos + TAILLE_GROUPE])
pos += TAILLE_GROUPE
reste = choristes[pos:]
if len(reste) == TAILLE_GROUPE - 1:
groupes.append(reste)
else:
for gid, pers in enumerate(reste):
groupes[gid].append(pers)
for groupe in groupes:
groupe.sort()
random.shuffle(groupes)
return groupes
def assigner_taches(task: Category | Task, group_count: int, cur_group: int = 0) -> int:
"""Assigne les tâches aux groupes (round-robin)"""
if isinstance(task, Task):
task.assigned = list(
map(
lambda x: x % group_count,
range(cur_group, cur_group + task.nb_groups),
)
)
return (cur_group + task.nb_groups) % group_count
for subtask in task.tasks:
cur_group = assigner_taches(subtask, group_count, cur_group)
return cur_group
def export_short(config: Config, groupes: list[list[str]]) -> str:
"""Exporte la liste des tâches au format court (pour vérification)"""
def export_taskcat(grp: Task | Category) -> str:
if isinstance(grp, Task):
return f'* {grp.qualified_name}: {", ".join(map(lambda x: str(x+1), grp.assigned))}'
out = "\n" + "#" * (2 + grp.depth) + f" {grp.name}"
if grp.time:
out += f" ({grp.time})"
out += "\n\n"
if grp.intro:
out += grp.intro + "\n\n"
out += "\n".join(map(export_taskcat, grp.tasks))
return out
out = "## Groupes\n\n"
for g_id, group in enumerate(groupes):
out += f"* Groupe {g_id+1} : " + ", ".join(group) + "\n"
out += "\n## Tâches\n"
out += "\n".join(map(export_taskcat, config.taches.tasks))
return out
def export_latex(config: Config, groupes: list[list[str]]) -> str:
"""Exporter la liste des tâches en LaTeX (à insérer dans un template)"""
def main():
parser = argparse.ArgumentParser("Répartition des tâches")
parser.add_argument("taches", help="Fichier yaml contenant les tâches")
parser.add_argument("choristes", help="Fichier CSV contenant les choristes")
args = parser.parse_args()
config = Config(args.taches, args.choristes)
if __name__ == "__main__":
main()

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
ruamel.yaml
Jinja2

99
taches.yml Normal file
View file

@ -0,0 +1,99 @@
---
env:
nbChoristes: 90
nbChoristesDiner: 60
CA:
- Charlotte
- Cécile
- Théo
- Gauthier
- Aurore
- Anaïs D.
taches:
- cat: Samedi
taches:
- nom: Nettoyer tables
heure: 12:50
descr: "Nettoyer les tables (miettes, coup d'éponge), jeter les détritus, etc."
- nom: Préparer salle
heure: 12:55
descr: "Empiler les tables au fond, mettre en place {nbChoristes} chaises : 4 sections en demi-lune complète devant le clavier. Brancher rallonge et enceintes, installer la salle."
- cat: Goûter
heure: '16:00'
taches:
- nom: Service
descr: "Sous la direction du CA, sortir goûter de la réserve, préparer thé & café, les disposer sur le comptoir et quelques tables dans la grande salle du gîte, faire le service, …"
- nom: Ranger
descr: "Débarrasser et ranger les restes du goûter, enlever les miettes (sans en mettre partout !) et passer un coup d'éponge, balayer et ranger le matériel de nettoyage."
- cat: Dîner
heure: '20:00'
taches:
- nom: Installer tables
descr: "Arranger tables & chaises pour {nbChoristesDiner} personnes"
- nom: Mettre la table
descr: "Mettre la table — vaisselle, couverts, etc."
- nom: Service
descr: "Servir l'apéro, le repas, le dessert"
- nom: Débarrasser
descr: "Débarrasser les tables après le dîner"
- nom: Nettoyer
descr: "Nettoyer les tables (miettes, coup d'éponge) et balayer rapidement"
- nom: Vider le compost
descr: "Vider le compost : soulever à deux le sac plein par en dessous, passer par porte de la petite « cuisine plonge » (vaisselle), vider le sac dans les grands bacs en bois à 5 mètres à gauche en sortant (pendant que 1 personne ouvre les portes/le bac & éclaire)."
- nom: Plonge
descr: "Faire toute la vaisselle et la ranger (utiliser le lave vaisselle !)"
nb_groupes: 2
- cat: Dimanche
taches:
- cat: Petit déjeuner
heure: '8:00'
taches:
- nom: Installer
descr: "Préparation du thé, café, arranger quelques tables, sortir la nourriture"
- nom: Réveiller
heure: 8:20
descr: "Aller dans **tous** les dortoirs (toutes les chambres du 1er étage, *dortoir sous-sol*, *bâtiment annexe en face*), ouvrir *toutes* les portes, faire le tour des lits & annoncer **de vive voix** la fin du petit dej & le début des festivités dans **une demi-heure** pour les gros dormeurs ! (demandez *samedi* à un·e ancien·ne si vous ne connaissez pas les lieux)"
- nom: Débarrasser
descr: "Débarrasser les tables, ranger la nourriture"
- nom: Nettoyer
descr: "Nettoyer les tables (miettes, coup d'éponge), jeter les détritus"
- nom: Vaisselle
descr: "Faire la vaisselle, lancer le lave-vaisselle et filer en répet !"
- nom: Installer répet
heure: '8:40'
descr: "Ranger tables & chaises, installer et brancher le clavier"
- cat: "Déjeuner"
taches:
- nom: Arranger buffet
descr: "Installer des tables salé/sucré pour mettre les plats partagés, installer les plats, vérifier qu'ils sont étiquetés"
- nom: Installer vaisselle
descr: "Sortir la vaisselle propre et les couverts, tout mettre sur les tables du buffet"
- nom: Installer tables
descr: "Arranger tables & chaises pour {nbChoristes} personnes"
- nom: Débarrasser
descr: "Débarrasser les tables, mettre de côté la nourriture entamée"
- nom: Gérer les restes
descr: "Découper tous les restes en portions, annoncer **de vive voix** aux choristes d'apporter leurs tupperwares, distribuer tous les restes (de force s'il le faut). *Tout doit partir* !"
- nom: Nettoyer
descr: "Nettoyer les tables (miettes, coup d'éponge), jeter les détritus"
- nom: Vaisselle
descr: "Faire la vaisselle (utiliser le lave-vaisselle !) et la ranger. Rendre aux choristes les plats ou contenants perso"
- nom: Vider le compost
descr: "Vider le compost : soulever à deux le sac plein par en dessous, passer par porte de la petite « cuisine plonge » (vaisselle), vider le sac dans les grands bacs en bois à 5 mètres à gauche en sortant (pendant que 1 personne ouvre les portes/le bac"
- cat: Grand rangement
intro: "**Chacun·e est responsable du rangement et des espaces annexes de **sa** chambre !"
taches:
- nom: Nettoyer salles de bain étage
descr: Nettoyer les salles de bain du 1er étage du gîte principal
notes: "Remplir les seaux de produit dans la salle plonge"
- nom: Nettoyer salles de bain rdc
descr: Nettoyer les salles de bain du rez-de-chaussée du gîte principal
notes: "Remplir les seaux de produit dans la salle plonge"
- nom: Serpillère rdc
descr: "Passer la serpillère au rez-de-chaussée du gîte principal (ne pas hésiter à changer *souvent* l'eau du seau quand elle est sale)"
notes: "Remplir les seaux de produit dans la salle plonge"
- nom: Serpillère école
descr: "Passer la serpillère dans la salle des fêtes annexe (ne pas hésiter à changer *souvent* l'eau du seau quand elle est sale). L'annexe est en face du gîte, dans la montée : dernière porte sur la droite du bâtiment, après la table pique-nique."
notes: "Remplir les seaux de produit dans la salle plonge"
- nom: Meubles RdC
descr: "Remettre les meubles du rez-de-chaussée du gîte principal en place (y compris tous les bancs !)"