ScoDoc/app/scodoc/sco_apogee_reader.py

495 lines
18 KiB
Python

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Lecture du fichier "maquette" Apogée
Le fichier CSV, champs séparés par des tabulations, a la structure suivante:
<pre>
XX-APO_TITRES-XX
apoC_annee 2007/2008
apoC_cod_dip VDTCJ
apoC_Cod_Exp 1
apoC_cod_vdi 111
apoC_Fichier_Exp VDTCJ_V1CJ.txt
apoC_lib_dip DUT CJ
apoC_Titre1 Export Apogée du 13/06/2008 à 14:29
apoC_Titre2
XX-APO_TYP_RES-XX
...section optionnelle au contenu quelconque...
XX-APO_COLONNES-XX
apoL_a01_code Type Objet Code Version Année Session Admission/Admissibilité Type Rés. Etudiant Numéro
apoL_a02_nom 1 Nom
apoL_a03_prenom 1 Prénom
apoL_a04_naissance Session Admissibilité Naissance
APO_COL_VAL_DEB
apoL_c0001 VET V1CJ 111 2007 0 1 N V1CJ - DUT CJ an1 0 1 Note
apoL_c0002 VET V1CJ 111 2007 0 1 B 0 1 Barème
apoL_c0003 VET V1CJ 111 2007 0 1 R 0 1 Résultat
APO_COL_VAL_FIN
apoL_c0030 APO_COL_VAL_FIN
XX-APO_VALEURS-XX
apoL_a01_code apoL_a02_nom apoL_a03_prenom apoL_a04_naissance apoL_c0001 apoL_c0002 apoL_c0003 apoL_c0004 apoL_c0005 apoL_c0006 apoL_c0007 apoL_c0008 apoL_c0009 apoL_c0010 apoL_c0011 apoL_c0012 apoL_c0013 apoL_c0014 apoL_c0015 apoL_c0016 apoL_c0017 apoL_c0018 apoL_c0019 apoL_c0020 apoL_c0021 apoL_c0022 apoL_c0023 apoL_c0024 apoL_c0025 apoL_c0026 apoL_c0027 apoL_c0028 apoL_c0029
10601232 AARIF MALIKA 22/09/1986 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM 18 20 18 20 ADM 18 20 ADM 18 20 ADM 18 20 ADM
</pre>
On récupère nos éléments pédagogiques dans la section XX-APO-COLONNES-XX et
notre liste d'étudiants dans la section XX-APO_VALEURS-XX. Les champs de la
section XX-APO_VALEURS-XX sont décrits par les lignes successives de la
section XX-APO_COLONNES-XX.
Le fichier CSV correspond à une étape, qui est récupérée sur la ligne
<pre>
apoL_c0001 VET V1CJ ...
</pre>
"""
from collections import namedtuple
import io
import pprint
import re
# Pour la détection auto de l'encodage des fichiers Apogée:
from chardet import detect as chardet_detect
from app import log
from app.scodoc.sco_exceptions import ScoFormatError
from app.scodoc import sco_preferences
APO_PORTAL_ENCODING = (
"utf8" # encodage du fichier CSV Apogée (était 'ISO-8859-1' avant jul. 2016)
)
APO_INPUT_ENCODING = "ISO-8859-1" #
APO_OUTPUT_ENCODING = APO_INPUT_ENCODING # encodage des fichiers Apogee générés
APO_DECIMAL_SEP = "," # separateur décimal: virgule
APO_SEP = "\t"
APO_NEWLINE = "\r\n"
ApoEtudTuple = namedtuple("ApoEtudTuple", ("nip", "nom", "prenom", "naissance", "cols"))
class DictCol(dict):
"A dict, where we can add attributes"
class StringIOWithLineNumber(io.StringIO):
"simple wrapper to use a string as a file with line numbers"
def __init__(self, data: str):
super().__init__(data)
self.lineno = 0
def readline(self):
self.lineno += 1
return super().readline()
class ApoCSVReadWrite:
"Gestion lecture/écriture de fichiers csv Apogée"
def __init__(self, data: str):
if not data:
raise ScoFormatError("Fichier Apogée vide !")
self.data = data
self._file = StringIOWithLineNumber(data) # pour traiter comme un fichier
self.apo_elts: dict = None
self.cols: dict[str, dict[str, str]] = None
self.column_titles: str = None
self.col_ids: list[str] = None
self.csv_etuds: list[ApoEtudTuple] = []
# section_str: utilisé pour ré-écrire les headers sans aucune altération
self.sections_str: dict[str, str] = {}
"contenu initial de chaque section"
# self.header: str = ""
# "début du fichier Apogée jusqu'à XX-APO_TYP_RES-XX non inclu (sera ré-écrit non modifié)"
self.header_apo_typ_res: str = ""
"section XX-APO_TYP_RES-XX (qui peut en option ne pas être ré-écrite)"
self.titles: dict[str, str] = {}
"titres Apogée (section XX-APO_TITRES-XX)"
self.read_sections()
# Check that we have collected all requested infos:
if not self.header_apo_typ_res:
# on pourrait rendre XX-APO_TYP_RES-XX optionnelle mais mieux vaut vérifier:
raise ScoFormatError(
"format incorrect: pas de XX-APO_TYP_RES-XX",
filename=self.get_filename(),
)
if self.cols is None:
raise ScoFormatError(
"format incorrect: pas de XX-APO_COLONNES-XX",
filename=self.get_filename(),
)
if self.column_titles is None:
raise ScoFormatError(
"format incorrect: pas de XX-APO_VALEURS-XX",
filename=self.get_filename(),
)
def read_sections(self):
"""Lit une à une les sections du fichier Apogée"""
# sanity check: we are at the begining of Apogee CSV
start_pos = self._file.tell()
section = self._file.readline().strip()
if section != "XX-APO_TITRES-XX":
raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX")
while True:
self.read_section(section)
line, end_pos = _apo_next_non_blank_line(self._file)
self.sections_str[section] = self.data[start_pos:end_pos]
if not line:
break
section = line
start_pos = end_pos
def read_section(self, section_name: str):
"""Read a section: _file is on the first line after section title"""
if section_name == "XX-APO_TITRES-XX":
# Titres:
# on va y chercher apoC_Fichier_Exp qui donnera le nom du fichier
# ainsi que l'année scolaire et le code diplôme.
self.titles = self._apo_read_titres(self._file)
elif section_name == "XX-APO_TYP_RES-XX":
self.header_apo_typ_res = _apo_read_typ_res(self._file)
elif section_name == "XX-APO_COLONNES-XX":
self.cols = self.apo_read_cols()
self.apo_elts = self.group_elt_cols(self.cols)
elif section_name == "XX-APO_VALEURS-XX":
# les étudiants
self.apo_read_section_valeurs()
else:
raise ScoFormatError(
f"format incorrect: section inconnue: {section_name}",
filename=self.get_filename(),
)
def apo_read_cols(self):
"""Lecture colonnes apo :
Démarre après la balise XX-APO_COLONNES-XX
et s'arrête après la ligne suivant la balise APO_COL_VAL_FIN
Colonne Apogee: les champs sont données par la ligne
apoL_a01_code de la section XX-APO_COLONNES-XX
col_id est apoL_c0001, apoL_c0002, ...
:return: { col_id : { title : value } }
Example: { 'apoL_c0001' : { 'Type Objet' : 'VET', 'Code' : 'V1IN', ... }, ... }
"""
line = self._file.readline().strip(" " + APO_NEWLINE)
fields = line.split(APO_SEP)
if fields[0] != "apoL_a01_code":
raise ScoFormatError(
f"invalid line: {line} (expecting apoL_a01_code)",
filename=self.get_filename(),
)
col_keys = fields
while True: # skip premiere partie (apoL_a02_nom, ...)
line = self._file.readline().strip(" " + APO_NEWLINE)
if line == "APO_COL_VAL_DEB":
break
# après APO_COL_VAL_DEB
cols = {}
i = 0
while True:
line = self._file.readline().strip(" " + APO_NEWLINE)
if line == "APO_COL_VAL_FIN":
break
i += 1
fields = line.split(APO_SEP)
# sanity check
col_id = fields[0] # apoL_c0001, ...
if col_id in cols:
raise ScoFormatError(
f"duplicate column definition: {col_id}",
filename=self.get_filename(),
)
m = re.match(r"^apoL_c([0-9]{4})$", col_id)
if not m:
raise ScoFormatError(
f"invalid column id: {line} (expecting apoL_c{col_id})",
filename=self.get_filename(),
)
if int(m.group(1)) != i:
raise ScoFormatError(
f"invalid column id: {col_id} for index {i}",
filename=self.get_filename(),
)
cols[col_id] = DictCol(list(zip(col_keys, fields)))
cols[col_id].lineno = self._file.lineno # for debuging purpose
self._file.readline() # skip next line
return cols
def group_elt_cols(self, cols) -> dict:
"""Return (ordered) dict of ApoElt from list of ApoCols.
Clé: id apogée, eg 'V1RT', 'V1GE2201', ...
Valeur: ApoElt, avec les attributs code, type_objet
Si les id Apogée ne sont pas uniques (ce n'est pas garanti), garde le premier
"""
elts = {}
for col_id in sorted(list(cols.keys()), reverse=True):
col = cols[col_id]
if col["Code"] in elts:
elts[col["Code"]].append(col)
else:
elts[col["Code"]] = ApoElt([col])
return elts # { code apo : ApoElt }
def apo_read_section_valeurs(self):
"traitement de la section XX-APO_VALEURS-XX"
self.column_titles = self._file.readline()
self.col_ids = self.column_titles.strip().split()
self.csv_etuds = self.apo_read_etuds()
def apo_read_etuds(self) -> list[ApoEtudTuple]:
"""Lecture des étudiants (et résultats) du fichier CSV Apogée.
Les lignes "étudiant" commencent toujours par
`12345678 NOM PRENOM 15/05/2003`
le premier code étant le NIP.
"""
etud_tuples = []
while True:
line = self._file.readline()
# cette section est impérativement la dernière du fichier
# donc on arrête ici:
if not line:
break
if not line.strip():
continue # silently ignore blank lines
line = line.strip(APO_NEWLINE)
fields = line.split(APO_SEP)
if len(fields) < 4:
raise ScoFormatError(
"""Ligne étudiant invalide
(doit commencer par 'NIP NOM PRENOM dd/mm/yyyy')""",
filename=self.get_filename(),
)
cols = {} # { col_id : value }
try:
for i, field in enumerate(fields):
cols[self.col_ids[i]] = field
except IndexError as exc:
raise
raise ScoFormatError(
f"Fichier Apogee incorrect (colonnes excédentaires ? (<tt>{i}/{field}</tt>))",
filename=self.get_filename(),
) from exc
etud_tuples.append(
ApoEtudTuple(
nip=fields[0], # id etudiant
nom=fields[1],
prenom=fields[2],
naissance=fields[3],
cols=cols,
)
# XXX à remettre dans apogee_csv.py
# export_res_etape=self.export_res_etape,
# export_res_sem=self.export_res_sem,
# export_res_ues=self.export_res_ues,
# export_res_modules=self.export_res_modules,
# export_res_sdj=self.export_res_sdj,
# export_res_rat=self.export_res_rat,
# )
)
return etud_tuples
def _apo_read_titres(self, f) -> dict:
"Lecture section TITRES du fichier Apogée, renvoie dict"
d = {}
while True:
line = f.readline().strip(
" " + APO_NEWLINE
) # ne retire pas le \t (pour les clés vides)
if not line.strip(): # stoppe sur ligne pleines de \t
break
fields = line.split(APO_SEP)
if len(fields) == 2:
k, v = fields
else:
log(f"Error read CSV: \nline={line}\nfields={fields}")
log(dir(f))
raise ScoFormatError(
f"Fichier Apogee incorrect (section titres, {len(fields)} champs au lieu de 2)",
filename=self.get_filename(),
)
d[k] = v
#
if not d.get("apoC_Fichier_Exp", None):
raise ScoFormatError(
"Fichier Apogee incorrect: pas de titre apoC_Fichier_Exp",
filename=self.get_filename(),
)
# keep only basename: may be a windows or unix pathname
s = d["apoC_Fichier_Exp"].split("/")[-1]
s = s.split("\\")[-1] # for DOS paths, eg C:\TEMP\VL4RT_V3ASR.TXT
d["apoC_Fichier_Exp"] = s
return d
def get_filename(self) -> str:
"""Le nom du fichier APogée, tel qu'indiqué dans le fichier
ou vide."""
if self.titles:
return self.titles.get("apoC_Fichier_Exp", "")
return ""
def write(self, apo_etuds: list["ApoEtud"]) -> bytes:
"""Renvoie le contenu actualisé du fichier Apogée"""
f = io.StringIO()
self._write_header(f)
self._write_etuds(f, apo_etuds)
return f.getvalue().encode(APO_OUTPUT_ENCODING)
def _write_etuds(self, f, apo_etuds: list["ApoEtud"]):
"""write apo CSV etuds on f"""
for apo_etud in apo_etuds:
fields = [] # e['nip'], e['nom'], e['prenom'], e['naissance'] ]
for col_id in self.col_ids:
try:
fields.append(str(apo_etud.new_cols[col_id]))
except KeyError:
log(
f"""Error: {apo_etud["nip"]} {apo_etud["nom"]} missing column key {col_id}
Details:\napo_etud = {pprint.pformat(apo_etud)}
col_ids={pprint.pformat(self.col_ids)}
étudiant ignoré.
"""
)
f.write(APO_SEP.join(fields) + APO_NEWLINE)
def _write_header(self, f):
"""write apo CSV header on f
(beginning of CSV until columns titles just after XX-APO_VALEURS-XX line)
"""
remove_typ_res = sco_preferences.get_preference("export_res_remove_typ_res")
for section, data in self.sections_str.items():
# ne recopie pas la section résultats, et en option supprime APO_TYP_RES
if (section != "XX-APO_VALEURS-XX") and (
section != "XX-APO_TYP_RES-XX" or not remove_typ_res
):
f.write(data)
f.write("XX-APO_VALEURS-XX" + APO_NEWLINE)
f.write(self.column_titles)
class ApoElt:
"""Définition d'un Element Apogée
sur plusieurs colonnes du fichier CSV
"""
def __init__(self, cols):
assert len(cols) > 0
assert len(set([c["Code"] for c in cols])) == 1 # colonnes de meme code
assert len(set([c["Type Objet"] for c in cols])) == 1 # colonnes de meme type
self.cols = cols
self.code = cols[0]["Code"]
self.version = cols[0]["Version"]
self.type_objet = cols[0]["Type Objet"]
def append(self, col):
"""ajoute une "colonne" à l'élément"""
assert col["Code"] == self.code
if col["Type Objet"] != self.type_objet:
log(
f"""Warning: ApoElt: duplicate id {
self.code} ({self.type_objet} and {col["Type Objet"]})"""
)
self.type_objet = col["Type Objet"]
self.cols.append(col)
def __repr__(self):
return f"ApoElt(code='{self.code}', cols={pprint.pformat(self.cols)})"
def guess_data_encoding(text: bytes, threshold=0.6):
"""Guess string encoding, using chardet heuristics.
Returns encoding, or None if detection failed (confidence below threshold)
"""
r = chardet_detect(text)
if r["confidence"] < threshold:
return None
else:
return r["encoding"]
def fix_data_encoding(
text: bytes,
default_source_encoding=APO_INPUT_ENCODING,
dest_encoding=APO_INPUT_ENCODING,
) -> tuple[bytes, str]:
"""Try to ensure that text is using dest_encoding
returns converted text, and a message describing the conversion.
Raises UnicodeEncodeError en cas de problème, en général liée à
une auto-détection errornée.
"""
message = ""
detected_encoding = guess_data_encoding(text)
if not detected_encoding:
if default_source_encoding != dest_encoding:
message = f"converting from {default_source_encoding} to {dest_encoding}"
text = text.decode(default_source_encoding).encode(dest_encoding)
else:
if detected_encoding != dest_encoding:
message = (
f"converting from detected {default_source_encoding} to {dest_encoding}"
)
text = text.decode(detected_encoding).encode(dest_encoding)
return text, message
def _apo_read_typ_res(f) -> str:
"Lit la section XX-APO_TYP_RES-XX"
text = "XX-APO_TYP_RES-XX" + APO_NEWLINE
while True:
line = f.readline()
stripped_line = line.strip()
if not stripped_line:
break
text += line
return text
def _apo_next_non_blank_line(f: StringIOWithLineNumber) -> tuple[str, int]:
"Ramène prochaine ligne non blanche, stripped, et l'indice de son début"
while True:
pos = f.tell()
line = f.readline()
if not line:
return "", -1
stripped_line = line.strip()
if stripped_line:
return stripped_line, pos