diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 90d2900cb..9311fa457 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -190,20 +190,23 @@ def etud_upload_file_form(etudid): ) -def _store_etud_file_to_new_archive(etud_archive_id, data, filename, description=""): +def _store_etud_file_to_new_archive( + etud_archive_id, data, filename, description="" +) -> tuple[bool, str]: """Store data to new archive.""" filesize = len(data) if filesize < 10 or filesize > scu.CONFIG.ETUD_MAX_FILE_SIZE: - return 0, "Fichier image de taille invalide ! (%d)" % filesize + return False, f"Fichier archive '{filename}' de taille invalide ! ({filesize})" archive_id = EtudsArchive.create_obj_archive(etud_archive_id, description) EtudsArchive.store(archive_id, filename, data) + return True, "ok" def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): """Delete an archive""" # check permission if not can_edit_etud_archive(current_user): - raise AccessDenied("opération non autorisée pour %s" % str(current_user)) + raise AccessDenied(f"opération non autorisée pour {current_user}") etuds = sco_etud.get_etud_info(filled=True) if not etuds: raise ScoValueError("étudiant inexistant") @@ -354,7 +357,9 @@ def etudarchive_import_files( "Importe des fichiers" def callback(etud, data, filename): - _store_etud_file_to_new_archive(etud["etudid"], data, filename, description) + return _store_etud_file_to_new_archive( + etud["etudid"], data, filename, description + ) # Utilise la fontion developpée au depart pour les photos ( diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 598ec96b0..70f226b52 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -872,6 +872,7 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): admission = ( Admission.query.filter_by(etudid=etudid).first().to_dict(no_nulls=True) ) + del admission["id"] # pour garder id == etudid dans etud etud.update(admission) # adrs = adresse_list(cnx, {"etudid": etudid}) @@ -883,6 +884,7 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): adr = adrs[0] if len(adrs) > 1: log("fill_etuds_info: etudid=%s a %d adresses" % (etudid, len(adrs))) + adr.pop("id", None) etud.update(adr) format_etud_ident(etud) diff --git a/app/scodoc/sco_photos.py b/app/scodoc/sco_photos.py index e42b6586c..2bb247199 100644 --- a/app/scodoc/sco_photos.py +++ b/app/scodoc/sco_photos.py @@ -76,7 +76,7 @@ UNKNOWN_IMAGE_URL = "get_photo_image?etudid=" # with empty etudid => unknown fa IMAGE_EXT = ".jpg" JPG_QUALITY = 0.92 REDUCED_HEIGHT = 90 # pixels -MAX_FILE_SIZE = 1024 * 1024 # max allowed size for uploaded image, in bytes +MAX_FILE_SIZE = 4 * 1024 * 1024 # max allowed size for uploaded image, in bytes H90 = ".h90" # suffix for reduced size images @@ -244,34 +244,36 @@ def photo_pathname(photo_filename: str, size="orig"): return False -def store_photo(etud, data): +def store_photo(etud: dict, data, filename: str) -> tuple[bool, str]: """Store image for this etud. If there is an existing photo, it is erased and replaced. data is a bytes string with image raw data. Update database to store filename. - Returns (status, msg) + Returns (status, err_msg) """ # basic checks filesize = len(data) if filesize < 10 or filesize > MAX_FILE_SIZE: - return 0, "Fichier image de taille invalide ! (%d)" % filesize + return False, f"Fichier image '{filename}' de taille invalide ! ({filesize})" try: - filename = save_image(etud["etudid"], data) - except PIL.UnidentifiedImageError: - raise ScoGenError(msg="Fichier d'image invalide ou non format non supporté") + saved_filename = save_image(etud["etudid"], data) + except PIL.UnidentifiedImageError as exc: + raise ScoGenError( + msg="Fichier d'image '{filename}' invalide ou format non supporté" + ) from exc # update database: - etud["photo_filename"] = filename + etud["photo_filename"] = saved_filename etud["foto"] = None cnx = ndb.GetDBConnexion() sco_etud.identite_edit_nocheck(cnx, etud) cnx.commit() # - logdb(cnx, method="changePhoto", msg=filename, etudid=etud["etudid"]) + logdb(cnx, method="changePhoto", msg=saved_filename, etudid=etud["etudid"]) # - return 1, "ok" + return True, "ok" def suppress_photo(etud: Identite) -> None: @@ -366,9 +368,9 @@ def copy_portal_photo_to_fs(etud): portal_timeout = sco_preferences.get_preference("portal_timeout") f = None try: - log("copy_portal_photo_to_fs: getting %s" % url) + log(f"copy_portal_photo_to_fs: getting {url}") r = requests.get(url, timeout=portal_timeout) - except: + except Exception: # log("download failed: exception:\n%s" % traceback.format_exc()) # log("called from:\n" + "".join(traceback.format_stack())) log("copy_portal_photo_to_fs: error.") @@ -378,16 +380,16 @@ def copy_portal_photo_to_fs(etud): return None, "%s: erreur chargement de %s" % (etud["nomprenom"], url) data = r.content # image bytes try: - status, diag = store_photo(etud, data) - except: - status = 0 - diag = "Erreur chargement photo du portail" + status, err_msg = store_photo(etud, data, "(inconnue)") + except Exception: + status = False + err_msg = "Erreur chargement photo du portail" log("copy_portal_photo_to_fs: failure (exception in store_photo)!") - if status == 1: - log("copy_portal_photo_to_fs: copied %s" % url) + if status: + log(f"copy_portal_photo_to_fs: copied {url}") return ( photo_pathname(etud["photo_filename"]), f"{etud['nomprenom']}: photo chargée", ) else: - return None, "%s: %s" % (etud["nomprenom"], diag) + return None, "%s: %s" % (etud["nomprenom"], err_msg) diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index a8a96a43c..16a116741 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -33,14 +33,10 @@ from zipfile import ZipFile, BadZipfile from flask.templating import render_template import reportlab from reportlab.lib.units import cm, mm -from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak -from reportlab.platypus import Table, TableStyle, Image, KeepInFrame -from reportlab.platypus.flowables import Flowable -from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate -from reportlab.lib.pagesizes import A4, landscape +from reportlab.platypus import Paragraph +from reportlab.platypus import Table, TableStyle +from reportlab.platypus.doctemplate import BaseDocTemplate from reportlab.lib import styles -from reportlab.lib.colors import Color from reportlab.lib import colors from PIL import Image as PILImage @@ -82,7 +78,7 @@ def trombino( # if format != "html" and not dialog_confirmed: - ok, dialog = check_local_photos_availability(groups_infos, format=format) + ok, dialog = check_local_photos_availability(groups_infos, fmt=format) if not ok: return dialog @@ -96,7 +92,6 @@ def trombino( return sco_trombino_doc.trombino_doc(groups_infos) else: raise Exception("invalid format") - # return _trombino_html_header() + trombino_html( group, members) + html_sco_header.sco_footer() def _trombino_html_header(): @@ -105,7 +100,7 @@ def _trombino_html_header(): def trombino_html(groups_infos): "HTML snippet for trombino (with title and menu)" - menuTrombi = [ + menu_trombi = [ { "title": "Charger des photos...", "endpoint": "scolar.photos_import_files_form", @@ -125,19 +120,22 @@ def trombino_html(groups_infos): if groups_infos.members: if groups_infos.tous_les_etuds_du_sem: - ng = "Tous les étudiants" + group_txt = "Tous les étudiants" else: - ng = "Groupe %s" % groups_infos.groups_titles + group_txt = f"Groupe {groups_infos.groups_titles}" else: - ng = "Aucun étudiant inscrit dans ce groupe !" + group_txt = "Aucun étudiant inscrit dans ce groupe !" H = [ - '
%s | ' - % (ng) + f"""
{group_txt} | """ ] if groups_infos.members: H.append( "" - + htmlutils.make_menu("Gérer les photos", menuTrombi, alone=True) + + htmlutils.make_menu("Gérer les photos", menu_trombi, alone=True) + " | " ) H.append("
Attention: %d photos ne sont pas disponibles et ne peuvent pas être exportées.
Vous pouvez exporter seulement les photos existantes""" - % ( - nb_missing, - groups_infos.base_url + "&dialog_confirmed=1&format=%s" % format, - ), + f"""
Attention: {nb_missing} photos ne sont pas disponibles + et ne peuvent pas être exportées.
+Vous pouvez exporter seulement les photos existantes""", dest_url="trombino", OK="Exporter seulement les photos existantes", cancel_url="groups_view?curtab=tab-photos&" @@ -215,28 +213,26 @@ def check_local_photos_availability(groups_infos, format=""): parameters=parameters, ), ) - else: - return True, "" + return True, "" def _trombino_zip(groups_infos): "Send photos as zip archive" data = io.BytesIO() - Z = ZipFile(data, "w") - # assume we have the photos (or the user acknowledged the fact) - # Archive originals (not reduced) images, in JPEG - for t in groups_infos.members: - im_path = sco_photos.photo_pathname(t["photo_filename"], size="orig") - if not im_path: - continue - img = open(im_path, "rb").read() - code_nip = t["code_nip"] - if code_nip: - filename = code_nip + ".jpg" - else: - filename = f'{t["nom"]}_{t["prenom"]}_{t["etudid"]}.jpg' - Z.writestr(filename, img) - Z.close() + with ZipFile(data, "w") as zip_file: + # assume we have the photos (or the user acknowledged the fact) + # Archive originals (not reduced) images, in JPEG + for t in groups_infos.members: + im_path = sco_photos.photo_pathname(t["photo_filename"], size="orig") + if not im_path: + continue + img = open(im_path, "rb").read() + code_nip = t["code_nip"] + if code_nip: + filename = code_nip + ".jpg" + else: + filename = f'{t["nom"]}_{t["prenom"]}_{t["etudid"]}.jpg' + zip_file.writestr(filename, img) size = data.tell() log(f"trombino_zip: {size} bytes") data.seek(0) @@ -258,19 +254,22 @@ def trombino_copy_photos(group_ids=[], dialog_confirmed=False): header = html_sco_header.sco_header(page_title="Chargement des photos") footer = html_sco_header.sco_footer() if not portal_url: - return ( - header - + '
portail non configuré
' - % back_url - + footer - ) + return f"""{ header } +portail non configuré
+ + { footer } + """ if not dialog_confirmed: return scu.confirm_dialog( - """Les photos du groupe %s présentes dans ScoDoc seront remplacées par celles du portail (si elles existent).
-(les photos sont normalement automatiquement copiées lors de leur première utilisation, l'usage de cette fonction n'est nécessaire que si les photos du portail ont été modifiées)
- """ - % (groups_infos.groups_titles), + f"""Les photos du groupe {groups_infos.groups_titles} présentes + dans ScoDoc seront remplacées par celles du portail (si elles existent). +
+(les photos sont normalement automatiquement copiées + lors de leur première utilisation, l'usage de cette fonction + n'est nécessaire que si les photos du portail ont été modifiées) +
+ """, dest_url="", cancel_url=back_url, parameters={"group_ids": group_ids}, @@ -284,16 +283,16 @@ def trombino_copy_photos(group_ids=[], dialog_confirmed=False): if path: nok += 1 - msg.append("%d photos correctement chargées" % nok) + msg.append(f"{nok} photos correctement chargées") - return ( - header - + "retour au trombinoscope' % back_url - + footer - ) + return f"""{ header } +
retour au trombinoscope + { footer } + """ def _get_etud_platypus_image(t, image_width=2 * cm): @@ -323,38 +322,38 @@ def _get_etud_platypus_image(t, image_width=2 * cm): def _trombino_pdf(groups_infos): "Send photos as pdf page" # Generate PDF page - filename = "trombino_%s" % groups_infos.groups_filename + ".pdf" + filename = f"trombino_{groups_infos.groups_filename}.pdf" sem = groups_infos.formsemestre # suppose 1 seul semestre - PHOTOWIDTH = 3 * cm - COLWIDTH = 3.6 * cm + PHOTO_WIDTH = 3 * cm + COL_WIDTH = 3.6 * cm N_PER_ROW = 5 # XXX should be in ScoDoc preferences - StyleSheet = styles.getSampleStyleSheet() + style_sheet = styles.getSampleStyleSheet() report = io.BytesIO() # in-memory document, no disk file objects = [ Paragraph( SU("Trombinoscope " + sem["titreannee"] + " " + groups_infos.groups_titles), - StyleSheet["Heading3"], + style_sheet["Heading3"], ) ] L = [] n = 0 currow = [] - log("_trombino_pdf %d elements" % len(groups_infos.members)) + log(f"_trombino_pdf {len(groups_infos.members)} elements") for t in groups_infos.members: - img = _get_etud_platypus_image(t, image_width=PHOTOWIDTH) + img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH) elem = Table( [ [img], [ Paragraph( SU(sco_etud.format_nomprenom(t)), - StyleSheet["Normal"], + style_sheet["Normal"], ) ], ], - colWidths=[PHOTOWIDTH], + colWidths=[PHOTO_WIDTH], ) currow.append(elem) if n == (N_PER_ROW - 1): @@ -365,11 +364,11 @@ def _trombino_pdf(groups_infos): currow += [" "] * (N_PER_ROW - len(currow)) L.append(currow) if not L: - table = Paragraph(SU("Aucune photo à exporter !"), StyleSheet["Normal"]) + table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) else: table = Table( L, - colWidths=[COLWIDTH] * N_PER_ROW, + colWidths=[COL_WIDTH] * N_PER_ROW, style=TableStyle( [ # ('RIGHTPADDING', (0,0), (-1,-1), -5*mm), @@ -400,39 +399,36 @@ def _trombino_pdf(groups_infos): # --------------------- Sur une idée de l'IUT d'Orléans: def _listeappel_photos_pdf(groups_infos): "Doc pdf pour liste d'appel avec photos" - filename = "trombino_%s" % groups_infos.groups_filename + ".pdf" + filename = f"trombino_{groups_infos.groups_filename}.pdf" sem = groups_infos.formsemestre # suppose 1 seul semestre - PHOTOWIDTH = 2 * cm + PHOTO_WIDTH = 2 * cm # COLWIDTH = 3.6 * cm # ROWS_PER_PAGE = 26 # XXX should be in ScoDoc preferences - StyleSheet = styles.getSampleStyleSheet() + style_sheet = styles.getSampleStyleSheet() report = io.BytesIO() # in-memory document, no disk file objects = [ Paragraph( SU( - sem["titreannee"] - + " " - + groups_infos.groups_titles - + " (%d)" % len(groups_infos.members) + f"""{sem["titreannee"]} {groups_infos.groups_titles} ({len(groups_infos.members)})""" ), - StyleSheet["Heading3"], + style_sheet["Heading3"], ) ] L = [] n = 0 currow = [] - log("_listeappel_photos_pdf %d elements" % len(groups_infos.members)) + log(f"_listeappel_photos_pdf {len(groups_infos.members)} elements") n = len(groups_infos.members) # npages = n / 2*ROWS_PER_PAGE + 1 # nb de pages papier # for page in range(npages): for i in range(n): # page*2*ROWS_PER_PAGE, (page+1)*2*ROWS_PER_PAGE): t = groups_infos.members[i] - img = _get_etud_platypus_image(t, image_width=PHOTOWIDTH) + img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH) txt = Paragraph( SU(sco_etud.format_nomprenom(t)), - StyleSheet["Normal"], + style_sheet["Normal"], ) if currow: currow += [""] @@ -444,7 +440,7 @@ def _listeappel_photos_pdf(groups_infos): currow += [" "] * 3 L.append(currow) if not L: - table = Paragraph(SU("Aucune photo à exporter !"), StyleSheet["Normal"]) + table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) else: table = Table( L, @@ -544,7 +540,7 @@ def photos_import_files_form(group_ids=()): else: def callback(etud, data, filename): - sco_photos.store_photo(etud, data) + return sco_photos.store_photo(etud, data, filename) ( ignored_zipfiles, @@ -555,6 +551,7 @@ def photos_import_files_form(group_ids=()): zipfile=tf[2]["zipfile"], callback=callback, filename_title="fichier_photo", + back_url=back_url, ) return render_template( "scolar/photos_import_files.html", @@ -571,11 +568,22 @@ def photos_import_files_form(group_ids=()): ) +def _norm_zip_filename(fn, lowercase=True): + "normalisation used to match filenames" + fn = fn.replace("\\", "/") # not sure if this is necessary ? + fn = fn.strip() + if lowercase: + fn = fn.lower() + fn = fn.split("/")[-1] # use only last component, not directories + return fn + + def zip_excel_import_files( xlsfile=None, zipfile=None, callback=None, filename_title="", # doit obligatoirement etre specifié + back_url=None, ): """Importation de fichiers à partir d'un excel et d'un zip La fonction @@ -595,26 +603,22 @@ def zip_excel_import_files( try: etudid_idx = titles.index("etudid") filename_idx = titles.index(filename_title) - except: + except Exception as exc: raise ScoValueError( - "Fichier excel incorrect (il faut une colonne etudid et une colonne %s) !" - % filename_title - ) + f"Fichier excel incorrect (il faut une colonne etudid et une colonne {filename_title}) !" + ) from exc - def normfilename(fn, lowercase=True): - "normalisation used to match filenames" - fn = fn.replace("\\", "/") # not sure if this is necessary ? - fn = fn.strip() - if lowercase: - fn = fn.lower() - fn = fn.split("/")[-1] # use only last component, not directories - return fn - - filename_to_etud = {} # filename : etudid - for l in data[1:]: + filename_to_etudid = {} # filename : etudid + for line_num, l in enumerate(data[1:]): filename = l[filename_idx].strip() if filename: - filename_to_etud[normfilename(filename)] = l[etudid_idx] + try: + filename_to_etudid[_norm_zip_filename(filename)] = int(l[etudid_idx]) + except ValueError as exc: + raise ScoValueError( + f"etudid invalide ({l[etudid_idx]}) sur ligne {line_num+1} !", + dest_url=back_url, + ) from exc # 2- Ouvre le zip et try: @@ -625,35 +629,43 @@ def zip_excel_import_files( stored_etud_filename = [] # [ (etud, filename) ] for name in z.namelist(): if len(name) > 4 and name[-1] != "/" and "." in name: - data = z.read(name) + try: + data = z.read(name) + except BadZipfile as exc: + raise ScoValueError( + f"Fichier Zip incorrect: erreur sur {name}", dest_url=back_url + ) from exc # match zip filename with name given in excel - normname = normfilename(name) - if normname in filename_to_etud: - etudid = filename_to_etud[normname] + normname = _norm_zip_filename(name) + if normname in filename_to_etudid: + etudid = filename_to_etudid[normname] # ok, store photo try: etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - del filename_to_etud[normname] - except: - raise ScoValueError("ID étudiant invalide: %s" % etudid) + del filename_to_etudid[normname] + except Exception as exc: + raise ScoValueError( + f"ID étudiant invalide: {etudid}", dest_url=back_url + ) from exc - callback( + status, err_msg = callback( etud, data, - normfilename(name, lowercase=False), + _norm_zip_filename(name, lowercase=False), ) - + if not status: + raise ScoValueError(f"Erreur: {err_msg}", dest_url=back_url) stored_etud_filename.append((etud, name)) else: - log("zip: zip name %s not in excel !" % name) + log(f"zip: zip name {name} not in excel !") ignored_zipfiles.append(name) else: if name[-1] != "/": ignored_zipfiles.append(name) - log("zip: ignoring %s" % name) - if filename_to_etud: + log(f"zip: ignoring {name}") + if filename_to_etudid: # lignes excel non traitées - unmatched_files = list(filename_to_etud.keys()) + unmatched_files = list(filename_to_etudid.keys()) else: unmatched_files = [] return ignored_zipfiles, unmatched_files, stored_etud_filename diff --git a/app/views/scolar.py b/app/views/scolar.py index f558c0ed9..3d29481a4 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -1020,11 +1020,13 @@ def formChangePhoto(etudid=None): return flask.redirect(dest_url) else: data = tf[2]["photofile"].read() - status, diag = sco_photos.store_photo(etud, data) - if status != 0: + status, err_msg = sco_photos.store_photo( + etud, data, tf[2]["photofile"].filename + ) + if status: return flask.redirect(dest_url) else: - H.append('
Erreur:' + diag + "
") + H.append(f"""Erreur: {err_msg}
""") return "\n".join(H) + html_sco_header.sco_footer() diff --git a/scodoc.py b/scodoc.py index e72cc754d..2b00d2bfe 100755 --- a/scodoc.py +++ b/scodoc.py @@ -473,7 +473,6 @@ def photos_import_files(formsemestre_id: int, xlsfile: str, zipfile: str): """Import des photos d'étudiants à partir d'une liste excel et d'un zip avec les images.""" import app as mapp from app.scodoc import sco_trombino, sco_photos - from app.scodoc import notesdb as ndb from flask_login import login_user from app.auth.models import get_super_admin @@ -488,7 +487,7 @@ def photos_import_files(formsemestre_id: int, xlsfile: str, zipfile: str): login_user(admin_user) def callback(etud, data, filename): - sco_photos.store_photo(etud, data) + return sco_photos.store_photo(etud, data, filename) ( ignored_zipfiles,