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 +0 -0
- ezCOS/ezCOS.py +676 -0
- ezCOS/utils/tools.py +219 -0
- ezcos-0.0.1.dist-info/METADATA +132 -0
- ezcos-0.0.1.dist-info/RECORD +8 -0
- ezcos-0.0.1.dist-info/WHEEL +5 -0
- ezcos-0.0.1.dist-info/entry_points.txt +2 -0
- ezcos-0.0.1.dist-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/ezCOS/)
|
|
14
|
+
[](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 @@
|
|
|
1
|
+
ezCOS
|