ezCOS 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ezCOS/__init__.py ADDED
File without changes
ezCOS/ezCOS.py ADDED
@@ -0,0 +1,676 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # ezCOS.py -
4
+ #
5
+ # Authors:
6
+ # Géry Casiez https://gery.casiez.net
7
+ #
8
+ # 2026
9
+ #
10
+ # BSD License https://opensource.org/licenses/BSD-3-Clause
11
+ #
12
+ # Redistribution and use in source and binary forms, with or without
13
+ # modification, are permitted provided that the following conditions are met:
14
+ #
15
+ # 1. Redistributions of source code must retain the above copyright notice, this list of conditions
16
+ # and the following disclaimer.
17
+ #
18
+ # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions
19
+ # and the following disclaimer in the documentation and/or other materials provided with the distribution.
20
+ #
21
+ # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or
22
+ # promote products derived from this software without specific prior written permission.
23
+
24
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
25
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
27
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
29
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+
33
+ import argparse
34
+ import configparser
35
+ from datetime import datetime
36
+ import os
37
+ from openpyxl import load_workbook
38
+ from openpyxl.worksheet.datavalidation import DataValidation
39
+ from openpyxl.utils import quote_sheetname
40
+ from odysseeapi.OdysseeCOS import OdysseeCOS
41
+ import zipfile
42
+ import pandas as pd
43
+ import sys
44
+ from tqdm import tqdm
45
+ from unidecode import unidecode
46
+ import tqdm
47
+
48
+ from .utils.tools import getColumnInfo, populateSheet, computeAge, loadJson, saveJson, createDir, getTextFromPDF, extractInfo, formatPrenom, formatLieu, formattxt, cleantext, thunderbird, trAvis, extractInfoRapport
49
+
50
+
51
+ def populateXLSX(template_dir, template_file, odyssee, candidates_folder):
52
+ wb = load_workbook(filename=f"{template_dir}/{template_file}", data_only=False)
53
+ ws = wb["MembresComite"]
54
+
55
+ # Membres du comité de sélection
56
+ membres = odyssee.get_committee_members()
57
+ etablissements = odyssee.get_institutions()
58
+
59
+ mapping = {
60
+ "idKeycloak": {"column": "ID"},
61
+ "nomUsage": {"column": "Nom"},
62
+ "prenom": {"column": "Prénom", "transformation": formatPrenom},
63
+ "civilite": {"column": "H/F", "transformation": lambda x: "H" if x == 1 else "F" },
64
+ "rang": {"column": "Corps"},
65
+ "etablissement": {"column": "Etablissement", "transformation": lambda x: next((formatLieu(e['libelle']) for e in etablissements if e['id'] == x), "") },
66
+ }
67
+ populateSheet(ws, membres, mapping)
68
+
69
+ # Candidats
70
+ candidats = odyssee.get_candidates()
71
+ candidatsDetailed = odyssee.get_candidates_with_details()
72
+
73
+ candidates_infos_fromPDF = getCandidatesInfos(candidates_folder)
74
+
75
+ # Ajout de l'ID dans les détails des candidats pour faciliter le mapping
76
+ for c in candidatsDetailed:
77
+ # Ajout de l'ID Keycloak dans les détails des candidats pour faciliter le mapping
78
+ idKeycloak = next((cand['candidat']['idKeycloakActuel'] for cand in candidats if cand['candidat']['nomUsage'] == c['informationsGeneralesCandidatureDto']['nomUsage'] and cand['candidat']['prenom'] == c['informationsGeneralesCandidatureDto']['prenom']), None)
79
+ c['informationsGeneralesCandidatureDto']['idKeycloak'] = idKeycloak
80
+
81
+ # Lieu de soutenance
82
+ lieu = c['candidatureThese']['lieuSoutenanceEtablissement']
83
+ if lieu is None:
84
+ lieu = c['candidatureThese']['lieuSoutenanceAutre']
85
+ else:
86
+ lieu = next((formatLieu(e['libelle']) for e in etablissements if e['id'] == lieu), "")
87
+ c['candidatureThese']['lieuSoutenance'] = lieu
88
+
89
+ # Direction de thèse
90
+ c['candidatureThese']['directeurThese'] = f"{formatPrenom(c['candidatureThese']['directeur']['prenom'])} {c['candidatureThese']['directeur']['nom'].upper()}"
91
+
92
+ # Jury de thèse
93
+ membresJury = []
94
+ if c['candidatureJuryThese']['president'] is not None:
95
+ membresJury.append(f"{formatPrenom(c['candidatureJuryThese']['president']['prenom'])} {c['candidatureJuryThese']['president']['nom'].upper()} (Président)")
96
+ for membre in c['candidatureJuryThese']['membres']:
97
+ membresJury.append(f"{formatPrenom(membre['prenom'])} {membre['nom'].upper()}")
98
+ c['candidatureThese']['juryThese'] = ", ".join(membresJury)
99
+
100
+ # Mots clés recherche
101
+ keywords = odyssee.get_keywords()
102
+ motsCles = []
103
+ for mc in c['candidatureBlocMotCle']['motsCles']:
104
+ if mc['motCleLibre'] is not None:
105
+ motsCles.append(mc['motCleLibre'])
106
+ else:
107
+ mcle = next((k['libelle'] for k in keywords if k['id'] == mc['motCle']), "")
108
+ motsCles.append(mcle.split("- ")[1])
109
+ c['candidatureBlocMotCle']['motsCles'] = "; ".join(motsCles)
110
+
111
+ # Lieu d'exercice du candidat
112
+ idlieu = c['candidatureLieuRecherche']['etablissementLieuRecherche']
113
+ c['candidatureLieuRecherche']['etablissementLieuRecherche'] = next((formatLieu(e['libelle']) for e in etablissements if e['id'] == idlieu), "")
114
+
115
+ # Ajout des infos extraites des PDF
116
+ candidate_infos = next((ci for ci in candidates_infos_fromPDF if ci['id'] == c['informationsGeneralesCandidatureDto']['idKeycloak']), None)
117
+ if candidate_infos:
118
+ c['informationsGeneralesCandidatureDto']['nom'] = candidate_infos.get('Nom', None)
119
+ c['informationsGeneralesCandidatureDto']['email'] = candidate_infos.get('Email', None)
120
+ c['informationsGeneralesCandidatureDto']['dateNaissance'] = candidate_infos.get('Date de naissance', None)
121
+ c['informationsGeneralesCandidatureDto']['lieuNaissance'] = candidate_infos.get('Lieu de naissance', None)
122
+ c['informationsGeneralesCandidatureDto']['paysNationalite'] = candidate_infos.get('Pays de nationalité', None)
123
+ c['informationsGeneralesCandidatureDto']['telephone'] = candidate_infos.get('Téléphone', None)
124
+ c['informationsGeneralesCandidatureDto']['adressePostale'] = candidate_infos.get('Adresse postale', None)
125
+ c['informationsGeneralesCandidatureDto']['complementAdresse'] = candidate_infos.get('Adresse comp.', None)
126
+ c['informationsGeneralesCandidatureDto']['codePostal'] = candidate_infos.get('Code postal', None)
127
+ c['informationsGeneralesCandidatureDto']['ville'] = candidate_infos.get('Ville', None)
128
+ c['informationsGeneralesCandidatureDto']['pays'] = candidate_infos.get('Pays', None)
129
+
130
+ # Data validation Interne / Externe
131
+ columnsInfos = getColumnInfo(ws)
132
+ dvInterneExterne = DataValidation(type="list", formula1='"Interne,Externe"', allow_blank=True)
133
+ ws.add_data_validation(dvInterneExterne)
134
+ for row_index in range(2, ws.max_row + 1):
135
+ index_interne_externe = next((col['index'] for col in columnsInfos if col['column'] == "Interne / Externe"), None)
136
+ dvInterneExterne.add(ws.cell(row=row_index, column=index_interne_externe + 1))
137
+
138
+
139
+ ws = wb["Candidats"]
140
+ mapping = {
141
+ "informationsGeneralesCandidatureDto:idKeycloak": {"column": "ID"},
142
+ "informationsGeneralesCandidatureDto:nomUsage": {"column": "Nom d'usage", "transformation": lambda x: x.upper() if x else None},
143
+ "informationsGeneralesCandidatureDto:prenom": {"column": "Prénom", "transformation": formatPrenom},
144
+ "informationsGeneralesCandidatureDto:civilite": {"column": "Civilité", "transformation": lambda x: "M." if x == 1 else "Mme" },
145
+ "candidatureThese:titre": {"column": "Titre thèse"},
146
+ "candidatureThese:dateObtention": {"column": "Date soutenance", "transformation": lambda x: datetime.strptime(x, "%Y-%m-%d").strftime("%d/%m/%Y") if x else None},
147
+ "candidatureThese:lieuSoutenance": {"column": "Lieu soutenance"},
148
+ "candidatureThese:directeurThese": {"column": "Directeur Thèse"},
149
+ "candidatureThese:juryThese": {"column": "Jury"},
150
+ "candidatureBlocMotCle:description": {"column": "Description recherche"},
151
+ "candidatureBlocMotCle:motsCles": {"column": "Mots clés"},
152
+ "candidatureLieuRecherche:etablissementLieuRecherche": {"column": "Lieu d'exercice"},
153
+ "informationsGeneralesCandidatureDto:nom": {"column": "Nom"},
154
+ "informationsGeneralesCandidatureDto:email": {"column": "Email"},
155
+ "informationsGeneralesCandidatureDto:dateNaissance": {"column": "Date de naissance"},
156
+ "informationsGeneralesCandidatureDto:lieuNaissance": {"column": "Lieu de naissance"},
157
+ "informationsGeneralesCandidatureDto:paysNationalite": {"column": "Pays de nationalité"},
158
+ "informationsGeneralesCandidatureDto:telephone": {"column": "Téléphone"},
159
+ "informationsGeneralesCandidatureDto:adressePostale": {"column": "Adresse postale"},
160
+ "informationsGeneralesCandidatureDto:complementAdresse": {"column": "Adresse comp."},
161
+ "informationsGeneralesCandidatureDto:codePostal": {"column": "Code postal"},
162
+ "informationsGeneralesCandidatureDto:ville": {"column": "Ville"},
163
+ "informationsGeneralesCandidatureDto:pays": {"column": "Pays"}
164
+ }
165
+ populateSheet(ws, candidatsDetailed, mapping)
166
+
167
+ # Validation des données
168
+ columnsInfos = getColumnInfo(ws)
169
+ dvAvis = DataValidation(type="list", formula1='"Très favorable,Favorable,Réservé,Défavorable"', allow_blank=True)
170
+ ws.add_data_validation(dvAvis)
171
+
172
+ dvRapporteur = DataValidation(type="list", formula1=f"{quote_sheetname('MembresComite')}!$K$2:$K${len(membres)+1}", allow_blank=True)
173
+ ws.add_data_validation(dvRapporteur)
174
+
175
+ for row_index in range(2, ws.max_row + 1):
176
+ index_avis_rapp1 = next((col['index'] for col in columnsInfos if col['column'] == "AvisRapp1"), None)
177
+ dvAvis.add(ws.cell(row=row_index, column=index_avis_rapp1 + 1))
178
+ index_avis_rapp2 = next((col['index'] for col in columnsInfos if col['column'] == "AvisRapp2"), None)
179
+ dvAvis.add(ws.cell(row=row_index, column=index_avis_rapp2 + 1))
180
+
181
+ index_rapporteur1 = next((col['index'] for col in columnsInfos if col['column'] == "Rapporteur1"), None)
182
+ dvRapporteur.add(ws.cell(row=row_index, column=index_rapporteur1 + 1))
183
+ index_rapporteur2 = next((col['index'] for col in columnsInfos if col['column'] == "Rapporteur2"), None)
184
+ dvRapporteur.add(ws.cell(row=row_index, column=index_rapporteur2 + 1))
185
+
186
+ wb.save(f"comitePopulated.xlsx")
187
+
188
+ def unzip_applications(directory):
189
+ zip_files = [f for f in os.listdir(directory) if f.endswith('.zip')]
190
+
191
+ for zip_file in zip_files:
192
+ path_to_zip_file = os.path.join(directory, zip_file)
193
+ directory_to_extract_to = os.path.join(directory, os.path.splitext(zip_file)[0])
194
+
195
+ with zipfile.ZipFile(path_to_zip_file, 'r') as zip_ref:
196
+ zip_ref.extractall(directory_to_extract_to)
197
+
198
+ def extractInfos(infos, txt):
199
+ res = {}
200
+ for info in infos:
201
+ i = extractInfo(info['odyssee'], txt, info.get('stopWord', None))
202
+ if i and 'operation' in info:
203
+ i = info['operation'](i)
204
+ res[info['target']] = i
205
+ return res
206
+
207
+ def getPdfFiles(folder):
208
+ pdf_files = []
209
+ for root, _, files in os.walk(folder):
210
+ for file in files:
211
+ if file.startswith("Edition_Dossier") and file.endswith(".pdf"):
212
+ pdf_files.append(os.path.join(root, file))
213
+ return pdf_files
214
+
215
+ def getCandidatesInfos(folder):
216
+ candidates_infos = []
217
+ pdf_files = getPdfFiles(folder)
218
+ infos2extract = [
219
+ {"odyssee": "Nom de famille",
220
+ "target": "Nom",
221
+ "operation": (lambda x : x.upper())},
222
+ {"odyssee": "Adresse électronique",
223
+ "target": "Email"},
224
+ {"odyssee": "Date de naissance",
225
+ "target": "Date de naissance"},
226
+ {"odyssee": "Lieu de naissance",
227
+ "target": "Lieu de naissance"},
228
+ {"odyssee": "Pays de nationalité",
229
+ "target": "Pays de nationalité"},
230
+ {"odyssee": "Téléphone personnel",
231
+ "target": "Téléphone"},
232
+ {"odyssee": "Adresse postale",
233
+ "target": "Adresse postale"},
234
+ {"odyssee": "Complément d’adresse",
235
+ "target": "Adresse comp."},
236
+ {"odyssee": "Code postal",
237
+ "target": "Code postal"},
238
+ {"odyssee": "Ville",
239
+ "target": "Ville"},
240
+ {"odyssee": "Pays",
241
+ "target": "Pays"}
242
+ ]
243
+ for pdf_file in pdf_files:
244
+ txt = getTextFromPDF(pdf_file)
245
+ id = pdf_file.split("/")[-2].split("_")[-1]
246
+ infos = extractInfos(infos2extract, txt)
247
+ infos["id"] = id
248
+ candidates_infos.append(infos)
249
+ return candidates_infos
250
+
251
+ def stats(file):
252
+ df = pd.read_excel(file)
253
+ ages = []
254
+ f = 0
255
+ fav = []
256
+ indecis = []
257
+ defav = []
258
+
259
+ for _, r in df.iterrows():
260
+ ages.append(computeAge(r['Date de naissance']))
261
+ if r['Civilité'] == 'Mme':
262
+ f += 1
263
+ if str(r['AvisRapp1']) not in ['Défavorable', 'Réservé', 'Favorable', 'Très favorable', 'nan'] or \
264
+ str(r['AvisRapp2']) not in ['Défavorable', 'Réservé', 'Favorable', 'Très favorable', 'nan']:
265
+ print("Probleme avis sur %s %s %s"%(r["Nom d'usage"], r['AvisRapp1'], r['AvisRapp2']))
266
+ sys.exit(-1)
267
+ if r['AvisRapp1'] in ['Favorable', 'Très favorable'] and r['AvisRapp2'] in ['Favorable', 'Très favorable']:
268
+ fav.append("%s %s"%(r['Prénom'], r["Nom d'usage"]))
269
+ elif r['AvisRapp1'] in ['Défavorable', 'Réservé'] and r['AvisRapp2'] in ['Défavorable', 'Réservé']:
270
+ defav.append("%s %s"%(r['Prénom'], r["Nom d'usage"]))
271
+ else:
272
+ indecis.append("%s %s"%(r['Prénom'], r["Nom d'usage"]))
273
+
274
+ print("---------------------------")
275
+ print("Dossiers fav ou très favorables des 2 rapporteurs : %s"%len(fav))
276
+ print(fav)
277
+ print("Dossiers indécis ou manque un avis %s"%len(indecis))
278
+ print(indecis)
279
+ print("Dossiers defav / reservé des 2 rapporteurs : %s"%len(defav))
280
+ print(defav)
281
+
282
+ print("---------------------------")
283
+ print("%s dossiers recevables"%len(ages))
284
+ print("%s femmes (%2.1f%%), %s hommes (%2.1f%%)"%(f, f/len(ages)*100, len(ages)-f, (1-f/len(ages))*100))
285
+ print("age moyen : %2.1f min : %2.1f, max : %2.1f"%(sum(ages)/len(ages), min(ages), max(ages)))
286
+
287
+ def getMembres(file):
288
+ df = pd.read_excel(file, sheet_name="MembresComite")
289
+ membres = []
290
+
291
+ for _, r in df.iterrows():
292
+ m = {}
293
+ m['nom'] = r['Nom']
294
+ m['prenom'] = r['Prénom']
295
+ m['corps'] = r['Corps']
296
+ m['section'] = r['Section CNU ou organisme']
297
+ m['interne'] = r['Interne / Externe'] == 'Interne'
298
+ m['etablissement'] = r['Etablissement'] if not pd.isna(r['Etablissement']) else ""
299
+ if str(m['nom']) != "nan":
300
+ m['nomfichier'] = unidecode("%s_%s"%(m['nom'].replace(" ","_"), \
301
+ m["prenom"].replace(" ","_")))
302
+ membres.append(m)
303
+ return membres
304
+
305
+ def generateImpartialite(file):
306
+ membres = getMembres(file)
307
+ saveJson(membres, 'membres.json')
308
+ createDir('impartialite')
309
+ os.system('node carbone/impartialite.js')
310
+
311
+
312
+ avisdetailles = {
313
+ '' : ""
314
+ }
315
+
316
+ def loadAvisDetailles (file):
317
+ df = pd.read_excel(file, sheet_name="AvisDetailles")
318
+
319
+ for _, r in df.iterrows():
320
+ avisdetailles[r['Code']] = r['Texte']
321
+
322
+ def checkAvis(avis):
323
+ for a in avis:
324
+ if a not in avisdetailles.keys():
325
+ print("ERROR: avis " + a + " not found")
326
+
327
+ def getAvisTexte(avis):
328
+ if avis == None:
329
+ return ""
330
+ s = ""
331
+ for a in avis:
332
+ s = s + avisdetailles[a] + " "
333
+ return s
334
+
335
+ def formatAdresse(l):
336
+ s = ""
337
+ LB = "\n"
338
+ if str(l['Adresse postale']) != "nan":
339
+ s = s + l['Adresse postale'] + LB
340
+ if str(l['Adresse comp.']) != "nan":
341
+ s = "%s%s%s"%(s, l['Adresse comp.'], LB)
342
+
343
+ if str(l['Pays']) == "FRANCE":
344
+ s = "%s%s %s"%(s, int(l['Code postal']), l['Ville'])
345
+ else:
346
+ s = "%s%s %s%s%s"%(s, l['Code postal'], l['Ville'], LB, l['Pays'])
347
+ return s
348
+
349
+ def getListAvis(r):
350
+ a = r['AvisAudition']
351
+ if str(a) != 'nan':
352
+ return a.replace(" ", "").split(",")
353
+ else:
354
+ return []
355
+
356
+ def getCandidateData(r, rapNum):
357
+ c = {}
358
+ if (rapNum == 1):
359
+ if str(r["Rapporteur1"]) == "nan" :
360
+ rapporteur = "Rapporteur1"
361
+ else:
362
+ rapporteur = r["Rapporteur1"]
363
+ else:
364
+ if str(r["Rapporteur2"]) == "nan" :
365
+ rapporteur = "Rapporteur2"
366
+ else:
367
+ rapporteur = r["Rapporteur2"]
368
+
369
+ listeAvis = getListAvis(r)
370
+ checkAvis(listeAvis)
371
+ if 'A' in listeAvis:
372
+ avistexte = "Favorable"
373
+ avisboolean = "true"
374
+ else:
375
+ avistexte = "Défavorable"
376
+ avisboolean = "false"
377
+ avis = getAvisTexte(listeAvis)
378
+
379
+ c["nomcandidat"] = r["Nom d'usage"]
380
+ c["prenomcandidat"] = r["Prénom"]
381
+ c["civilite"] = r["Civilité"]
382
+ c["civiliteExt"] = "Madame" if r["Civilité"] == "Mme" else "Monsieur"
383
+ c["heureAudition"] = formattxt(r["heureAudition"], "")
384
+ c["adresse"] = formatAdresse(r)
385
+ c["avisboolean"] = avisboolean
386
+ c["avis"] = avis
387
+ c["email"] = r['Email']
388
+ c["avistexte"] = avistexte
389
+
390
+ c["nomfichier"] = unidecode("%s_%s-%s"%(r["Nom d'usage"].replace(" ","_"), \
391
+ r["Prénom"].replace(" ","_"), rapporteur.replace(" ","_")))
392
+ c["nomfichierCandidat"] = unidecode("%s_%s"%(r["Nom d'usage"].replace(" ","_"), \
393
+ r["Prénom"].replace(" ","_")))
394
+ c["age"] = "%3.0f ans"%computeAge(r['Date de naissance'])
395
+ c["titreThese"] = cleantext(r['Titre thèse'])
396
+ c["DescriptionRecherche"] = cleantext(r['Description recherche'], True)
397
+ c["MotsCles"] = cleantext(r['Mots clés'], True)
398
+ c["LieuSoutenance"] = cleantext(r['Lieu soutenance'])
399
+ c["DirecteurThese"] = cleantext(r['Directeur Thèse'])
400
+ c["jury"] = cleantext(r['Jury'])
401
+ s = rapporteur.split(" ")
402
+ rapp = rapporteur
403
+ if len(s) > 1:
404
+ if len(s) == 3:
405
+ rapp = "%s %s %s"%(s[2],s[0],s[1])
406
+ else:
407
+ rapp = "%s %s"%(s[1],s[0])
408
+ c["rapporteur"] = rapp
409
+ return c
410
+
411
+ def generateReportsReunion1(file):
412
+ df = pd.read_excel(file)
413
+ candidats = []
414
+
415
+ for _, r in df.iterrows():
416
+ candidats.append(getCandidateData(r, 1))
417
+ candidats.append(getCandidateData(r, 2))
418
+
419
+ saveJson(candidats, 'candidats.json')
420
+ createDir('rapportsMembresReunion1')
421
+ os.system('node carbone/generaterapportsMembresReunion1.js')
422
+
423
+ def findRapporteur(nomprenom, rapporteurs):
424
+ if pd.isna(nomprenom):
425
+ return None
426
+ return next((r for r in rapporteurs if "%s %s"%(r["nomUsage"], r["prenom"]) == unidecode(nomprenom.upper())), None)
427
+
428
+ def designationRapporteurs(file, odyssee):
429
+ df = pd.read_excel(file, sheet_name="Candidats")
430
+ for _, row in df.iterrows():
431
+ id_candidat = row["ID"]
432
+
433
+ rapporteur1 = row["Rapporteur1"]
434
+ rapp1 = findRapporteur(rapporteur1, odyssee.get_committee_members())
435
+ if not pd.isna(rapporteur1) and not rapp1:
436
+ print(f"Rapporteur {rapporteur1} non trouvé dans les rapporteurs")
437
+ continue
438
+
439
+ rapporteur2 = row["Rapporteur2"]
440
+ rapp2 = findRapporteur(rapporteur2, odyssee.get_committee_members())
441
+ if not pd.isna(rapporteur2) and not rapp2:
442
+ print(f"Rapporteur {rapporteur2} non trouvé dans les rapporteurs")
443
+ continue
444
+
445
+ if not pd.isna(rapporteur1) and not pd.isna(rapporteur2):
446
+ print(f"Assignation de {rapporteur1} et {rapporteur2} pour le candidat {row['Prénom']} {row["Nom d'usage"]}")
447
+ res = odyssee.assign_jury_members_to_candidate(id_candidat, rapp1["id"], rapp2["id"])
448
+
449
+ if res:
450
+ print("OK")
451
+ else:
452
+ print("KO")
453
+ sys.exit(1)
454
+
455
+ def getAvis(txt):
456
+ tsLesAvis = [{"txt": "Très favorable à l’audition", "short": "TF"}, {"txt": "Favorable à l’audition", "short": "F"}, {"txt": "Réservé", "short": "R"}, {"txt": "Défavorable à l’audition", "short": "D"}]
457
+ avis = []
458
+ for a in [a["txt"] for a in tsLesAvis]:
459
+ case = extractInfoRapport(a, txt)
460
+ if "☒" in case or "X" in case:
461
+ avis.append(a)
462
+ if len(avis) == 0:
463
+ return ""
464
+ if len(avis) > 1:
465
+ print("Attention : Plusieurs avis trouvés dans le rapport :", avis)
466
+ return ""
467
+ avis = next(a for a in tsLesAvis if a["txt"] == avis[0])
468
+ return avis["short"]
469
+
470
+ def callbackOnReportsDownloaded(filename, res, r, i):
471
+ txt = getTextFromPDF(filename)
472
+ avis = getAvis(txt)
473
+ rapporteur = f"{r['nomUsage']}_{r['prenom'].capitalize()}"
474
+ res[f'rapporteur_{i+1}'] = {'nom': rapporteur, 'avis': avis}
475
+ return res
476
+
477
+ def downloadReports(odyssee, path):
478
+ allinfos = odyssee.download_reports(path)
479
+ saveJson(allinfos, "infos_rapports.json")
480
+
481
+ print(f"Total de rapports téléchargés: {allinfos['termines']} / {allinfos['total']}")
482
+
483
+ def reportRapporteursAvis(xlsxFile: str):
484
+ """
485
+ Report des avis des rapporteurs (de leur rapport pdf) dans le fichier Excel
486
+ Args: xlsxFile (str): chemin vers le fichier Excel
487
+ Returns: Fichier Excel avec -2 dans le nom
488
+ """
489
+ candidates = loadJson("infos_rapports.json")
490
+ candidates = candidates["infos"]
491
+
492
+ wbIn = load_workbook(filename = xlsxFile)
493
+ sIn = wbIn['Candidats']
494
+ columnsInfos = getColumnInfo(sIn)
495
+
496
+ for i in range(2, sIn.max_row+1):
497
+ index_ID = next((col['index'] for col in columnsInfos if col['column'] == "ID"), None)
498
+ ID = sIn.cell(row=i, column=index_ID + 1).value
499
+ candidat = next((c for c in candidates if c["candidatID"] == ID ), None)
500
+ if candidat is not None:
501
+ rapport1 = candidat.get("rapport_1", None)
502
+ if rapport1 is not None:
503
+ txt = getTextFromPDF(rapport1)
504
+ avis = getAvis(txt)
505
+ colAvis1 = next((col['index'] for col in columnsInfos if col['column'] == "AvisRapp1"), None)
506
+ sIn.cell(row=i, column=colAvis1 + 1).value = trAvis(avis)
507
+
508
+ rapport2 = candidat.get("rapport_2", None)
509
+ if rapport2 is not None:
510
+ txt = getTextFromPDF(rapport2)
511
+ avis = getAvis(txt)
512
+ colAvis2 = next((col['index'] for col in columnsInfos if col['column'] == "AvisRapp2"), None)
513
+ sIn.cell(row=i, column=colAvis2 + 1).value = trAvis(avis)
514
+
515
+ newFile = xlsxFile.replace(".xlsx", "-2.xlsx")
516
+ wbIn.save(newFile)
517
+ print(f"Avis des rapporteurs reportés dans le fichier {newFile}")
518
+
519
+ def avisCandidatEtVotePremiereReunion(xlsxFile, odyssee):
520
+ avisdetailles = loadAvisDetailles(xlsxFile)
521
+
522
+ df = pd.read_excel(xlsxFile, sheet_name="Candidats")
523
+ for _, r in tqdm.tqdm(df.iterrows(), desc="Enregistrement des avis de la première réunion"):
524
+ id_candidat = r["ID"]
525
+ avis = getListAvis(r)
526
+ cleaned_avis = checkAvis(avis)
527
+ motif = getAvisTexte(cleaned_avis)
528
+ nombreVotants, suffragesExprimes, bulletinsNuls, bulletinsBlancs, bulletinsEnAccord, bulletinsEnDesaccord = r["nombreVotants"], r["suffragesExprimes"], r["bulletinsNuls"], r["bulletinsBlancs"], r["bulletinsEnAccord"], r["bulletinsEnDesaccord"]
529
+ odyssee.opinion_for_interview(id_candidat, avis, motif, nombreVotants, suffragesExprimes, bulletinsNuls, bulletinsBlancs, bulletinsEnAccord, bulletinsEnDesaccord)
530
+
531
+ def generatePVrepartition(file):
532
+ df = pd.read_excel(file)
533
+ candidats = []
534
+
535
+ for _, r in df.iterrows():
536
+ c = getCandidateData(r, 1)
537
+ c2 = getCandidateData(r, 2)
538
+ c['nomrap1'] = c['rapporteur']
539
+ c['nomrap2'] = c2['rapporteur']
540
+ c['nomprenom'] = "%s - %s"%(c['nomcandidat'], c['prenomcandidat'])
541
+ candidats.append(c)
542
+
543
+ candidats = sorted(candidats, key=lambda x: x['nomprenom'])
544
+ saveJson(candidats, 'candidats.json')
545
+ os.system('node carbone/generaterapportsPVrepartition.js')
546
+
547
+ def generateAttestationsVisio(file):
548
+ membres = getMembres(file)
549
+ saveJson(membres, 'membres.json')
550
+ createDir('attestationsVisio')
551
+ os.system('node carbone/attestationsVisio.js')
552
+
553
+ def generateLettresAuditionnes(file):
554
+ df = pd.read_excel(file)
555
+ candidats = []
556
+
557
+ for _, r in df.iterrows():
558
+ c = getCandidateData(r, 1)
559
+ if c["avisboolean"] == "true":
560
+ candidats.append(c)
561
+
562
+ saveJson(candidats, 'candidats.json')
563
+ createDir('convocationsCandiats')
564
+ os.system('node carbone/generateLettresAuditionnes.js')
565
+
566
+ def sendMailAuditionnes(file, config):
567
+ df = pd.read_excel(file)
568
+ candidats = []
569
+
570
+ for _, r in df.iterrows():
571
+ c = getCandidateData(r, 1)
572
+ if c["avistexte"] == "Favorable":
573
+ candidats.append(c)
574
+
575
+ candidats = candidats[0:1]
576
+
577
+ for c in candidats:
578
+ subject = "Convocation à l'audition du poste XXXX"
579
+ recipient = "%s %s"%(c["prenomcandidat"], c["nomcandidat"])
580
+ recipient = recipient.lower().title()
581
+ c['recipient'] = recipient
582
+ print('%s/%s.pdf'%(config['FILES']['convocations-candidats'], c['nomfichierCandidat']))
583
+ # Read the content of templates/mailCandodatsAuditionnes.txt
584
+ with open('templates/mailCandidatsAuditionnes.txt', 'r') as f:
585
+ mail_template = f.read()
586
+ message = mail_template.format_map(c)
587
+ thunderbird(config['SOFTWARE']['thunderbird-bin'], recipientName=recipient, recipientAddress=c['email'], thesubject=subject, thecontent=message, cc='', attachment='%s/%s.pdf'%(config['FILES']['convocations-candidats'], c['nomfichierCandidat']))
588
+
589
+
590
+ def main():
591
+ config = configparser.ConfigParser()
592
+ config.read("config.ini")
593
+
594
+ parser = argparse.ArgumentParser(description='ezCOS - un outil pour faciliter la gestion des comités de sélection')
595
+ parser.add_argument('-auth', help = 'Authentification sur Odyssee', action="store_true")
596
+ parser.add_argument('-downloadApplications', help = 'Téléchargement des dossiers des candidats', action="store_true")
597
+ parser.add_argument('-unzipApplications', help = 'Décompression des dossiers des candidats', action="store_true")
598
+ parser.add_argument('-populateXLSX', help = 'Récupération des infos sur les candidats sur Odyssee', action="store_true")
599
+ parser.add_argument('-stats', help = 'Compute stats', action="store_true")
600
+ parser.add_argument('-impartialite', help = "Déclaration d'impartialité", action="store_true")
601
+ parser.add_argument('-r1', help = 'rapports précomplétés sur les candidats', action="store_true")
602
+ parser.add_argument('-assignOdyssee', help = 'affectation des dossiers aux rapporteurs sur Odyssee', action="store_true")
603
+ parser.add_argument('-pvRepart', help = 'generate PV repartition', action="store_true")
604
+ parser.add_argument('-attestationsVisio', help = 'attestions participation visio membres', action="store_true")
605
+ parser.add_argument('-reports', help = "téléchargement des rapports des candidatures d'Odyssee", action="store_true")
606
+ parser.add_argument('-reports2xlsx', help = 'report des avis des rapporteurs dans le fichier Excel', action="store_true")
607
+ parser.add_argument('-avisPremiereReunion', help = 'avis par candidat + vote suite à la première réunion', action="store_true")
608
+ parser.add_argument('-convocCandidats', help = 'generate convocations candidats', action="store_true")
609
+ parser.add_argument('-convocCandidatsPDF', help = 'generate convocations candidats', action="store_true")
610
+ parser.add_argument('-envoimailauditionnes', help = 'Envoi de mail aux candidats auditionnés', action="store_true")
611
+ args = parser.parse_args()
612
+
613
+ numposte = config["ODYSSEE"]["numposte"]
614
+ odyssee = OdysseeCOS(numposte)
615
+
616
+ candidates_folder = "dossiers_candidats"
617
+
618
+ xlsx_candidats = config['FILES']['xlsx-candidats']
619
+ soffice = config['SOFTWARE']['soffice']
620
+
621
+ if args.auth:
622
+ login = config["ODYSSEE"]["login"]
623
+ password = config["ODYSSEE"]["password"]
624
+ odyssee.authenticate(login, password)
625
+
626
+ if args.downloadApplications:
627
+ odyssee.download_applications(candidates_folder)
628
+
629
+ if args.unzipApplications:
630
+ unzip_applications(candidates_folder)
631
+
632
+ if args.populateXLSX:
633
+ template_xlsx = "comite.xlsx"
634
+ populateXLSX('templates', template_xlsx, odyssee, candidates_folder)
635
+
636
+ if args.stats:
637
+ stats(xlsx_candidats)
638
+
639
+ if args.impartialite:
640
+ generateImpartialite(xlsx_candidats)
641
+
642
+ if args.r1:
643
+ generateReportsReunion1(xlsx_candidats)
644
+
645
+ if args.assignOdyssee:
646
+ designationRapporteurs(xlsx_candidats, odyssee)
647
+
648
+ if args.pvRepart:
649
+ generatePVrepartition(xlsx_candidats)
650
+
651
+ if args.attestationsVisio:
652
+ generateAttestationsVisio(xlsx_candidats)
653
+
654
+ if args.reports:
655
+ loadAvisDetailles(xlsx_candidats)
656
+ downloadReports(odyssee, "rapportsOdyssee")
657
+
658
+ if args.reports2xlsx:
659
+ reportRapporteursAvis(config["FILES"]["xlsx-candidats"])
660
+
661
+ if args.avisPremiereReunion:
662
+ avisCandidatEtVotePremiereReunion(config["FILES"]["xlsx-candidats"], odyssee)
663
+
664
+ if args.convocCandidats:
665
+ loadAvisDetailles(xlsx_candidats)
666
+ generateLettresAuditionnes(xlsx_candidats)
667
+
668
+ if args.convocCandidatsPDF:
669
+ os.system('cd convocationsCandiats; %s --headless --convert-to pdf *.docx'%soffice)
670
+
671
+ if args.envoimailauditionnes:
672
+ loadAvisDetailles(xlsx_candidats)
673
+ sendMailAuditionnes(xlsx_candidats, config)
674
+
675
+ if __name__ == "__main__":
676
+ main()
ezCOS/utils/tools.py ADDED
@@ -0,0 +1,219 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # tools.py -
4
+ #
5
+ # Authors:
6
+ # Géry Casiez https://gery.casiez.net
7
+ #
8
+ # 2026
9
+ #
10
+ # BSD License https://opensource.org/licenses/BSD-3-Clause
11
+ #
12
+ # Redistribution and use in source and binary forms, with or without
13
+ # modification, are permitted provided that the following conditions are met:
14
+ #
15
+ # 1. Redistributions of source code must retain the above copyright notice, this list of conditions
16
+ # and the following disclaimer.
17
+ #
18
+ # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions
19
+ # and the following disclaimer in the documentation and/or other materials provided with the distribution.
20
+ #
21
+ # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or
22
+ # promote products derived from this software without specific prior written permission.
23
+
24
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
25
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
27
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
29
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ #
32
+
33
+ import os
34
+ import json
35
+ from datetime import datetime
36
+ import pymupdf
37
+ import re
38
+
39
+ def getColumnInfo(ws) -> list:
40
+ """
41
+ Récupère les informations des colonnes d'une feuille du fichier Excel et les retourne sous forme de liste de dictionnaires.
42
+ """
43
+ list_with_values=[]
44
+ for i, cell in enumerate(ws[1]):
45
+ list_with_values.append({'column': cell.value, 'index': i})
46
+ return list_with_values
47
+
48
+ def populateSheet(ws, infos, mapping):
49
+ """
50
+ Remplit la feuille Excel avec les données fournies dans le mapping.
51
+ - ws : la feuille Excel à remplir
52
+ - infos : les données à insérer dans la feuille
53
+ - mapping : un dictionnaire qui associe les clés des données aux colonnes de la feuille Excel, avec éventuellement une transformation à appliquer aux données avant de les insérer.
54
+ """
55
+
56
+ columnsInfos = getColumnInfo(ws)
57
+ row_index = 2
58
+ for m in infos:
59
+ for key, value in mapping.items():
60
+ decomposed_key = key.split(':')
61
+ data_value = m
62
+ for k in decomposed_key:
63
+ data_value = data_value.get(k, {})
64
+
65
+ if data_value != {}:
66
+ sheet_col_index = next((col['index'] for col in columnsInfos if col['column'] == value['column']), None)
67
+ if sheet_col_index is not None:
68
+ if 'transformation' in value:
69
+ ws.cell(row=row_index, column=sheet_col_index + 1, value=value['transformation'](data_value))
70
+ else:
71
+ ws.cell(row=row_index, column=sheet_col_index + 1, value=data_value)
72
+ else:
73
+ print(f"Colonne '{value['column']}' non trouvée dans le fichier Excel.")
74
+ row_index += 1
75
+
76
+ def formatPrenom(prenom: str) -> str | None:
77
+ """
78
+ Formate le prénom en capitalisant la première lettre de chaque mot et en séparant les mots par des tirets.
79
+ Par exemple, "jean-paul" devient "Jean-Paul" et "marie claire" devient "Marie Claire".
80
+ """
81
+ if prenom:
82
+ stepOne = " ".join([p.capitalize() for p in prenom.split(" ")])
83
+ return "-".join([p.capitalize() for p in stepOne.split("-")])
84
+ return None
85
+
86
+ def formatLieu(lieu: str) -> str | None:
87
+ """
88
+ Formate le lieu en capitalisant la première lettre de chaque mot, sauf pour les mots "DE" qui sont mis en majuscules."
89
+ """
90
+ if lieu:
91
+ return " ".join([l.capitalize() if l not in ["DE"] else l.lower() for l in lieu.split(" ")])
92
+ return None
93
+
94
+ def computeAge(d) -> float:
95
+ """
96
+ Calcule l'âge à partir d'une date de naissance donnée au format "dd/mm/yyyy". Si la date est "nan", retourne 0.
97
+ """
98
+ if str(d) != "nan":
99
+ a = datetime.now()
100
+ b = datetime.strptime(d,"%d/%m/%Y")
101
+ c = a - b
102
+ return c.days/365.0
103
+ else:
104
+ return 0
105
+
106
+ def loadJson(filename):
107
+ """
108
+ Charge les données d'un fichier JSON et les retourne sous forme de dictionnaire.
109
+ """
110
+ with open(filename, 'r', encoding='utf8') as f:
111
+ return json.load(f)
112
+
113
+ def saveJson(data, filename):
114
+ """
115
+ Sauvegarde les données dans un fichier JSON avec une indentation de 4 espaces et en préservant les caractères non ASCII.
116
+ """
117
+ with open(filename, 'w', encoding='utf8') as f:
118
+ json.dump(data, f, indent=4, ensure_ascii=False)
119
+
120
+ def createDir(path):
121
+ """Crée un répertoire à l'emplacement spécifié s'il n'existe pas déjà.
122
+ Args:
123
+ path (str): Le chemin du répertoire à créer.
124
+ """
125
+ if (not(os.path.exists(path))):
126
+ os.makedirs(path)
127
+
128
+ def getTextFromPDF(file):
129
+ """
130
+ Extrait le texte d'un fichier PDF en utilisant la bibliothèque pymupdf.
131
+ Args:
132
+ file (str): Le chemin du fichier PDF.
133
+ Returns:
134
+ str: Le texte extrait du PDF.
135
+ """
136
+ pdf_document = pymupdf.open(file)
137
+ text = ""
138
+
139
+ for page_num in range(pdf_document.page_count):
140
+ page = pdf_document.load_page(page_num)
141
+ text += page.get_text()
142
+ return text
143
+
144
+ def extractInfo(info: str, txt: str, stopWord=None) -> str:
145
+ """
146
+ Extrait une information spécifique d'un texte en utilisant une expression régulière.
147
+ Args:
148
+ - info (str): L'information à extraire, utilisée comme point de départ dans le texte.
149
+ - txt (str): Le texte à partir duquel extraire l'information.
150
+ - stopWord (str, optional): Un mot qui indique la fin de l'information à extraire. Si None, l'extraction se fait jusqu'à la fin de la ligne.
151
+ Returns:
152
+ str: L'information extraite du texte, ou une chaîne vide si l'information n'est pas trouvée.
153
+ """
154
+ res = ""
155
+
156
+ if stopWord:
157
+ pattern = f"{info} : (.*?){stopWord}"
158
+ match = re.search(pattern, txt, re.DOTALL)
159
+ else:
160
+ pattern = f"^{info} : (.*)$"
161
+ match = re.search(pattern, txt, re.MULTILINE)
162
+
163
+ if match:
164
+ res = match.group(1).strip()
165
+ if res is not None:
166
+ res = res.replace("\n", "")
167
+
168
+ return res
169
+
170
+ def extractInfoRapport(info, txt, stopWord=None):
171
+ res = ""
172
+
173
+ if stopWord:
174
+ pattern = f"{info}(.*?){stopWord}"
175
+ match = re.search(pattern, txt, re.DOTALL)
176
+ else:
177
+ pattern = f"{info}(.*?)$"
178
+ match = re.search(pattern, txt, re.MULTILINE)
179
+
180
+ if match:
181
+ res = match.group(1).strip()
182
+ if res is not None:
183
+ res = res.replace("\n", "")
184
+
185
+ return res
186
+
187
+
188
+ def formattxt(txt, prefix = " - "):
189
+ if (str(txt) == "") or (str(txt) == "nan"):
190
+ return ""
191
+ else:
192
+ return "%s%s"%(prefix, txt)
193
+
194
+ def cleantext(txt, convBullets=False):
195
+ if str(txt) != "nan":
196
+ res = str(txt).replace("\"","").replace(" ","").replace("_x000D_","").replace("¿","-")
197
+ if convBullets:
198
+ return res.replace(" -","\n-").replace(",-",",\n-")
199
+ else:
200
+ return res
201
+ else:
202
+ return ""
203
+
204
+ def thunderbird(thunderbirdbin, recipientName, recipientAddress, thesubject, thecontent, cc='' , attachment=''):
205
+ cmd = "%s -compose \"to='%s',cc='%s',subject='%s',format=1,body='%s',attachment='%s'\""%(thunderbirdbin, recipientAddress, cc, thesubject, thecontent, attachment)
206
+ # print(cmd)
207
+ os.system(cmd)
208
+
209
+ def trAvis(avis):
210
+ if avis == "TF":
211
+ return "Très favorable"
212
+ elif avis == "F":
213
+ return "Favorable"
214
+ elif avis == "R":
215
+ return "Réservé"
216
+ elif avis == "D":
217
+ return "Défavorable"
218
+ else:
219
+ return avis
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezCOS
3
+ Version: 0.0.1
4
+ Summary: Outil de simplification de la gestion des COS.
5
+ Author-email: Géry Casiez <gery.casiez@univ-lille.fr>
6
+ Project-URL: Homepage, https://github.com/casiez/ezCOS
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ezCOS
12
+
13
+ [![PyPI Version](https://img.shields.io/pypi/v/ezCOS)](https://pypi.org/project/ezCOS/)
14
+ [![Downloads](https://static.pepy.tech/badge/ezCOS)](https://pepy.tech/project/ezCOS)
15
+
16
+ Outil pour faciliter la gestion des comités de sélection (COS) dans les établissements d'enseignement supérieur. Il permet de télécharger les dossiers des candidats depuis Odyssee, de les décompresser et de remplir automatiquement un fichier Excel avec les informations pertinentes sur les candidats... (voir ci-dessous)
17
+
18
+ ezCOS s'appuie sur [odysseeapi](https://github.com/casiez/OdysseeAPI/) pour interagir avec la plateforme Odyssee.
19
+
20
+ ## Installation
21
+
22
+ ```
23
+ pip install ezCOS -U
24
+ ```
25
+
26
+ Créer un répertoire de travail pour le comité de sélection, puis y copier le fichier `config.ini`, les modèles de fichiers Excel et Word depuis le répertoire `templates` dans un répertoire `templates`. Copier également le contenu du répertoire `carbone`dans un répertoire du même nom.
27
+
28
+ Modifier le fichier `config.ini` paramétrer Odyssee et modifier des chemins.
29
+
30
+ ## Etapes
31
+
32
+ 1. Télécharger les dossiers des candidats depuis Odyssee :
33
+ ```
34
+ ezCOS -auth -downloadApplications
35
+ ```
36
+ L'option `-auth` permet de s'authentifier auprès d'Odyssee pour accéder aux dossiers des candidats. L'option `-downloadApplications` lance le processus de téléchargement des dossiers. Les archives zip des dossiers sont téléchargés dans le répertoire `dossiers_candidats`.
37
+
38
+ 1. Décompresser les dossiers des candidats :
39
+ ```
40
+ ezCOS -unzipApplications
41
+ ```
42
+ L'option `-unzipApplications` lance le processus de décompression des archives zip téléchargées. Les dossiers décompressés se trouvent dans le répertoire `dossiers_candidats`.
43
+
44
+ 1. Remplir automatiquement un fichier Excel avec les informations sur les candidats :
45
+ ```
46
+ ezCOS -auth -populateXLSX
47
+ ```
48
+ L'option `-populateXLSX` lance le processus de remplissage du fichier Excel avec les informations extraites des dossiers des candidats. Le fichier `templates/comite.xlsx` est utilisé comme modèle pour créer le fichier `comitePopulated.xlsx` qui contient les informations des candidats et des membres du comité.
49
+
50
+ A cette étape, il faut renommer le fichier `comitePopulated.xlsx` en utilisant celui défini dans la section [FILES] du fichier `config.ini` (ex: `comite.xlsx`).
51
+
52
+ Dans la feuille "Candidats" du fichier Excel, affecter les rapporteurs à chaque candidat en utilisant les listes déroulantes dans la colonne "Rapporteur1" et "Rapporteur2". Les membres du comité sont listés dans l'onglet "MembresComite" du fichier Excel.
53
+
54
+ Dans l'onglet "MembresComite", compléter la section et le statut de chaque membre du comité (Interne / Externe).
55
+
56
+ 1. Générer les avis d'impartialité pour les membres du comité :
57
+ ```
58
+ ezCOS -impartialite
59
+ ```
60
+ L'option `-impartialite` lance le processus de génération des déclarations d'impartialité pour les membres du comité, à partir du modèle `templates/Impartialite.xlsx`. Les décalrations sont sauvegardées dans le répertoire `impartialite`.
61
+
62
+ 1. Génération des modèles de rapports pré-complétés pour les rapporteurs :
63
+ ```
64
+ ezCOS -r1
65
+ ```
66
+ L'option `-r1` lance le processus de génération des modèles de rapports pré-complétés pour les rapporteurs, avec les infos des candidats et à partir du modèle `templates/NOMCANDIDATprenom-NOMRAPPORTEUR.docx`. Les rapports sont générés au format DOCX et sauvegardés dans le répertoire `rapportsMembresReunion1`.
67
+
68
+ 1. Affectation des rapporteurs sur Odyssee :
69
+ ````
70
+ ezCOS -auth -assignOdyssee
71
+ ```
72
+ L'option `-assignOdyssee` lance le processus d'affectation des rapporteurs sur Odyssee, en utilisant les informations du fichier Excel. Les rapporteurs sont affectés aux candidats sur Odyssee en fonction des choix faits dans les colonnes "Rapporteur1" et "Rapporteur2" du fichier Excel.
73
+
74
+ Vérifier le résultat de l'affectation des rapporteurs sur Odyssee.
75
+
76
+ 1. Génération du PV de répartition des rapporteurs :
77
+ ```
78
+ ezCOS -pvRepart
79
+ ```
80
+ L'option `-pvRepart` lance le processus de génération du PV de répartition des rapporteurs, à partir du modèle `templates/PVrepartition.docx`. Le PV est sauvegardé sous le nom `PVrepartitionRapporteurs.docx`.
81
+
82
+ 1. Génération des attestations de participation en visio pour les membres du comité :
83
+ ```
84
+ ezCOS -attestationsVisio
85
+ ```
86
+ L'option `-attestationsVisio` lance le processus de génération des attestations de participation en visio pour les membres du comité, à partir du modèle `templates/Attestation_visioconference_membre.docx`. Les attestations sont générées au format DOCX et sauvegardées dans le répertoire `attestationsVisio`.
87
+
88
+ 1. Téléchargement des rapports des rapporteurs depuis Odyssee :
89
+ ```
90
+ ezCOS -auth -reports
91
+ ```
92
+ L'option `-reports` lance le processus de téléchargement des rapports des rapporteurs depuis Odyssee. Les rapports sont téléchargés au format PDF et sauvegardés dans le répertoire `rapportsOdyssee`.
93
+
94
+ 1. Extraction des avis des rapports des rapporteurs et mise à jour du fichier Excel :
95
+ ```
96
+ ezCOS -reports2xlsx
97
+ ```
98
+ L'option `-reports2xlsx` lance le processus d'extraction des avis des rapports des rapporteurs et de mise à jour du fichier Excel. Les avis sont extraits des rapports PDF téléchargés à l'étape précédente, puis reportés dans les colonnes "AvisRapp1" et "AvisRapp2" du fichier Excel. Par précaution, le script crée une copie du fichier Excel avant de le modifier, avec le suffixe "-2". Les avis sont ensuite à copier dans le fichier Excel original.
99
+
100
+ 1. Avis par candidat et vote suite à la première réunion du comité de sélection :
101
+ ```
102
+ ezCOS -auth -avisPremiereReunion
103
+ ```
104
+ L'option `-avisPremiereReunion` utilise la colonne "AvisAudition" du fichier Excel pour générer un avis global. Les lignes de cette colonne doivent être remplies avec des codes (ex: "A", "B", "C", "D") séparés par des virgules, et définis dans la feuille AvisDetailles du fichier Excel. Les candidats auditionnés doivent avoir au moinns un avis "A" dans cette colonne.
105
+
106
+ Les résultats du vote sont dans les colonnes correspondantes du fichier Excel.
107
+
108
+ Vérifier le résultat de la mise à jour des avis et des votes sur Odyssee.
109
+
110
+ 1. Génération des convocations pour les candidats auditionnés :
111
+ ```
112
+ ezCOS -convocCandidats
113
+ ```
114
+ L'option `-convocCandidats` lance le processus de génération des convocations pour les candidats auditionnés, à partir du modèle `templates/Convocation_candidats.docx`. Les convocations sont générées au format DOCX et sauvegardées dans le répertoire `convocationsAuditions`.
115
+
116
+ 1. Conversion au format PDF des convocations pour les candidats auditionnés :
117
+ ```
118
+ ezCOS -convocCandidatsPDF
119
+ ```
120
+ L'option `-convocCandidatsPDF` lance le processus de conversion au format PDF des convocations pour les candidats auditionnés, à partir des fichiers DOCX générés à l'étape précédente. Les convocations au format PDF sont sauvegardées dans le répertoire `convocationsAuditions`.
121
+
122
+ 1. Envoi des convocations aux candidats auditionnés par email :
123
+ ```
124
+ ezCOS -envoimailauditionnes
125
+ ```
126
+ L'option `-envoimailauditionnes` lance le processus d'envoi des convocations aux candidats auditionnés par email, en utilisant les adresses email extraites des dossiers des candidats. Les convocations au format PDF sont envoyées en pièce jointe.
127
+
128
+ Le template `templates/mailCandidatsAuditionnes.txt` est utilisé pour le corps du mail.
129
+
130
+ Les mails ne sont pas envoyés directement, mais préparés dans le client de messagerie Thunderbird. Vérifier les mails préparés dans Thunderbird avant de les envoyer.
131
+
132
+ Vérifier que vous avez bien configuré `convocations-candidats` dans le fichier de config.ini (mettre le chemin absolu), ainsi que le chemin vers thunderbird (`thunderbird-bin`).
@@ -0,0 +1,8 @@
1
+ ezCOS/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ezCOS/ezCOS.py,sha256=eZoOdWrRq28JzdoglSuRoOdNdzIKKkDWBoRoNVzWY6k,30024
3
+ ezCOS/utils/tools.py,sha256=i_ldSyqU1cD9Ve0pFc3ZTkOsY3u0-AJZial2mnjMUDE,8020
4
+ ezcos-0.0.1.dist-info/METADATA,sha256=g7bvsm3QCDenrWP4cSI-MFpKjgc1FwY3gRtJoj0sFeM,8277
5
+ ezcos-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ ezcos-0.0.1.dist-info/entry_points.txt,sha256=Wnsg3A_ztUHh7VLWQXg39jVvojyGJL80nmJJGjYaJ48,43
7
+ ezcos-0.0.1.dist-info/top_level.txt,sha256=qiFznm37k-Ih7Wo_su3wdYmDS6yBJ33gExKJz8fy9Vo,6
8
+ ezcos-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ezCOS = ezCOS.ezCOS:main
@@ -0,0 +1 @@
1
+ ezCOS