From c28dcf677a13c472ea57483dd9683191cd6a4e19 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 2 Oct 2022 23:43:29 +0200 Subject: [PATCH] =?UTF-8?q?Edition=20pr=C3=A9f=C3=A9rences:=20sections=20d?= =?UTF-8?q?=C3=A9pliables.=20+=20Code=20cleaning.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but_pv.py | 278 +- app/pe/pe_avislatex.py | 1034 +++---- app/pe/pe_view.py | 360 +-- app/scodoc/TrivialFormulator.py | 48 +- app/scodoc/html_sco_header.py | 642 ++-- app/scodoc/html_sidebar.py | 344 +-- app/scodoc/safehtml.py | 160 +- app/scodoc/sco_abs_views.py | 2086 ++++++------- app/scodoc/sco_apogee_csv.py | 2716 ++++++++--------- app/scodoc/sco_archives_etud.py | 772 ++--- app/scodoc/sco_bulletins_generator.py | 736 ++--- app/scodoc/sco_cost_formation.py | 394 +-- app/scodoc/sco_dept.py | 798 ++--- app/scodoc/sco_etape_bilan.py | 1546 +++++----- app/scodoc/sco_etud.py | 2 +- app/scodoc/sco_evaluation_check_abs.py | 514 ++-- app/scodoc/sco_find_etud.py | 822 ++--- app/scodoc/sco_formsemestre_edit.py | 2 +- app/scodoc/sco_formsemestre_inscriptions.py | 2 +- app/scodoc/sco_formsemestre_validation.py | 4 +- app/scodoc/sco_import_etuds.py | 2 +- app/scodoc/sco_import_users.py | 624 ++-- app/scodoc/sco_liste_notes.py | 16 +- app/scodoc/sco_lycee.py | 518 ++-- app/scodoc/sco_moduleimpl_inscriptions.py | 1210 ++++---- app/scodoc/sco_page_etud.py | 6 +- app/scodoc/sco_placement.py | 1274 ++++---- app/scodoc/sco_preferences.py | 55 +- app/scodoc/sco_pvpdf.py | 1850 +++++------ app/scodoc/sco_report.py | 4 +- app/scodoc/sco_saisie_notes.py | 2690 ++++++++-------- app/scodoc/sco_semset.py | 1079 ++++--- app/scodoc/sco_synchro_etuds.py | 2 +- app/scodoc/sco_trombino.py | 1342 ++++---- app/scodoc/sco_ue_external.py | 768 ++--- app/scodoc/sco_users.py | 750 ++--- app/static/css/scodoc.css | 11 +- app/views/absences.py | 3040 +++++++++---------- app/views/notes.py | 10 +- app/views/scolar.py | 6 +- app/views/users.py | 2006 ++++++------ 41 files changed, 15287 insertions(+), 15236 deletions(-) diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py index f969be91..2940345e 100644 --- a/app/but/jury_but_pv.py +++ b/app/but/jury_but_pv.py @@ -1,139 +1,139 @@ -############################################################################## -# ScoDoc -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# See LICENSE -############################################################################## - -"""Jury BUT: table synthèse résultats semestre / PV -""" -from flask import g, request, url_for - -from openpyxl.styles import Font, Border, Side, Alignment, PatternFill - -from app import log -from app.but import jury_but -from app.models.etudiants import Identite -from app.models.formsemestre import FormSemestre -from app.scodoc.gen_tables import GenTable -from app.scodoc import sco_excel -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc import sco_preferences -from app.scodoc import sco_utils as scu - - -def _descr_cursus_but(etud: Identite) -> str: - "description de la liste des semestres BUT suivis" - # prend simplement tous les semestre de type APC, ce qui sera faux si - # l'étudiant change de spécialité au sein du même département - # (ce qui ne peut normalement pas se produire) - indices = sorted( - [ - ins.formsemestre.semestre_id - if ins.formsemestre.semestre_id is not None - else -1 - for ins in etud.formsemestre_inscriptions - if ins.formsemestre.formation.is_apc() - ] - ) - return ", ".join(f"S{indice}" for indice in indices) - - -def pvjury_table_but(formsemestre_id: int, format="html"): - """Page récapitulant les décisions de jury BUT - formsemestre peut être pair ou impair - """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - assert formsemestre.formation.is_apc() - title = "Procès-verbal de jury BUT annuel" - - if format == "html": - line_sep = "
" - else: - line_sep = "\n" - # remplace pour le BUT la fonction sco_pvjury.pvjury_table - annee_but = (formsemestre.semestre_id + 1) // 2 - titles = { - "nom": "Nom", - "cursus": "Cursus", - "ues": "UE validées", - "niveaux": "Niveaux de compétences validés", - "decision_but": f"Décision BUT{annee_but}", - "diplome": "Résultat au diplôme", - "devenir": "Devenir", - "observations": "Observations", - } - rows = [] - for etudid in formsemestre.etuds_inscriptions: - etud: Identite = Identite.query.get(etudid) - try: - deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - if deca.annee_but != annee_but: # wtf ? - log( - f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}" - ) - continue - except ScoValueError: - deca = None - row = { - "nom": etud.etat_civil_pv(line_sep=line_sep), - "_nom_order": etud.sort_key, - "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', - "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', - "_nom_target": url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etud.id, - ), - "cursus": _descr_cursus_but(etud), - "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", - "niveaux": deca.descr_niveaux_validation(line_sep=line_sep) - if deca - else "-", - "decision_but": deca.code_valide if deca else "", - "devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()]) - if deca - else "", - } - - rows.append(row) - - rows.sort(key=lambda x: x["_nom_order"]) - - # Style excel... passages à la ligne sur \n - xls_style_base = sco_excel.excel_make_style() - xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top") - - tab = GenTable( - base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}", - caption=title, - columns_ids=titles.keys(), - html_caption=title, - html_class="pvjury_table_but table_leftalign", - html_title=f"""
{title} - - version excel
- - """, - html_with_td_classes=True, - origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}", - page_title=title, - pdf_title=title, - preferences=sco_preferences.SemPreferences(), - rows=rows, - table_id="formation_table_recap", - titles=titles, - xls_columns_width={ - "nom": 32, - "cursus": 12, - "ues": 32, - "niveaux": 32, - "decision_but": 14, - "diplome": 17, - "devenir": 8, - "observations": 12, - }, - xls_style_base=xls_style_base, - ) - return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True) +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: table synthèse résultats semestre / PV +""" +from flask import g, request, url_for + +from openpyxl.styles import Font, Border, Side, Alignment, PatternFill + +from app import log +from app.but import jury_but +from app.models.etudiants import Identite +from app.models.formsemestre import FormSemestre +from app.scodoc.gen_tables import GenTable +from app.scodoc import sco_excel +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_preferences +from app.scodoc import sco_utils as scu + + +def _descr_cursus_but(etud: Identite) -> str: + "description de la liste des semestres BUT suivis" + # prend simplement tous les semestre de type APC, ce qui sera faux si + # l'étudiant change de spécialité au sein du même département + # (ce qui ne peut normalement pas se produire) + indices = sorted( + [ + ins.formsemestre.semestre_id + if ins.formsemestre.semestre_id is not None + else -1 + for ins in etud.formsemestre_inscriptions + if ins.formsemestre.formation.is_apc() + ] + ) + return ", ".join(f"S{indice}" for indice in indices) + + +def pvjury_table_but(formsemestre_id: int, format="html"): + """Page récapitulant les décisions de jury BUT + formsemestre peut être pair ou impair + """ + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + assert formsemestre.formation.is_apc() + title = "Procès-verbal de jury BUT annuel" + + if format == "html": + line_sep = "
" + else: + line_sep = "\n" + # remplace pour le BUT la fonction sco_pvjury.pvjury_table + annee_but = (formsemestre.semestre_id + 1) // 2 + titles = { + "nom": "Nom", + "cursus": "Cursus", + "ues": "UE validées", + "niveaux": "Niveaux de compétences validés", + "decision_but": f"Décision BUT{annee_but}", + "diplome": "Résultat au diplôme", + "devenir": "Devenir", + "observations": "Observations", + } + rows = [] + for etudid in formsemestre.etuds_inscriptions: + etud: Identite = Identite.query.get(etudid) + try: + deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) + if deca.annee_but != annee_but: # wtf ? + log( + f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}" + ) + continue + except ScoValueError: + deca = None + row = { + "nom": etud.etat_civil_pv(line_sep=line_sep), + "_nom_order": etud.sort_key, + "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', + "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', + "_nom_target": url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + ), + "cursus": _descr_cursus_but(etud), + "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", + "niveaux": deca.descr_niveaux_validation(line_sep=line_sep) + if deca + else "-", + "decision_but": deca.code_valide if deca else "", + "devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()]) + if deca + else "", + } + + rows.append(row) + + rows.sort(key=lambda x: x["_nom_order"]) + + # Style excel... passages à la ligne sur \n + xls_style_base = sco_excel.excel_make_style() + xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top") + + tab = GenTable( + base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}", + caption=title, + columns_ids=titles.keys(), + html_caption=title, + html_class="pvjury_table_but table_leftalign", + html_title=f"""
{title} + + version excel
+ + """, + html_with_td_classes=True, + origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}", + page_title=title, + pdf_title=title, + preferences=sco_preferences.SemPreferences(), + rows=rows, + table_id="formation_table_recap", + titles=titles, + xls_columns_width={ + "nom": 32, + "cursus": 12, + "ues": 32, + "niveaux": 32, + "decision_but": 14, + "diplome": 17, + "devenir": 8, + "observations": 12, + }, + xls_style_base=xls_style_base, + ) + return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True) diff --git a/app/pe/pe_avislatex.py b/app/pe/pe_avislatex.py index f4062ad9..5a507738 100644 --- a/app/pe/pe_avislatex.py +++ b/app/pe/pe_avislatex.py @@ -1,517 +1,517 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -import os -import codecs -import re -from app.pe import pe_tagtable -from app.pe import pe_jurype -from app.pe import pe_tools - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log -from app.scodoc.gen_tables import GenTable, SeqGenTable -from app.scodoc import sco_preferences -from app.scodoc import sco_etud - - -DEBUG = False # Pour debug et repérage des prints à changer en Log - -DONNEE_MANQUANTE = ( - "" # Caractère de remplacement des données manquantes dans un avis PE -) - -# ---------------------------------------------------------------------------------------- -def get_code_latex_from_modele(fichier): - """Lit le code latex à partir d'un modèle. Renvoie une chaine unicode. - - Le fichier doit contenir le chemin relatif - vers le modele : attention pas de vérification du format d'encodage - Le fichier doit donc etre enregistré avec le même codage que ScoDoc (utf-8) - """ - fid_latex = codecs.open(fichier, "r", encoding=scu.SCO_ENCODING) - un_avis_latex = fid_latex.read() - fid_latex.close() - return un_avis_latex - - -# ---------------------------------------------------------------------------------------- -def get_code_latex_from_scodoc_preference(formsemestre_id, champ="pe_avis_latex_tmpl"): - """ - Extrait le template (ou le tag d'annotation au regard du champ fourni) des préférences LaTeX - et s'assure qu'il est renvoyé au format unicode - """ - template_latex = sco_preferences.get_preference(champ, formsemestre_id) - - return template_latex or "" - - -# ---------------------------------------------------------------------------------------- -def get_tags_latex(code_latex): - """Recherche tous les tags présents dans un code latex (ce code étant obtenu - à la lecture d'un modèle d'avis pe). - Ces tags sont répérés par les balises **, débutant et finissant le tag - et sont renvoyés sous la forme d'une liste. - - result: liste de chaines unicode - """ - if code_latex: - # changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})" - res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex) - return [tag[2:-2] for tag in res] - else: - return [] - - -def comp_latex_parcourstimeline(etudiant, promo, taille=17): - """Interprète un tag dans un avis latex **parcourstimeline** - et génère le code latex permettant de retracer le parcours d'un étudiant - sous la forme d'une frise temporelle. - Nota: modeles/parcourstimeline.tex doit avoir été inclu dans le préambule - - result: chaine unicode (EV:) - """ - codelatexDebut = ( - """" - \\begin{parcourstimeline}{**debut**}{**fin**}{**nbreSemestres**}{%d} - """ - % taille - ) - - modeleEvent = """ - \\parcoursevent{**nosem**}{**nomsem**}{**descr**} - """ - - codelatexFin = """ - \\end{parcourstimeline} - """ - reslatex = codelatexDebut - reslatex = reslatex.replace("**debut**", etudiant["entree"]) - reslatex = reslatex.replace("**fin**", str(etudiant["promo"])) - reslatex = reslatex.replace("**nbreSemestres**", str(etudiant["nbSemestres"])) - # Tri du parcours par ordre croissant : de la forme descr, nom sem date-date - parcours = etudiant["parcours"][::-1] # EV: XXX je ne comprend pas ce commentaire ? - - for no_sem in range(etudiant["nbSemestres"]): - descr = modeleEvent - nom_semestre_dans_parcours = parcours[no_sem]["nom_semestre_dans_parcours"] - descr = descr.replace("**nosem**", str(no_sem + 1)) - if no_sem % 2 == 0: - descr = descr.replace("**nomsem**", nom_semestre_dans_parcours) - descr = descr.replace("**descr**", "") - else: - descr = descr.replace("**nomsem**", "") - descr = descr.replace("**descr**", nom_semestre_dans_parcours) - reslatex += descr - reslatex += codelatexFin - return reslatex - - -# ---------------------------------------------------------------------------------------- -def interprete_tag_latex(tag): - """Découpe les tags latex de la forme S1:groupe:dut:min et renvoie si possible - le résultat sous la forme d'un quadruplet. - """ - infotag = tag.split(":") - if len(infotag) == 4: - return ( - infotag[0].upper(), - infotag[1].lower(), - infotag[2].lower(), - infotag[3].lower(), - ) - else: - return (None, None, None, None) - - -# ---------------------------------------------------------------------------------------- -def get_code_latex_avis_etudiant( - donnees_etudiant, un_avis_latex, annotationPE, footer_latex, prefs -): - """ - Renvoie le code latex permettant de générer l'avis d'un étudiant en utilisant ses - donnees_etudiant contenu dans le dictionnaire de synthèse du jury PE et en suivant un - fichier modele donné - - result: chaine unicode - """ - if not donnees_etudiant or not un_avis_latex: # Cas d'un template vide - return annotationPE if annotationPE else "" - - # Le template latex (corps + footer) - code = un_avis_latex + "\n\n" + footer_latex - - # Recherche des tags dans le fichier - tags_latex = get_tags_latex(code) - if DEBUG: - log("Les tags" + str(tags_latex)) - - # Interprète et remplace chaque tags latex par les données numériques de l'étudiant (y compris les - # tags "macros" tels que parcourstimeline - for tag_latex in tags_latex: - # les tags numériques - valeur = DONNEE_MANQUANTE - - if ":" in tag_latex: - (aggregat, groupe, tag_scodoc, champ) = interprete_tag_latex(tag_latex) - valeur = str_from_syntheseJury( - donnees_etudiant, aggregat, groupe, tag_scodoc, champ - ) - - # La macro parcourstimeline - elif tag_latex == "parcourstimeline": - valeur = comp_latex_parcourstimeline( - donnees_etudiant, donnees_etudiant["promo"] - ) - - # Le tag annotationPE - elif tag_latex == "annotation": - valeur = annotationPE - - # Le tag bilanParTag - elif tag_latex == "bilanParTag": - valeur = get_bilanParTag(donnees_etudiant) - - # Les tags "simples": par ex. nom, prenom, civilite, ... - else: - if tag_latex in donnees_etudiant: - valeur = donnees_etudiant[tag_latex] - elif tag_latex in prefs: # les champs **NomResponsablePE**, ... - valeur = pe_tools.escape_for_latex(prefs[tag_latex]) - - # Vérification des pb d'encodage (debug) - # assert isinstance(tag_latex, unicode) - # assert isinstance(valeur, unicode) - - # Substitution - code = code.replace("**" + tag_latex + "**", valeur) - return code - - -# ---------------------------------------------------------------------------------------- -def get_annotation_PE(etudid, tag_annotation_pe): - """Renvoie l'annotation PE dans la liste de ces annotations ; - Cette annotation est reconnue par la présence d'un tag **PE** - (cf. .get_preferences -> pe_tag_annotation_avis_latex). - - Result: chaine unicode - """ - if tag_annotation_pe: - cnx = ndb.GetDBConnexion() - annotations = sco_etud.etud_annotations_list( - cnx, args={"etudid": etudid} - ) # Les annotations de l'étudiant - annotationsPE = [] - - exp = re.compile(r"^" + tag_annotation_pe) - - for a in annotations: - commentaire = scu.unescape_html(a["comment"]) - if exp.match(commentaire): # tag en début de commentaire ? - a["comment_u"] = commentaire # unicode, HTML non quoté - annotationsPE.append( - a - ) # sauvegarde l'annotation si elle contient le tag - - if annotationsPE: # Si des annotations existent, prend la plus récente - annotationPE = sorted(annotationsPE, key=lambda a: a["date"], reverse=True)[ - 0 - ]["comment_u"] - - annotationPE = exp.sub( - "", annotationPE - ) # Suppression du tag d'annotation PE - annotationPE = annotationPE.replace("\r", "") # Suppression des \r - annotationPE = annotationPE.replace( - "
", "\n\n" - ) # Interprète les retours chariots html - return annotationPE - return "" # pas d'annotations - - -# ---------------------------------------------------------------------------------------- -def str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, champ): - """Extrait du dictionnaire de synthèse du juryPE pour un étudiant donnée, - une valeur indiquée par un champ ; - si champ est une liste, renvoie la liste des valeurs extraites. - - Result: chaine unicode ou liste de chaines unicode - """ - - if isinstance(champ, list): - return [ - str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, chp) - for chp in champ - ] - else: # champ = str à priori - valeur = DONNEE_MANQUANTE - if ( - (aggregat in donnees_etudiant) - and (groupe in donnees_etudiant[aggregat]) - and (tag_scodoc in donnees_etudiant[aggregat][groupe]) - ): - donnees_numeriques = donnees_etudiant[aggregat][groupe][tag_scodoc] - if champ == "rang": - valeur = "%s/%d" % ( - donnees_numeriques[ - pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index("rang") - ], - donnees_numeriques[ - pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index( - "nbinscrits" - ) - ], - ) - elif champ in pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS: - indice_champ = pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index( - champ - ) - if ( - len(donnees_numeriques) > indice_champ - and donnees_numeriques[indice_champ] != None - ): - if isinstance( - donnees_numeriques[indice_champ], float - ): # valeur numérique avec formattage unicode - valeur = "%2.2f" % donnees_numeriques[indice_champ] - else: - valeur = "%s" % donnees_numeriques[indice_champ] - - return valeur - - -# ---------------------------------------------------------------------------------------- -def get_bilanParTag(donnees_etudiant, groupe="groupe"): - """Renvoie le code latex d'un tableau récapitulant, pour tous les tags trouvés dans - les données étudiants, ses résultats. - result: chaine unicode - """ - - entete = [ - ( - agg, - pe_jurype.JuryPE.PARCOURS[agg]["affichage_court"], - pe_jurype.JuryPE.PARCOURS[agg]["ordre"], - ) - for agg in pe_jurype.JuryPE.PARCOURS - ] - entete = sorted(entete, key=lambda t: t[2]) - - lignes = [] - valeurs = {"note": [], "rang": []} - for (indice_aggregat, (aggregat, intitule, _)) in enumerate(entete): - # print("> " + aggregat) - # listeTags = jury.get_allTagForAggregat(aggregat) # les tags de l'aggrégat - listeTags = [ - tag for tag in donnees_etudiant[aggregat][groupe].keys() if tag != "dut" - ] # - for tag in listeTags: - - if tag not in lignes: - lignes.append(tag) - valeurs["note"].append( - [""] * len(entete) - ) # Ajout d'une ligne de données - valeurs["rang"].append( - [""] * len(entete) - ) # Ajout d'une ligne de données - indice_tag = lignes.index(tag) # l'indice de ligne du tag - - # print(" --- " + tag + "(" + str(indice_tag) + "," + str(indice_aggregat) + ")") - [note, rang] = str_from_syntheseJury( - donnees_etudiant, aggregat, groupe, tag, ["note", "rang"] - ) - valeurs["note"][indice_tag][indice_aggregat] = "" + note + "" - valeurs["rang"][indice_tag][indice_aggregat] = ( - ("\\textit{" + rang + "}") if note else "" - ) # rang masqué si pas de notes - - code_latex = "\\begin{tabular}{|c|" + "|c" * (len(entete)) + "|}\n" - code_latex += "\\hline \n" - code_latex += ( - " & " - + " & ".join(["\\textbf{" + intitule + "}" for (agg, intitule, _) in entete]) - + " \\\\ \n" - ) - code_latex += "\\hline" - code_latex += "\\hline \n" - for (i, ligne_val) in enumerate(valeurs["note"]): - titre = lignes[i] # règle le pb d'encodage - code_latex += "\\textbf{" + titre + "} & " + " & ".join(ligne_val) + "\\\\ \n" - code_latex += ( - " & " - + " & ".join( - ["{\\scriptsize " + clsmt + "}" for clsmt in valeurs["rang"][i]] - ) - + "\\\\ \n" - ) - code_latex += "\\hline \n" - code_latex += "\\end{tabular}" - - return code_latex - - -# ---------------------------------------------------------------------------------------- -def get_avis_poursuite_par_etudiant( - jury, etudid, template_latex, tag_annotation_pe, footer_latex, prefs -): - """Renvoie un nom de fichier et le contenu de l'avis latex d'un étudiant dont l'etudid est fourni. - result: [ chaine unicode, chaine unicode ] - """ - if pe_tools.PE_DEBUG: - pe_tools.pe_print(jury.syntheseJury[etudid]["nom"] + " " + str(etudid)) - - civilite_str = jury.syntheseJury[etudid]["civilite_str"] - nom = jury.syntheseJury[etudid]["nom"].replace(" ", "-") - prenom = jury.syntheseJury[etudid]["prenom"].replace(" ", "-") - - nom_fichier = scu.sanitize_filename( - "avis_poursuite_%s_%s_%s" % (nom, prenom, etudid) - ) - if pe_tools.PE_DEBUG: - pe_tools.pe_print("fichier latex =" + nom_fichier, type(nom_fichier)) - - # Entete (commentaire) - contenu_latex = ( - "%% ---- Etudiant: " + civilite_str + " " + nom + " " + prenom + "\n" - ) - - # les annnotations - annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe) - if pe_tools.PE_DEBUG: - pe_tools.pe_print(annotationPE, type(annotationPE)) - - # le LaTeX - avis = get_code_latex_avis_etudiant( - jury.syntheseJury[etudid], template_latex, annotationPE, footer_latex, prefs - ) - # if pe_tools.PE_DEBUG: pe_tools.pe_print(avis, type(avis)) - contenu_latex += avis + "\n" - - return [nom_fichier, contenu_latex] - - -def get_templates_from_distrib(template="avis"): - """Récupère le template (soit un_avis.tex soit le footer.tex) à partir des fichiers mémorisés dans la distrib des avis pe (distrib local - ou par défaut et le renvoie""" - if template == "avis": - pe_local_tmpl = pe_tools.PE_LOCAL_AVIS_LATEX_TMPL - pe_default_tmpl = pe_tools.PE_DEFAULT_AVIS_LATEX_TMPL - elif template == "footer": - pe_local_tmpl = pe_tools.PE_LOCAL_FOOTER_TMPL - pe_default_tmpl = pe_tools.PE_DEFAULT_FOOTER_TMPL - - if template in ["avis", "footer"]: - # pas de preference pour le template: utilise fichier du serveur - if os.path.exists(pe_local_tmpl): - template_latex = get_code_latex_from_modele(pe_local_tmpl) - else: - if os.path.exists(pe_default_tmpl): - template_latex = get_code_latex_from_modele(pe_default_tmpl) - else: - template_latex = "" # fallback: avis vides - return template_latex - - -# ---------------------------------------------------------------------------------------- -def table_syntheseAnnotationPE(syntheseJury, tag_annotation_pe): - """Génère un fichier excel synthétisant les annotations PE telles qu'inscrites dans les fiches de chaque étudiant""" - sT = SeqGenTable() # le fichier excel à générer - - # Les etudids des étudiants à afficher, triés par ordre alphabétiques de nom+prénom - donnees_tries = sorted( - [ - (etudid, syntheseJury[etudid]["nom"] + " " + syntheseJury[etudid]["prenom"]) - for etudid in syntheseJury.keys() - ], - key=lambda c: c[1], - ) - etudids = [e[0] for e in donnees_tries] - if not etudids: # Si pas d'étudiants - T = GenTable( - columns_ids=["pas d'étudiants"], - rows=[], - titles={"pas d'étudiants": "pas d'étudiants"}, - html_sortable=True, - xls_sheet_name="dut", - ) - sT.add_genTable("Annotation PE", T) - return sT - - # Si des étudiants - maxParcours = max( - [syntheseJury[etudid]["nbSemestres"] for etudid in etudids] - ) # le nombre de semestre le + grand - - infos = ["civilite", "nom", "prenom", "age", "nbSemestres"] - entete = ["etudid"] - entete.extend(infos) - entete.extend(["P%d" % i for i in range(1, maxParcours + 1)]) # ajout du parcours - entete.append("Annotation PE") - columns_ids = entete # les id et les titres de colonnes sont ici identiques - titles = {i: i for i in columns_ids} - - rows = [] - for ( - etudid - ) in etudids: # parcours des étudiants par ordre alphabétique des nom+prénom - e = syntheseJury[etudid] - # Les info générales: - row = { - "etudid": etudid, - "civilite": e["civilite"], - "nom": e["nom"], - "prenom": e["prenom"], - "age": e["age"], - "nbSemestres": e["nbSemestres"], - } - # Les parcours: P1, P2, ... - n = 1 - for p in e["parcours"]: - row["P%d" % n] = p["titreannee"] - n += 1 - - # L'annotation PE - annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe) - row["Annotation PE"] = annotationPE if annotationPE else "" - rows.append(row) - - T = GenTable( - columns_ids=columns_ids, - rows=rows, - titles=titles, - html_sortable=True, - xls_sheet_name="Annotation PE", - ) - sT.add_genTable("Annotation PE", T) - return sT +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +import os +import codecs +import re +from app.pe import pe_tagtable +from app.pe import pe_jurype +from app.pe import pe_tools + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log +from app.scodoc.gen_tables import GenTable, SeqGenTable +from app.scodoc import sco_preferences +from app.scodoc import sco_etud + + +DEBUG = False # Pour debug et repérage des prints à changer en Log + +DONNEE_MANQUANTE = ( + "" # Caractère de remplacement des données manquantes dans un avis PE +) + +# ---------------------------------------------------------------------------------------- +def get_code_latex_from_modele(fichier): + """Lit le code latex à partir d'un modèle. Renvoie une chaine unicode. + + Le fichier doit contenir le chemin relatif + vers le modele : attention pas de vérification du format d'encodage + Le fichier doit donc etre enregistré avec le même codage que ScoDoc (utf-8) + """ + fid_latex = codecs.open(fichier, "r", encoding=scu.SCO_ENCODING) + un_avis_latex = fid_latex.read() + fid_latex.close() + return un_avis_latex + + +# ---------------------------------------------------------------------------------------- +def get_code_latex_from_scodoc_preference(formsemestre_id, champ="pe_avis_latex_tmpl"): + """ + Extrait le template (ou le tag d'annotation au regard du champ fourni) des préférences LaTeX + et s'assure qu'il est renvoyé au format unicode + """ + template_latex = sco_preferences.get_preference(champ, formsemestre_id) + + return template_latex or "" + + +# ---------------------------------------------------------------------------------------- +def get_tags_latex(code_latex): + """Recherche tous les tags présents dans un code latex (ce code étant obtenu + à la lecture d'un modèle d'avis pe). + Ces tags sont répérés par les balises **, débutant et finissant le tag + et sont renvoyés sous la forme d'une liste. + + result: liste de chaines unicode + """ + if code_latex: + # changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})" + res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex) + return [tag[2:-2] for tag in res] + else: + return [] + + +def comp_latex_parcourstimeline(etudiant, promo, taille=17): + """Interprète un tag dans un avis latex **parcourstimeline** + et génère le code latex permettant de retracer le parcours d'un étudiant + sous la forme d'une frise temporelle. + Nota: modeles/parcourstimeline.tex doit avoir été inclu dans le préambule + + result: chaine unicode (EV:) + """ + codelatexDebut = ( + """" + \\begin{parcourstimeline}{**debut**}{**fin**}{**nbreSemestres**}{%d} + """ + % taille + ) + + modeleEvent = """ + \\parcoursevent{**nosem**}{**nomsem**}{**descr**} + """ + + codelatexFin = """ + \\end{parcourstimeline} + """ + reslatex = codelatexDebut + reslatex = reslatex.replace("**debut**", etudiant["entree"]) + reslatex = reslatex.replace("**fin**", str(etudiant["promo"])) + reslatex = reslatex.replace("**nbreSemestres**", str(etudiant["nbSemestres"])) + # Tri du parcours par ordre croissant : de la forme descr, nom sem date-date + parcours = etudiant["parcours"][::-1] # EV: XXX je ne comprend pas ce commentaire ? + + for no_sem in range(etudiant["nbSemestres"]): + descr = modeleEvent + nom_semestre_dans_parcours = parcours[no_sem]["nom_semestre_dans_parcours"] + descr = descr.replace("**nosem**", str(no_sem + 1)) + if no_sem % 2 == 0: + descr = descr.replace("**nomsem**", nom_semestre_dans_parcours) + descr = descr.replace("**descr**", "") + else: + descr = descr.replace("**nomsem**", "") + descr = descr.replace("**descr**", nom_semestre_dans_parcours) + reslatex += descr + reslatex += codelatexFin + return reslatex + + +# ---------------------------------------------------------------------------------------- +def interprete_tag_latex(tag): + """Découpe les tags latex de la forme S1:groupe:dut:min et renvoie si possible + le résultat sous la forme d'un quadruplet. + """ + infotag = tag.split(":") + if len(infotag) == 4: + return ( + infotag[0].upper(), + infotag[1].lower(), + infotag[2].lower(), + infotag[3].lower(), + ) + else: + return (None, None, None, None) + + +# ---------------------------------------------------------------------------------------- +def get_code_latex_avis_etudiant( + donnees_etudiant, un_avis_latex, annotationPE, footer_latex, prefs +): + """ + Renvoie le code latex permettant de générer l'avis d'un étudiant en utilisant ses + donnees_etudiant contenu dans le dictionnaire de synthèse du jury PE et en suivant un + fichier modele donné + + result: chaine unicode + """ + if not donnees_etudiant or not un_avis_latex: # Cas d'un template vide + return annotationPE if annotationPE else "" + + # Le template latex (corps + footer) + code = un_avis_latex + "\n\n" + footer_latex + + # Recherche des tags dans le fichier + tags_latex = get_tags_latex(code) + if DEBUG: + log("Les tags" + str(tags_latex)) + + # Interprète et remplace chaque tags latex par les données numériques de l'étudiant (y compris les + # tags "macros" tels que parcourstimeline + for tag_latex in tags_latex: + # les tags numériques + valeur = DONNEE_MANQUANTE + + if ":" in tag_latex: + (aggregat, groupe, tag_scodoc, champ) = interprete_tag_latex(tag_latex) + valeur = str_from_syntheseJury( + donnees_etudiant, aggregat, groupe, tag_scodoc, champ + ) + + # La macro parcourstimeline + elif tag_latex == "parcourstimeline": + valeur = comp_latex_parcourstimeline( + donnees_etudiant, donnees_etudiant["promo"] + ) + + # Le tag annotationPE + elif tag_latex == "annotation": + valeur = annotationPE + + # Le tag bilanParTag + elif tag_latex == "bilanParTag": + valeur = get_bilanParTag(donnees_etudiant) + + # Les tags "simples": par ex. nom, prenom, civilite, ... + else: + if tag_latex in donnees_etudiant: + valeur = donnees_etudiant[tag_latex] + elif tag_latex in prefs: # les champs **NomResponsablePE**, ... + valeur = pe_tools.escape_for_latex(prefs[tag_latex]) + + # Vérification des pb d'encodage (debug) + # assert isinstance(tag_latex, unicode) + # assert isinstance(valeur, unicode) + + # Substitution + code = code.replace("**" + tag_latex + "**", valeur) + return code + + +# ---------------------------------------------------------------------------------------- +def get_annotation_PE(etudid, tag_annotation_pe): + """Renvoie l'annotation PE dans la liste de ces annotations ; + Cette annotation est reconnue par la présence d'un tag **PE** + (cf. .get_preferences -> pe_tag_annotation_avis_latex). + + Result: chaine unicode + """ + if tag_annotation_pe: + cnx = ndb.GetDBConnexion() + annotations = sco_etud.etud_annotations_list( + cnx, args={"etudid": etudid} + ) # Les annotations de l'étudiant + annotationsPE = [] + + exp = re.compile(r"^" + tag_annotation_pe) + + for a in annotations: + commentaire = scu.unescape_html(a["comment"]) + if exp.match(commentaire): # tag en début de commentaire ? + a["comment_u"] = commentaire # unicode, HTML non quoté + annotationsPE.append( + a + ) # sauvegarde l'annotation si elle contient le tag + + if annotationsPE: # Si des annotations existent, prend la plus récente + annotationPE = sorted(annotationsPE, key=lambda a: a["date"], reverse=True)[ + 0 + ]["comment_u"] + + annotationPE = exp.sub( + "", annotationPE + ) # Suppression du tag d'annotation PE + annotationPE = annotationPE.replace("\r", "") # Suppression des \r + annotationPE = annotationPE.replace( + "
", "\n\n" + ) # Interprète les retours chariots html + return annotationPE + return "" # pas d'annotations + + +# ---------------------------------------------------------------------------------------- +def str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, champ): + """Extrait du dictionnaire de synthèse du juryPE pour un étudiant donnée, + une valeur indiquée par un champ ; + si champ est une liste, renvoie la liste des valeurs extraites. + + Result: chaine unicode ou liste de chaines unicode + """ + + if isinstance(champ, list): + return [ + str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, chp) + for chp in champ + ] + else: # champ = str à priori + valeur = DONNEE_MANQUANTE + if ( + (aggregat in donnees_etudiant) + and (groupe in donnees_etudiant[aggregat]) + and (tag_scodoc in donnees_etudiant[aggregat][groupe]) + ): + donnees_numeriques = donnees_etudiant[aggregat][groupe][tag_scodoc] + if champ == "rang": + valeur = "%s/%d" % ( + donnees_numeriques[ + pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index("rang") + ], + donnees_numeriques[ + pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index( + "nbinscrits" + ) + ], + ) + elif champ in pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS: + indice_champ = pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index( + champ + ) + if ( + len(donnees_numeriques) > indice_champ + and donnees_numeriques[indice_champ] != None + ): + if isinstance( + donnees_numeriques[indice_champ], float + ): # valeur numérique avec formattage unicode + valeur = "%2.2f" % donnees_numeriques[indice_champ] + else: + valeur = "%s" % donnees_numeriques[indice_champ] + + return valeur + + +# ---------------------------------------------------------------------------------------- +def get_bilanParTag(donnees_etudiant, groupe="groupe"): + """Renvoie le code latex d'un tableau récapitulant, pour tous les tags trouvés dans + les données étudiants, ses résultats. + result: chaine unicode + """ + + entete = [ + ( + agg, + pe_jurype.JuryPE.PARCOURS[agg]["affichage_court"], + pe_jurype.JuryPE.PARCOURS[agg]["ordre"], + ) + for agg in pe_jurype.JuryPE.PARCOURS + ] + entete = sorted(entete, key=lambda t: t[2]) + + lignes = [] + valeurs = {"note": [], "rang": []} + for (indice_aggregat, (aggregat, intitule, _)) in enumerate(entete): + # print("> " + aggregat) + # listeTags = jury.get_allTagForAggregat(aggregat) # les tags de l'aggrégat + listeTags = [ + tag for tag in donnees_etudiant[aggregat][groupe].keys() if tag != "dut" + ] # + for tag in listeTags: + + if tag not in lignes: + lignes.append(tag) + valeurs["note"].append( + [""] * len(entete) + ) # Ajout d'une ligne de données + valeurs["rang"].append( + [""] * len(entete) + ) # Ajout d'une ligne de données + indice_tag = lignes.index(tag) # l'indice de ligne du tag + + # print(" --- " + tag + "(" + str(indice_tag) + "," + str(indice_aggregat) + ")") + [note, rang] = str_from_syntheseJury( + donnees_etudiant, aggregat, groupe, tag, ["note", "rang"] + ) + valeurs["note"][indice_tag][indice_aggregat] = "" + note + "" + valeurs["rang"][indice_tag][indice_aggregat] = ( + ("\\textit{" + rang + "}") if note else "" + ) # rang masqué si pas de notes + + code_latex = "\\begin{tabular}{|c|" + "|c" * (len(entete)) + "|}\n" + code_latex += "\\hline \n" + code_latex += ( + " & " + + " & ".join(["\\textbf{" + intitule + "}" for (agg, intitule, _) in entete]) + + " \\\\ \n" + ) + code_latex += "\\hline" + code_latex += "\\hline \n" + for (i, ligne_val) in enumerate(valeurs["note"]): + titre = lignes[i] # règle le pb d'encodage + code_latex += "\\textbf{" + titre + "} & " + " & ".join(ligne_val) + "\\\\ \n" + code_latex += ( + " & " + + " & ".join( + ["{\\scriptsize " + clsmt + "}" for clsmt in valeurs["rang"][i]] + ) + + "\\\\ \n" + ) + code_latex += "\\hline \n" + code_latex += "\\end{tabular}" + + return code_latex + + +# ---------------------------------------------------------------------------------------- +def get_avis_poursuite_par_etudiant( + jury, etudid, template_latex, tag_annotation_pe, footer_latex, prefs +): + """Renvoie un nom de fichier et le contenu de l'avis latex d'un étudiant dont l'etudid est fourni. + result: [ chaine unicode, chaine unicode ] + """ + if pe_tools.PE_DEBUG: + pe_tools.pe_print(jury.syntheseJury[etudid]["nom"] + " " + str(etudid)) + + civilite_str = jury.syntheseJury[etudid]["civilite_str"] + nom = jury.syntheseJury[etudid]["nom"].replace(" ", "-") + prenom = jury.syntheseJury[etudid]["prenom"].replace(" ", "-") + + nom_fichier = scu.sanitize_filename( + "avis_poursuite_%s_%s_%s" % (nom, prenom, etudid) + ) + if pe_tools.PE_DEBUG: + pe_tools.pe_print("fichier latex =" + nom_fichier, type(nom_fichier)) + + # Entete (commentaire) + contenu_latex = ( + "%% ---- Etudiant: " + civilite_str + " " + nom + " " + prenom + "\n" + ) + + # les annnotations + annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe) + if pe_tools.PE_DEBUG: + pe_tools.pe_print(annotationPE, type(annotationPE)) + + # le LaTeX + avis = get_code_latex_avis_etudiant( + jury.syntheseJury[etudid], template_latex, annotationPE, footer_latex, prefs + ) + # if pe_tools.PE_DEBUG: pe_tools.pe_print(avis, type(avis)) + contenu_latex += avis + "\n" + + return [nom_fichier, contenu_latex] + + +def get_templates_from_distrib(template="avis"): + """Récupère le template (soit un_avis.tex soit le footer.tex) à partir des fichiers mémorisés dans la distrib des avis pe (distrib local + ou par défaut et le renvoie""" + if template == "avis": + pe_local_tmpl = pe_tools.PE_LOCAL_AVIS_LATEX_TMPL + pe_default_tmpl = pe_tools.PE_DEFAULT_AVIS_LATEX_TMPL + elif template == "footer": + pe_local_tmpl = pe_tools.PE_LOCAL_FOOTER_TMPL + pe_default_tmpl = pe_tools.PE_DEFAULT_FOOTER_TMPL + + if template in ["avis", "footer"]: + # pas de preference pour le template: utilise fichier du serveur + if os.path.exists(pe_local_tmpl): + template_latex = get_code_latex_from_modele(pe_local_tmpl) + else: + if os.path.exists(pe_default_tmpl): + template_latex = get_code_latex_from_modele(pe_default_tmpl) + else: + template_latex = "" # fallback: avis vides + return template_latex + + +# ---------------------------------------------------------------------------------------- +def table_syntheseAnnotationPE(syntheseJury, tag_annotation_pe): + """Génère un fichier excel synthétisant les annotations PE telles qu'inscrites dans les fiches de chaque étudiant""" + sT = SeqGenTable() # le fichier excel à générer + + # Les etudids des étudiants à afficher, triés par ordre alphabétiques de nom+prénom + donnees_tries = sorted( + [ + (etudid, syntheseJury[etudid]["nom"] + " " + syntheseJury[etudid]["prenom"]) + for etudid in syntheseJury.keys() + ], + key=lambda c: c[1], + ) + etudids = [e[0] for e in donnees_tries] + if not etudids: # Si pas d'étudiants + T = GenTable( + columns_ids=["pas d'étudiants"], + rows=[], + titles={"pas d'étudiants": "pas d'étudiants"}, + html_sortable=True, + xls_sheet_name="dut", + ) + sT.add_genTable("Annotation PE", T) + return sT + + # Si des étudiants + maxParcours = max( + [syntheseJury[etudid]["nbSemestres"] for etudid in etudids] + ) # le nombre de semestre le + grand + + infos = ["civilite", "nom", "prenom", "age", "nbSemestres"] + entete = ["etudid"] + entete.extend(infos) + entete.extend(["P%d" % i for i in range(1, maxParcours + 1)]) # ajout du parcours + entete.append("Annotation PE") + columns_ids = entete # les id et les titres de colonnes sont ici identiques + titles = {i: i for i in columns_ids} + + rows = [] + for ( + etudid + ) in etudids: # parcours des étudiants par ordre alphabétique des nom+prénom + e = syntheseJury[etudid] + # Les info générales: + row = { + "etudid": etudid, + "civilite": e["civilite"], + "nom": e["nom"], + "prenom": e["prenom"], + "age": e["age"], + "nbSemestres": e["nbSemestres"], + } + # Les parcours: P1, P2, ... + n = 1 + for p in e["parcours"]: + row["P%d" % n] = p["titreannee"] + n += 1 + + # L'annotation PE + annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe) + row["Annotation PE"] = annotationPE if annotationPE else "" + rows.append(row) + + T = GenTable( + columns_ids=columns_ids, + rows=rows, + titles=titles, + html_sortable=True, + xls_sheet_name="Annotation PE", + ) + sT.add_genTable("Annotation PE", T) + return sT diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 79bd0307..06302cd8 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -1,180 +1,180 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - - -"""ScoDoc : interface des fonctions de gestion des avis de poursuites d'étude - -""" - -from flask import send_file, request -from app.scodoc.sco_exceptions import ScoValueError - -import app.scodoc.sco_utils as scu -from app.scodoc import sco_formsemestre -from app.scodoc import html_sco_header -from app.scodoc import sco_preferences - -from app.pe import pe_tools -from app.pe import pe_jurype -from app.pe import pe_avislatex - - -def _pe_view_sem_recap_form(formsemestre_id): - H = [ - html_sco_header.sco_header(page_title="Avis de poursuite d'études"), - f"""

Génération des avis de poursuites d'études

-

- Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de - poursuites d'études. -
- De nombreux aspects sont paramétrables: - - voir la documentation. -

-
-
- Les templates sont généralement installés sur le serveur ou dans le - paramétrage de ScoDoc. -
- Au besoin, vous pouvez spécifier ici votre propre fichier de template - (un_avis.tex): -
Template: - -
-
Pied de page: - -
-
- - -
- """, - ] - return "\n".join(H) + html_sco_header.sco_footer() - - -# called from the web, POST or GET -def pe_view_sem_recap( - formsemestre_id, - avis_tmpl_file=None, - footer_tmpl_file=None, -): - """Génération des avis de poursuite d'étude""" - if request.method == "GET": - return _pe_view_sem_recap_form(formsemestre_id) - prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id) - - semBase = sco_formsemestre.get_formsemestre(formsemestre_id) - - jury = pe_jurype.JuryPE(semBase) - # Ajout avis LaTeX au même zip: - etudids = list(jury.syntheseJury.keys()) - - # Récupération du template latex, du footer latex et du tag identifiant les annotations relatives aux PE - # (chaines unicodes, html non quoté) - template_latex = "" - # template fourni via le formulaire Web - if avis_tmpl_file: - try: - template_latex = avis_tmpl_file.read().decode("utf-8") - except UnicodeDecodeError as e: - raise ScoValueError( - "Données (template) invalides (caractères non UTF8 ?)" - ) from e - else: - # template indiqué dans préférences ScoDoc ? - template_latex = pe_avislatex.get_code_latex_from_scodoc_preference( - formsemestre_id, champ="pe_avis_latex_tmpl" - ) - - template_latex = template_latex.strip() - if not template_latex: - # pas de preference pour le template: utilise fichier du serveur - template_latex = pe_avislatex.get_templates_from_distrib("avis") - - # Footer: - footer_latex = "" - # template fourni via le formulaire Web - if footer_tmpl_file: - footer_latex = footer_tmpl_file.read().decode("utf-8") - else: - footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( - formsemestre_id, champ="pe_avis_latex_footer" - ) - footer_latex = footer_latex.strip() - if not footer_latex: - # pas de preference pour le footer: utilise fichier du serveur - footer_latex = pe_avislatex.get_templates_from_distrib( - "footer" - ) # fallback: footer vides - - tag_annotation_pe = pe_avislatex.get_code_latex_from_scodoc_preference( - formsemestre_id, champ="pe_tag_annotation_avis_latex" - ) - - # Ajout des annotations PE dans un fichier excel - sT = pe_avislatex.table_syntheseAnnotationPE(jury.syntheseJury, tag_annotation_pe) - if sT: - jury.add_file_to_zip( - jury.NOM_EXPORT_ZIP + "_annotationsPE" + scu.XLSX_SUFFIX, sT.excel() - ) - - latex_pages = {} # Dictionnaire de la forme nom_fichier => contenu_latex - for etudid in etudids: - [nom_fichier, contenu_latex] = pe_avislatex.get_avis_poursuite_par_etudiant( - jury, - etudid, - template_latex, - tag_annotation_pe, - footer_latex, - prefs, - ) - jury.add_file_to_zip("avis/" + nom_fichier + ".tex", contenu_latex) - latex_pages[nom_fichier] = contenu_latex # Sauvegarde dans un dico - - # Nouvelle version : 1 fichier par étudiant avec 1 fichier appelant créée ci-dessous - doc_latex = "\n% -----\n".join( - ["\\include{" + nom + "}" for nom in sorted(latex_pages.keys())] - ) - jury.add_file_to_zip("avis/avis_poursuite.tex", doc_latex) - - # Ajoute image, LaTeX class file(s) and modeles - pe_tools.add_pe_stuff_to_zip(jury.zipfile, jury.NOM_EXPORT_ZIP) - data = jury.get_zipped_data() - - return send_file( - data, - mimetype="application/zip", - download_name=scu.sanitize_filename(jury.NOM_EXPORT_ZIP + ".zip"), - as_attachment=True, - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + + +"""ScoDoc : interface des fonctions de gestion des avis de poursuites d'étude + +""" + +from flask import send_file, request +from app.scodoc.sco_exceptions import ScoValueError + +import app.scodoc.sco_utils as scu +from app.scodoc import sco_formsemestre +from app.scodoc import html_sco_header +from app.scodoc import sco_preferences + +from app.pe import pe_tools +from app.pe import pe_jurype +from app.pe import pe_avislatex + + +def _pe_view_sem_recap_form(formsemestre_id): + H = [ + html_sco_header.sco_header(page_title="Avis de poursuite d'études"), + f"""

Génération des avis de poursuites d'études

+

+ Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de + poursuites d'études. +
+ De nombreux aspects sont paramétrables: + + voir la documentation. +

+
+
+ Les templates sont généralement installés sur le serveur ou dans le + paramétrage de ScoDoc. +
+ Au besoin, vous pouvez spécifier ici votre propre fichier de template + (un_avis.tex): +
Template: + +
+
Pied de page: + +
+
+ + +
+ """, + ] + return "\n".join(H) + html_sco_header.sco_footer() + + +# called from the web, POST or GET +def pe_view_sem_recap( + formsemestre_id, + avis_tmpl_file=None, + footer_tmpl_file=None, +): + """Génération des avis de poursuite d'étude""" + if request.method == "GET": + return _pe_view_sem_recap_form(formsemestre_id) + prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id) + + semBase = sco_formsemestre.get_formsemestre(formsemestre_id) + + jury = pe_jurype.JuryPE(semBase) + # Ajout avis LaTeX au même zip: + etudids = list(jury.syntheseJury.keys()) + + # Récupération du template latex, du footer latex et du tag identifiant les annotations relatives aux PE + # (chaines unicodes, html non quoté) + template_latex = "" + # template fourni via le formulaire Web + if avis_tmpl_file: + try: + template_latex = avis_tmpl_file.read().decode("utf-8") + except UnicodeDecodeError as e: + raise ScoValueError( + "Données (template) invalides (caractères non UTF8 ?)" + ) from e + else: + # template indiqué dans préférences ScoDoc ? + template_latex = pe_avislatex.get_code_latex_from_scodoc_preference( + formsemestre_id, champ="pe_avis_latex_tmpl" + ) + + template_latex = template_latex.strip() + if not template_latex: + # pas de preference pour le template: utilise fichier du serveur + template_latex = pe_avislatex.get_templates_from_distrib("avis") + + # Footer: + footer_latex = "" + # template fourni via le formulaire Web + if footer_tmpl_file: + footer_latex = footer_tmpl_file.read().decode("utf-8") + else: + footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( + formsemestre_id, champ="pe_avis_latex_footer" + ) + footer_latex = footer_latex.strip() + if not footer_latex: + # pas de preference pour le footer: utilise fichier du serveur + footer_latex = pe_avislatex.get_templates_from_distrib( + "footer" + ) # fallback: footer vides + + tag_annotation_pe = pe_avislatex.get_code_latex_from_scodoc_preference( + formsemestre_id, champ="pe_tag_annotation_avis_latex" + ) + + # Ajout des annotations PE dans un fichier excel + sT = pe_avislatex.table_syntheseAnnotationPE(jury.syntheseJury, tag_annotation_pe) + if sT: + jury.add_file_to_zip( + jury.NOM_EXPORT_ZIP + "_annotationsPE" + scu.XLSX_SUFFIX, sT.excel() + ) + + latex_pages = {} # Dictionnaire de la forme nom_fichier => contenu_latex + for etudid in etudids: + [nom_fichier, contenu_latex] = pe_avislatex.get_avis_poursuite_par_etudiant( + jury, + etudid, + template_latex, + tag_annotation_pe, + footer_latex, + prefs, + ) + jury.add_file_to_zip("avis/" + nom_fichier + ".tex", contenu_latex) + latex_pages[nom_fichier] = contenu_latex # Sauvegarde dans un dico + + # Nouvelle version : 1 fichier par étudiant avec 1 fichier appelant créée ci-dessous + doc_latex = "\n% -----\n".join( + ["\\include{" + nom + "}" for nom in sorted(latex_pages.keys())] + ) + jury.add_file_to_zip("avis/avis_poursuite.tex", doc_latex) + + # Ajoute image, LaTeX class file(s) and modeles + pe_tools.add_pe_stuff_to_zip(jury.zipfile, jury.NOM_EXPORT_ZIP) + data = jury.get_zipped_data() + + return send_file( + data, + mimetype="application/zip", + download_name=scu.sanitize_filename(jury.NOM_EXPORT_ZIP + ".zip"), + as_attachment=True, + ) diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 0920ab0e..f3c768a2 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -38,6 +38,9 @@ def TrivialFormulator( html_foot_markup="", readonly=False, is_submitted=False, + title="", + after_table="", + before_table="{title}", ): """ form_url : URL for this form @@ -74,7 +77,8 @@ def TrivialFormulator( HTML elements: input_type : 'text', 'textarea', 'password', 'radio', 'menu', 'checkbox', - 'hidden', 'separator', 'file', 'date', 'datedmy' (avec validation), + 'hidden', 'separator', 'table_separator', + 'file', 'date', 'datedmy' (avec validation), 'boolcheckbox', 'text_suggest', 'color' (default text) @@ -111,6 +115,9 @@ def TrivialFormulator( html_foot_markup=html_foot_markup, readonly=readonly, is_submitted=is_submitted, + title=title, + after_table=after_table, + before_table=before_table, ) form = t.getform() if t.canceled(): @@ -144,6 +151,9 @@ class TF(object): html_foot_markup="", # html snippet put at the end, just after the table readonly=False, is_submitted=False, + title="", + after_table="", + before_table="{title}", ): self.form_url = form_url self.values = values.copy() @@ -165,6 +175,9 @@ class TF(object): self.top_buttons = top_buttons self.bottom_buttons = bottom_buttons self.html_foot_markup = html_foot_markup + self.title = title + self.after_table = after_table + self.before_table = before_table self.readonly = readonly self.result = None self.is_submitted = is_submitted @@ -426,6 +439,7 @@ class TF(object): R.append('' % self.formid) if self.top_buttons: R.append(buttons_markup + "

") + R.append(self.before_table.format(title=self.title)) R.append('') for field, descr in self.formdescription: if descr.get("readonly", False): @@ -453,6 +467,16 @@ class TF(object): etempl = separatortemplate R.append(etempl % {"label": title, "item_dom_attr": item_dom_attr}) continue + elif input_type == "table_separator": + etempl = "" + # Table ouverte ? + if len([p for p in R if " len( + [p for p in R if "{self.after_table}""") + R.append( + f"""{self.before_table.format(title=descr.get("title", ""))}
""" + ) else: etempl = itemtemplate lab = [] @@ -610,7 +634,7 @@ class TF(object): '' % (field, wid, values[field], attribs) ) - elif input_type == "separator": + elif (input_type == "separator") or (input_type == "table_separator"): pass elif input_type == "file": lem.append( @@ -641,13 +665,15 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); ) lem.append(('value="%(' + field + ')s" >') % values) else: - raise ValueError("unkown input_type for form (%s)!" % input_type) + raise ValueError(f"unkown input_type for form ({input_type})!") explanation = descr.get("explanation", "") if explanation: - lem.append('%s' % explanation) + lem.append(f"""{explanation}""") comment = descr.get("comment", "") if comment: - lem.append('
%s' % comment) + if (input_type != "checkbox") and (input_type != "boolcheckbox"): + lem.append("
") + lem.append(f"""{comment}""") R.append( etempl % { @@ -657,11 +683,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); } ) R.append("
") - + R.append(self.after_table) R.append(self.html_foot_markup) if self.bottom_buttons: - R.append("
" + buttons_markup) + R.append("
" + buttons_markup) if add_no_enter_js: R.append( @@ -753,7 +779,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); if input_type == "separator": # separator R.append('%s' % title) - else: + elif input_type != "table_separator": R.append('' % klass) R.append("%s" % title) R.append('' % klass) @@ -786,7 +812,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); R.append( '
%s
' % html.escape(self.values[field]) ) - elif input_type == "separator" or input_type == "hidden": + elif ( + input_type == "separator" + or input_type == "hidden" + or input_type == "table_separator" + ): pass elif input_type == "file": R.append("'%s'" % self.values[field]) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 70a6e814..409e4d13 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -1,321 +1,321 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""HTML Header/Footer for ScoDoc pages -""" - -import html - -from flask import render_template -from flask import request -from flask_login import current_user - -import app.scodoc.sco_utils as scu -from app import scodoc_flash_status_messages -from app.scodoc import html_sidebar -import sco_version - - -# Some constants: - -# Multiselect menus are used on a few pages and not loaded by default -BOOTSTRAP_MULTISELECT_JS = [ - "libjs/bootstrap-3.1.1-dist/js/bootstrap.min.js", - "libjs/bootstrap-multiselect/bootstrap-multiselect.js", - "libjs/purl.js", -] - -BOOTSTRAP_MULTISELECT_CSS = [ - "libjs/bootstrap-3.1.1-dist/css/bootstrap.min.css", - "libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.min.css", - "libjs/bootstrap-multiselect/bootstrap-multiselect.css", -] - - -def standard_html_header(): - """Standard HTML header for pages outside depts""" - # not used in ZScolar, see sco_header - return f""" - -ScoDoc: accueil - - - - - - - -{scu.CUSTOM_HTML_HEADER_CNX}""" - - -def standard_html_footer(): - """Le pied de page HTML de la page d'accueil.""" - return f""" -

Problèmes et suggestions sur le logiciel: {scu.SCO_USERS_LIST}

-

ScoDoc est un logiciel libre développé par Emmanuel Viennet.

-""" - - -_HTML_BEGIN = f""" - - - - - - - - - - -%(page_title)s - - - - - - - - - - - - - - - - - - - - -""" - - -def scodoc_top_html_header(page_title="ScoDoc: bienvenue"): - H = [ - _HTML_BEGIN % {"page_title": page_title, "encoding": scu.SCO_ENCODING}, - """""", - scu.CUSTOM_HTML_HEADER_CNX, - ] - return "\n".join(H) - - -# Header: -def sco_header( - # optional args - page_title="", # page title - no_side_bar=False, # hide sidebar - cssstyles=(), # additionals CSS sheets - javascripts=(), # additionals JS filenames to load - scripts=(), # script to put in page header - bodyOnLoad="", # JS - init_qtip=False, # include qTip - init_google_maps=False, # Google maps - init_datatables=True, - titrebandeau="", # titre dans bandeau superieur - head_message="", # message action (petit cadre jaune en haut) - user_check=True, # verifie passwords temporaires - etudid=None, - formsemestre_id=None, -): - "Main HTML page header for ScoDoc" - from app.scodoc.sco_formsemestre_status import formsemestre_page_title - - scodoc_flash_status_messages() - - # Get head message from http request: - if not head_message: - if request.method == "POST": - head_message = request.form.get("head_message", "") - elif request.method == "GET": - head_message = request.args.get("head_message", "") - params = { - "page_title": page_title or sco_version.SCONAME, - "no_side_bar": no_side_bar, - "ScoURL": scu.ScoURL(), - "encoding": scu.SCO_ENCODING, - "titrebandeau_mkup": "" + titrebandeau + "", - "authuser": current_user.user_name, - } - if bodyOnLoad: - params["bodyOnLoad_mkup"] = """onload="%s" """ % bodyOnLoad - else: - params["bodyOnLoad_mkup"] = "" - if no_side_bar: - params["margin_left"] = "1em" - else: - params["margin_left"] = "140px" - - H = [ - """ - - -%(page_title)s - - - -""" - % params - ] - # jQuery UI - # can modify loaded theme here - H.append( - f'\n' - ) - if init_google_maps: - # It may be necessary to add an API key: - H.append('') - - # Feuilles de style additionnelles: - for cssstyle in cssstyles: - H.append( - f"""\n""" - ) - - H.append( - f""" - - - - - - -""" - ) - - # jQuery - H.append( - f""" - """ - ) - # qTip - if init_qtip: - H.append( - f""" - """ - ) - - H.append( - f""" - """ - ) - if init_google_maps: - H.append( - f'' - ) - if init_datatables: - H.append( - f""" - """ - ) - # H.append( - # f'' - # ) - # JS additionels - for js in javascripts: - H.append(f"""\n""") - - H.append( - f""" -""" - ) - # Scripts de la page: - if scripts: - H.append("""""") - - H.append("") - - # Body et bandeau haut: - H.append("""""" % params) - H.append(scu.CUSTOM_HTML_HEADER) - # - if not no_side_bar: - H.append(html_sidebar.sidebar(etudid)) - H.append("""
""") - # En attendant le replacement complet de cette fonction, - # inclusion ici des messages flask - H.append(render_template("flashed_messages.html")) - # - # Barre menu semestre: - H.append(formsemestre_page_title(formsemestre_id)) - - # Avertissement si mot de passe à changer - if user_check: - if current_user.passwd_temp: - H.append( - f"""
- Attention !
- Vous avez reçu un mot de passe temporaire.
- Vous devez le changer: cliquez ici -
""" - ) - # - if head_message: - H.append('
' + html.escape(head_message) + "
") - # - # div pour affichage messages temporaires - H.append('
') - # - return "".join(H) - - -def sco_footer(): - """Main HTMl pages footer""" - return ( - """
""" + scu.CUSTOM_HTML_FOOTER + """""" - ) - - -def html_sem_header( - title, with_page_header=True, with_h2=True, page_title=None, **args -): - "Titre d'une page semestre avec lien vers tableau de bord" - # sem now unused and thus optional... - if with_page_header: - h = sco_header(page_title="%s" % (page_title or title), **args) - else: - h = "" - if with_h2: - return h + f"""

{title}

""" - else: - return h +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""HTML Header/Footer for ScoDoc pages +""" + +import html + +from flask import render_template +from flask import request +from flask_login import current_user + +import app.scodoc.sco_utils as scu +from app import scodoc_flash_status_messages +from app.scodoc import html_sidebar +import sco_version + + +# Some constants: + +# Multiselect menus are used on a few pages and not loaded by default +BOOTSTRAP_MULTISELECT_JS = [ + "libjs/bootstrap-3.1.1-dist/js/bootstrap.min.js", + "libjs/bootstrap-multiselect/bootstrap-multiselect.js", + "libjs/purl.js", +] + +BOOTSTRAP_MULTISELECT_CSS = [ + "libjs/bootstrap-3.1.1-dist/css/bootstrap.min.css", + "libjs/bootstrap-3.1.1-dist/css/bootstrap-theme.min.css", + "libjs/bootstrap-multiselect/bootstrap-multiselect.css", +] + + +def standard_html_header(): + """Standard HTML header for pages outside depts""" + # not used in ZScolar, see sco_header + return f""" + +ScoDoc: accueil + + + + + + + +{scu.CUSTOM_HTML_HEADER_CNX}""" + + +def standard_html_footer(): + """Le pied de page HTML de la page d'accueil.""" + return f""" +

Problèmes et suggestions sur le logiciel: {scu.SCO_USERS_LIST}

+

ScoDoc est un logiciel libre développé par Emmanuel Viennet.

+""" + + +_HTML_BEGIN = f""" + + + + + + + + + + +%(page_title)s + + + + + + + + + + + + + + + + + + + + +""" + + +def scodoc_top_html_header(page_title="ScoDoc: bienvenue"): + H = [ + _HTML_BEGIN % {"page_title": page_title, "encoding": scu.SCO_ENCODING}, + """""", + scu.CUSTOM_HTML_HEADER_CNX, + ] + return "\n".join(H) + + +# Header: +def sco_header( + # optional args + page_title="", # page title + no_side_bar=False, # hide sidebar + cssstyles=(), # additionals CSS sheets + javascripts=(), # additionals JS filenames to load + scripts=(), # script to put in page header + bodyOnLoad="", # JS + init_qtip=False, # include qTip + init_google_maps=False, # Google maps + init_datatables=True, + titrebandeau="", # titre dans bandeau superieur + head_message="", # message action (petit cadre jaune en haut) + user_check=True, # verifie passwords temporaires + etudid=None, + formsemestre_id=None, +): + "Main HTML page header for ScoDoc" + from app.scodoc.sco_formsemestre_status import formsemestre_page_title + + scodoc_flash_status_messages() + + # Get head message from http request: + if not head_message: + if request.method == "POST": + head_message = request.form.get("head_message", "") + elif request.method == "GET": + head_message = request.args.get("head_message", "") + params = { + "page_title": page_title or sco_version.SCONAME, + "no_side_bar": no_side_bar, + "ScoURL": scu.ScoURL(), + "encoding": scu.SCO_ENCODING, + "titrebandeau_mkup": "" + titrebandeau + "", + "authuser": current_user.user_name, + } + if bodyOnLoad: + params["bodyOnLoad_mkup"] = """onload="%s" """ % bodyOnLoad + else: + params["bodyOnLoad_mkup"] = "" + if no_side_bar: + params["margin_left"] = "1em" + else: + params["margin_left"] = "140px" + + H = [ + """ + + +%(page_title)s + + + +""" + % params + ] + # jQuery UI + # can modify loaded theme here + H.append( + f'\n' + ) + if init_google_maps: + # It may be necessary to add an API key: + H.append('') + + # Feuilles de style additionnelles: + for cssstyle in cssstyles: + H.append( + f"""\n""" + ) + + H.append( + f""" + + + + + + +""" + ) + + # jQuery + H.append( + f""" + """ + ) + # qTip + if init_qtip: + H.append( + f""" + """ + ) + + H.append( + f""" + """ + ) + if init_google_maps: + H.append( + f'' + ) + if init_datatables: + H.append( + f""" + """ + ) + # H.append( + # f'' + # ) + # JS additionels + for js in javascripts: + H.append(f"""\n""") + + H.append( + f""" +""" + ) + # Scripts de la page: + if scripts: + H.append("""""") + + H.append("") + + # Body et bandeau haut: + H.append("""""" % params) + H.append(scu.CUSTOM_HTML_HEADER) + # + if not no_side_bar: + H.append(html_sidebar.sidebar(etudid)) + H.append("""
""") + # En attendant le replacement complet de cette fonction, + # inclusion ici des messages flask + H.append(render_template("flashed_messages.html")) + # + # Barre menu semestre: + H.append(formsemestre_page_title(formsemestre_id)) + + # Avertissement si mot de passe à changer + if user_check: + if current_user.passwd_temp: + H.append( + f"""
+ Attention !
+ Vous avez reçu un mot de passe temporaire.
+ Vous devez le changer: cliquez ici +
""" + ) + # + if head_message: + H.append('
' + html.escape(head_message) + "
") + # + # div pour affichage messages temporaires + H.append('
') + # + return "".join(H) + + +def sco_footer(): + """Main HTMl pages footer""" + return ( + """
""" + scu.CUSTOM_HTML_FOOTER + """""" + ) + + +def html_sem_header( + title, with_page_header=True, with_h2=True, page_title=None, **args +): + "Titre d'une page semestre avec lien vers tableau de bord" + # sem now unused and thus optional... + if with_page_header: + h = sco_header(page_title="%s" % (page_title or title), **args) + else: + h = "" + if with_h2: + return h + f"""

{title}

""" + else: + return h diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 87e200b5..45497ff2 100644 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -1,172 +1,172 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -""" -Génération de la "sidebar" (marge gauche des pages HTML) -""" -from flask import render_template, url_for -from flask import g, request -from flask_login import current_user - -import app.scodoc.sco_utils as scu -from app.scodoc import sco_preferences -from app.scodoc.sco_permissions import Permission -from sco_version import SCOVERSION - - -def sidebar_common(): - "partie commune à toutes les sidebar" - home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) - H = [ - f"""ScoDoc {SCOVERSION}
- Accueil
-
{current_user.user_name} -
déconnexion -
- {sidebar_dept()} -

Scolarité

- Semestres
- Programmes
- Absences
- """ - ] - if current_user.has_permission( - Permission.ScoUsersAdmin - ) or current_user.has_permission(Permission.ScoUsersView): - H.append( - f"""Utilisateurs
""" - ) - - if current_user.has_permission(Permission.ScoChangePreferences): - H.append( - f"""Paramétrage
""" - ) - - return "".join(H) - - -def sidebar(etudid: int = None): - "Main HTML page sidebar" - # rewritten from legacy DTML code - from app.scodoc import sco_abs - from app.scodoc import sco_etud - - params = {} - - H = [ - f""" - - """ - ) - return "".join(H) - - -def sidebar_dept(): - """Partie supérieure de la marge de gauche""" - return render_template( - "sidebar_dept.html", - prefs=sco_preferences.SemPreferences(), - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +""" +Génération de la "sidebar" (marge gauche des pages HTML) +""" +from flask import render_template, url_for +from flask import g, request +from flask_login import current_user + +import app.scodoc.sco_utils as scu +from app.scodoc import sco_preferences +from app.scodoc.sco_permissions import Permission +from sco_version import SCOVERSION + + +def sidebar_common(): + "partie commune à toutes les sidebar" + home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) + H = [ + f"""ScoDoc {SCOVERSION}
+ Accueil
+
{current_user.user_name} +
déconnexion +
+ {sidebar_dept()} +

Scolarité

+ Semestres
+ Programmes
+ Absences
+ """ + ] + if current_user.has_permission( + Permission.ScoUsersAdmin + ) or current_user.has_permission(Permission.ScoUsersView): + H.append( + f"""Utilisateurs
""" + ) + + if current_user.has_permission(Permission.ScoChangePreferences): + H.append( + f"""Paramétrage
""" + ) + + return "".join(H) + + +def sidebar(etudid: int = None): + "Main HTML page sidebar" + # rewritten from legacy DTML code + from app.scodoc import sco_abs + from app.scodoc import sco_etud + + params = {} + + H = [ + f""" + + """ + ) + return "".join(H) + + +def sidebar_dept(): + """Partie supérieure de la marge de gauche""" + return render_template( + "sidebar_dept.html", + prefs=sco_preferences.SemPreferences(), + ) diff --git a/app/scodoc/safehtml.py b/app/scodoc/safehtml.py index a11ebf52..2988a0f3 100644 --- a/app/scodoc/safehtml.py +++ b/app/scodoc/safehtml.py @@ -1,80 +1,80 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -from html.parser import HTMLParser - - -"""HTML sanitizing function - used to clean user submitted HTML - (Python 3 only) -""" - -# permet de conserver les liens -def html_to_safe_html(text, convert_br=True): # was HTML2SafeHTML - # text = html_to_safe_html(text, valid_tags=("b", "a", "i", "br", "p")) - # New version (jul 2021) with our own parser - text = convert_html_to_text(text) - if convert_br: - return newline_to_br(text) - else: - return text - - -def convert_html_to_text(s): - parser = HTMLSanitizer() - parser.feed(s) - return parser.text - - -def newline_to_br(text): - return text.replace("\n", "
") - - -class HTMLSanitizer(HTMLParser): - def __init__(self, allowed_tags=("i", "b", "em", "br", "p"), **kwargs): - super(HTMLSanitizer, self).__init__(**kwargs) - self.allowed_tags = set(allowed_tags) - self.text = "" - - def handle_starttag(self, tag, attrs): - if tag in self.allowed_tags: - self.text += "<{} {}>".format( - tag, ", ".join(['{}="{}"'.format(k, v) for (k, v) in attrs]) - ) - - def handle_endtag(self, tag): - if tag in self.allowed_tags: - self.text += "" - - def handle_data(self, data): - self.text += data - - -if __name__ == "__main__": - test_parser = HTMLSanitizer() - test_parser.feed("""

Hello world gras italique

""") - print(test_parser.text) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +from html.parser import HTMLParser + + +"""HTML sanitizing function + used to clean user submitted HTML + (Python 3 only) +""" + +# permet de conserver les liens +def html_to_safe_html(text, convert_br=True): # was HTML2SafeHTML + # text = html_to_safe_html(text, valid_tags=("b", "a", "i", "br", "p")) + # New version (jul 2021) with our own parser + text = convert_html_to_text(text) + if convert_br: + return newline_to_br(text) + else: + return text + + +def convert_html_to_text(s): + parser = HTMLSanitizer() + parser.feed(s) + return parser.text + + +def newline_to_br(text): + return text.replace("\n", "
") + + +class HTMLSanitizer(HTMLParser): + def __init__(self, allowed_tags=("i", "b", "em", "br", "p"), **kwargs): + super(HTMLSanitizer, self).__init__(**kwargs) + self.allowed_tags = set(allowed_tags) + self.text = "" + + def handle_starttag(self, tag, attrs): + if tag in self.allowed_tags: + self.text += "<{} {}>".format( + tag, ", ".join(['{}="{}"'.format(k, v) for (k, v) in attrs]) + ) + + def handle_endtag(self, tag): + if tag in self.allowed_tags: + self.text += "" + + def handle_data(self, data): + self.text += data + + +if __name__ == "__main__": + test_parser = HTMLSanitizer() + test_parser.feed("""

Hello world gras italique

""") + print(test_parser.text) diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index b8392fcf..98faeb51 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -1,1043 +1,1043 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Pages HTML gestion absences - (la plupart portées du DTML) -""" -import datetime - -from flask import url_for, g, request, abort - -from app import log -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import Identite, FormSemestre -import app.scodoc.sco_utils as scu -from app.scodoc import notesdb as ndb -from app.scodoc.scolog import logdb -from app.scodoc.gen_tables import GenTable -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_etud -from app.scodoc import sco_find_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_photos -from app.scodoc import sco_preferences -from app.scodoc.sco_exceptions import ScoValueError - - -def doSignaleAbsence( - datedebut, - datefin, - moduleimpl_id=None, - demijournee=2, - estjust=False, - description=None, - etudid=False, -): # etudid implied - """Signalement d'une absence. - - Args: - datedebut: dd/mm/yyyy - datefin: dd/mm/yyyy (non incluse) - moduleimpl_id: module auquel imputer les absences - demijournee: 2 si journée complète, 1 matin, 0 après-midi - estjust: absence justifiée - description: str - etudid: etudiant concerné. Si non spécifié, cherche dans - les paramètres de la requête courante. - """ - etud = Identite.from_request(etudid) - - if not moduleimpl_id: - moduleimpl_id = None - description_abs = description - dates = sco_abs.DateRangeISO(datedebut, datefin) - nbadded = 0 - demijournee = int(demijournee) - for jour in dates: - if demijournee == 2: - sco_abs.add_absence( - etud.id, - jour, - False, - estjust, - description_abs, - moduleimpl_id, - ) - sco_abs.add_absence( - etud.id, - jour, - True, - estjust, - description_abs, - moduleimpl_id, - ) - nbadded += 2 - else: - sco_abs.add_absence( - etud.id, - jour, - demijournee, - estjust, - description_abs, - moduleimpl_id, - ) - nbadded += 1 - # - if estjust: - J = "" - else: - J = "NON " - indication_module = "" - if moduleimpl_id and moduleimpl_id != "NULL": - mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - formsemestre_id = mod["formsemestre_id"] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - ues = nt.get_ues_stat_dict() - for ue in ues: - modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) - for modimpl in modimpls: - if modimpl["moduleimpl_id"] == moduleimpl_id: - indication_module = "dans le module %s" % ( - modimpl["module"]["code"] or "(pas de code)" - ) - H = [ - html_sco_header.sco_header( - page_title=f"Signalement d'une absence pour {etud.nomprenom}", - ), - """

Signalement d'absences

""", - ] - if dates: - H.append( - """

Ajout de %d absences %sjustifiées du %s au %s %s

""" - % (nbadded, J, datedebut, datefin, indication_module) - ) - else: - H.append( - """

Aucune date ouvrable entre le %s et le %s !

""" - % (datedebut, datefin) - ) - - H.append( - f""" -
- """ - ) - H.append(sco_find_etud.form_search_etud()) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def SignaleAbsenceEtud(): # etudid implied - """Formulaire individuel simple de signalement d'une absence""" - # brute-force portage from very old dtml code ... - etud = sco_etud.get_etud_info(filled=True)[0] - etudid = etud["etudid"] - disabled = False - if not etud["cursem"]: - require_module = sco_preferences.get_preference( - "abs_require_module" - ) # on utilise la pref globale car pas de sem courant - if require_module: - menu_module = """
Pas inscrit dans un semestre courant, - et l'indication du module est requise. Donc pas de saisie d'absence possible !
""" - disabled = True - else: - menu_module = "" - else: - formsemestre_id = etud["cursem"]["formsemestre_id"] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - ues = nt.get_ues_stat_dict() - require_module = sco_preferences.get_preference( - "abs_require_module", formsemestre_id - ) - if require_module: - menu_module = """ - -

Module: - """ - ) - menu_module += """""" - - for ue in ues: - modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) - for modimpl in modimpls: - menu_module += ( - """\n""" - % { - "modimpl_id": modimpl["moduleimpl_id"], - "modname": modimpl["module"]["code"] or "", - } - ) - menu_module += """

""" - - H = [ - html_sco_header.sco_header( - page_title="Signalement d'une absence pour %(nomprenom)s" % etud, - ), - """
-

Signalement d'une absence pour %(nomprenom)s

-
- """ - % etud, - """""" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]), - sco_photos.etud_photo_html( - etudid=etudid, - title="fiche de " + etud["nomprenom"], - ), - """
""", - """ -
- -

- - - - - - -
Date début : j/m/a   Date fin (optionnelle): j/m/a
-
-Journée(s) - Matin(s) - Après-midi - -%(menu_module)s - -

-Absence justifiée. -
-Raison: (optionnel) -

- -

- - -

Seuls les modules du semestre en cours apparaissent.

-

Évitez de saisir une absence pour un module qui n'est pas en place à cette date.

-

Toutes les dates sont au format jour/mois/annee.

-
- -
- """ - % { - "etudid": etud["etudid"], - "menu_module": menu_module, - "disabled": "disabled" if disabled else "", - }, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def doJustifAbsence( - datedebut, - datefin, - demijournee, - description=None, - etudid=False, -): # etudid implied - """Justification d'une absence - - Args: - datedebut: dd/mm/yyyy - datefin: dd/mm/yyyy (non incluse) - demijournee: 2 si journée complète, 1 matin, 0 après-midi - estjust: absence justifiée - description: str - etudid: etudiant concerné. Si non spécifié, cherche dans les - paramètres de la requête. - """ - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - etudid = etud["etudid"] - description_abs = description - dates = sco_abs.DateRangeISO(datedebut, datefin) - nbadded = 0 - demijournee = int(demijournee) - for jour in dates: - if demijournee == 2: - sco_abs.add_justif( - etudid=etudid, - jour=jour, - matin=False, - description=description_abs, - ) - sco_abs.add_justif( - etudid=etudid, - jour=jour, - matin=True, - description=description_abs, - ) - nbadded += 2 - else: - sco_abs.add_justif( - etudid=etudid, - jour=jour, - matin=demijournee, - description=description_abs, - ) - nbadded += 1 - # - H = [ - html_sco_header.sco_header( - page_title="Justification d'une absence pour %(nomprenom)s" % etud, - ), - """

Justification d'absences

""", - ] - if dates: - H.append( - """

Ajout de %d justifications du %s au %s

""" - % (nbadded, datedebut, datefin) - ) - else: - H.append( - """

Aucune date ouvrable entre le %s et le %s !

""" - % (datedebut, datefin) - ) - - H.append( - """ -
""" - % etud - ) - H.append(sco_find_etud.form_search_etud()) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def JustifAbsenceEtud(): # etudid implied - """Formulaire individuel simple de justification d'une absence""" - # brute-force portage from very old dtml code ... - etud = sco_etud.get_etud_info(filled=True)[0] - etudid = etud["etudid"] - H = [ - html_sco_header.sco_header( - page_title="Justification d'une absence pour %(nomprenom)s" % etud, - ), - """
-

Justification d'une absence pour %(nomprenom)s

-
- """ - % etud, - """""" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html( - etudid=etudid, - title="fiche de " + etud["nomprenom"], - ), - """
""", - """ -
- - -

- - - - - - -
Date début : - -   Date Fin (optionnel):
-
- -Journée(s) - Matin(s) - Après midi - -

-Raison: (optionnel) - -

- - -

""" - % etud, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def doAnnuleAbsence(datedebut, datefin, demijournee, etudid=False): # etudid implied - """Annulation des absences pour une demi journée""" - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - etudid = etud["etudid"] - - dates = sco_abs.DateRangeISO(datedebut, datefin) - nbadded = 0 - demijournee = int(demijournee) - for jour in dates: - if demijournee == 2: - sco_abs.annule_absence(etudid, jour, False) - sco_abs.annule_absence(etudid, jour, True) - nbadded += 2 - else: - sco_abs.annule_absence(etudid, jour, demijournee) - nbadded += 1 - # - H = [ - html_sco_header.sco_header( - page_title="Annulation d'une absence pour %(nomprenom)s" % etud, - ), - """

Annulation d'absences pour %(nomprenom)s

""" % etud, - ] - if dates: - H.append( - "

Annulation sur %d demi-journées du %s au %s" - % (nbadded, datedebut, datefin) - ) - else: - H.append( - """

Aucune date ouvrable entre le %s et le %s !

""" - % (datedebut, datefin) - ) - - H.append( - """ -
""" - % etud - ) - H.append(sco_find_etud.form_search_etud()) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def AnnuleAbsenceEtud(): # etudid implied - """Formulaire individuel simple d'annulation d'une absence""" - # brute-force portage from very old dtml code ... - etud = sco_etud.get_etud_info(filled=True)[0] - etudid = etud["etudid"] - - H = [ - html_sco_header.sco_header( - page_title="Annulation d'une absence pour %(nomprenom)s" % etud, - ), - """
-

Annulation d'une absence pour %(nomprenom)s

-
- """ - % etud, # " - """""" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html( - etudid=etudid, - title="fiche de " + etud["nomprenom"], - ), - """
""", - """

A n'utiliser que suite à une erreur de saisie ou lorsqu'il s'avère que l'étudiant était en fait présent.

-

Si plusieurs modules sont affectés, les absences seront toutes effacées.

- """ - % etud, - """ - -
-
- -

- - - - - - -
Date début : - j/m/a -   Date Fin (optionnel): - j/m/a -
- -journée(s) - Matin(s) - Après midi - - -

- -

-
-
- -

- - - - - - -
Date début : - j/m/a -   Date Fin (optionnel): - j/m/a -
-

- -journée(s) - Matin(s) - Après midi - - -

- -(utiliser ceci en cas de justificatif erroné saisi indépendemment d'une absence) -

-
""" - % etud, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def doAnnuleJustif(datedebut0, datefin0, demijournee): # etudid implied - """Annulation d'une justification""" - etud = sco_etud.get_etud_info(filled=True)[0] - etudid = etud["etudid"] - dates = sco_abs.DateRangeISO(datedebut0, datefin0) - nbadded = 0 - demijournee = int(demijournee) - for jour in dates: - # Attention: supprime matin et après-midi - if demijournee == 2: - sco_abs.annule_justif(etudid, jour, False) - sco_abs.annule_justif(etudid, jour, True) - nbadded += 2 - else: - sco_abs.annule_justif(etudid, jour, demijournee) - nbadded += 1 - # - H = [ - html_sco_header.sco_header( - page_title="Annulation d'une justification pour %(nomprenom)s" % etud, - ), - """

Annulation de justifications pour %(nomprenom)s

""" % etud, - ] - - if dates: - H.append( - "

Annulation sur %d demi-journées du %s au %s" - % (nbadded, datedebut0, datefin0) - ) - else: - H.append( - """

Aucune date ouvrable entre le %s et le %s !

""" - % (datedebut0, datefin0) - ) - H.append( - """ -
""" - % etud - ) - H.append(sco_find_etud.form_search_etud()) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id=None): - """Supprime les absences non justifiées aux dates indiquées - Ne supprime pas les justificatifs éventuels. - Args: - etudid: l'étudiant - dates: liste de dates iso, eg [ "2000-01-15", "2000-01-16" ] - moduleimpl_id: si spécifié, n'affecte que les absences de ce module - - Returns: - None - """ - # log('AnnuleAbsencesDatesNoJust: moduleimpl_id=%s' % moduleimpl_id) - if not dates: - return - date0 = dates[0] - if len(date0.split(":")) == 2: - # am/pm is present - for date in dates: - jour, ampm = date.split(":") - if ampm == "am": - matin = 1 - elif ampm == "pm": - matin = 0 - else: - raise ValueError("invalid ampm !") - sco_abs.annule_absence(etudid, jour, matin, moduleimpl_id) - return - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - # supr les absences non justifiees - for date in dates: - cursor.execute( - """DELETE FROM absences - WHERE etudid=%(etudid)s and (not estjust) and jour=%(date)s and moduleimpl_id=%(moduleimpl_id)s - """, - vars(), - ) - sco_abs.invalidate_abs_etud_date(etudid, date) - # s'assure que les justificatifs ne sont pas "absents" - for date in dates: - cursor.execute( - """UPDATE absences SET estabs=FALSE - WHERE etudid=%(etudid)s AND jour=%(date)s AND moduleimpl_id=%(moduleimpl_id)s - """, - vars(), - ) - if dates: - date0 = dates[0] - else: - date0 = None - if len(dates) > 1: - date1 = dates[1] - else: - date1 = None - logdb( - cnx, - "AnnuleAbsencesDatesNoJust", - etudid=etudid, - msg="%s - %s - %s" % (date0, date1, moduleimpl_id), - ) - cnx.commit() - - -def EtatAbsences(): - """Etat des absences: choix du groupe""" - # crude portage from 1999 DTML - H = [ - html_sco_header.sco_header(page_title="Etat des absences"), - """

État des absences pour un groupe

-
""", - formChoixSemestreGroupe(), - """ - -
Date de début (j/m/a) : - - - -
Date de fin : - - - -
-
""" - % (scu.AnneeScolaire(), datetime.datetime.now().strftime("%d/%m/%Y")), - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def formChoixSemestreGroupe(all=False): - """partie de formulaire pour le choix d'un semestre et d'un groupe. - Si all, donne tous les semestres (même ceux verrouillés). - """ - # XXX assez primitif, à ameliorer TOTALEMENT OBSOLETE ! - if all: - sems = sco_formsemestre.do_formsemestre_list() - else: - sems = sco_formsemestre.do_formsemestre_list(args={"etat": "1"}) - if not sems: - raise ScoValueError("aucun semestre !") - H = ['") - return "\n".join(H) - - -def CalAbs(etudid, sco_year=None): - """Calendrier des absences d'un etudiant""" - # crude portage from 1999 DTML - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - etudid = etud["etudid"] - anneescolaire = int(scu.AnneeScolaire(sco_year)) - datedebut = str(anneescolaire) + "-08-01" - datefin = str(anneescolaire + 1) + "-07-31" - annee_courante = scu.AnneeScolaire() - nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) - nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) - events = [] - for a in sco_abs.list_abs_just(etudid=etudid, datedebut=datedebut): - events.append( - (str(a["jour"]), "a", "#F8B7B0", "", a["matin"], a["description"]) - ) - for a in sco_abs.list_abs_non_just(etudid=etudid, datedebut=datedebut): - events.append( - (str(a["jour"]), "A", "#EE0000", "", a["matin"], a["description"]) - ) - justifs_noabs = sco_abs.list_abs_justifs( - etudid=etudid, datedebut=datedebut, only_no_abs=True - ) - for a in justifs_noabs: - events.append( - (str(a["jour"]), "X", "#8EA2C6", "", a["matin"], a["description"]) - ) - CalHTML = sco_abs.YearTable(anneescolaire, events=events, halfday=1) - - # - H = [ - html_sco_header.sco_header( - page_title="Calendrier des absences de %(nomprenom)s" % etud, - cssstyles=["css/calabs.css"], - ), - """ - - -

Absences de %(nomprenom)s (%(inscription)s)

""" - % etud, - """A : absence NON justifiée
- a : absence justifiée
- X : justification sans absence
- %d absences sur l'année, dont %d justifiées (soit %d non justifiées)
(%d justificatifs inutilisés) -

- """ - % (nbabs, nbabsjust, nbabs - nbabsjust, len(justifs_noabs)), - """
%s
""" - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html( - etudid=etudid, - title="fiche de " + etud["nomprenom"], - ), - ), - CalHTML, - """
""", - """""" % etudid, - """Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), - """  Changer année:
""") - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def ListeAbsEtud( - etudid=None, - code_nip=None, - with_evals=True, - format="html", - absjust_only=0, - sco_year=None, -): - """Liste des absences d'un étudiant sur l'année en cours - En format 'html': page avec deux tableaux (non justifiées et justifiées). - En format json, xml, xls ou pdf: l'un ou l'autre des table, suivant absjust_only. - En format 'text': texte avec liste d'absences (pour mails). - - Args: - etudid: - with_evals: indique les evaluations aux dates d'absences - absjust_only: si vrai, renvoie table absences justifiées - sco_year: année scolaire à utiliser. Si non spécifier, utilie l'année en cours. e.g. "2005" - """ - # si absjust_only, table absjust seule (export xls ou pdf) - absjust_only = scu.to_bool(absjust_only) - datedebut = "%s-08-01" % scu.AnneeScolaire(sco_year=sco_year) - etudid = etudid or False - etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True) - if not etuds: - log(f"ListeAbsEtud: no etuds with etudid={etudid} or nip={code_nip}") - abort(404) - etud = etuds[0] - etudid = etud["etudid"] - # Liste des absences et titres colonnes tables: - titles, columns_ids, absnonjust, absjust = _tables_abs_etud( - etudid, datedebut, with_evals=with_evals, format=format - ) - if request.base_url: - base_url_nj = "%s?etudid=%s&absjust_only=0" % (request.base_url, etudid) - base_url_j = "%s?etudid=%s&absjust_only=1" % (request.base_url, etudid) - else: - base_url_nj = base_url_j = "" - tab_absnonjust = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=absnonjust, - html_class="table_leftalign", - table_id="tab_absnonjust", - base_url=base_url_nj, - filename="abs_" + scu.make_filename(etud["nomprenom"]), - caption="Absences non justifiées de %(nomprenom)s" % etud, - preferences=sco_preferences.SemPreferences(), - ) - tab_absjust = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=absjust, - html_class="table_leftalign", - table_id="tab_absjust", - base_url=base_url_j, - filename="absjust_" + scu.make_filename(etud["nomprenom"]), - caption="Absences justifiées de %(nomprenom)s" % etud, - preferences=sco_preferences.SemPreferences(), - ) - - # Formats non HTML et demande d'une seule table: - if format != "html" and format != "text": - if absjust_only == 1: - return tab_absjust.make_page(format=format) - else: - return tab_absnonjust.make_page(format=format) - - if format == "html": - # Mise en forme HTML: - H = [] - H.append( - html_sco_header.sco_header(page_title="Absences de %s" % etud["nomprenom"]) - ) - H.append( - """

Absences de %s (à partir du %s)

""" - % (etud["nomprenom"], ndb.DateISOtoDMY(datedebut)) - ) - - if len(absnonjust): - H.append("

%d absences non justifiées:

" % len(absnonjust)) - H.append(tab_absnonjust.html()) - else: - H.append("""

Pas d'absences non justifiées

""") - - if len(absjust): - H.append("""

%d absences justifiées:

""" % len(absjust)) - H.append(tab_absjust.html()) - else: - H.append("""

Pas d'absences justifiées

""") - return "\n".join(H) + html_sco_header.sco_footer() - - elif format == "text": - T = [] - if not len(absnonjust) and not len(absjust): - T.append( - """--- Pas d'absences enregistrées depuis le %s""" - % ndb.DateISOtoDMY(datedebut) - ) - else: - T.append( - """--- Absences enregistrées à partir du %s:""" - % ndb.DateISOtoDMY(datedebut) - ) - T.append("\n") - if len(absnonjust): - T.append("* %d absences non justifiées:" % len(absnonjust)) - T.append(tab_absnonjust.text()) - if len(absjust): - T.append("* %d absences justifiées:" % len(absjust)) - T.append(tab_absjust.text()) - return "\n".join(T) - else: - raise ValueError("Invalid format !") - - -def _tables_abs_etud( - etudid, - datedebut, - with_evals=True, - format="html", - absjust_only=0, -): - """Tables des absences justifiees et non justifiees d'un étudiant - sur l'année en cours - """ - absjust = sco_abs.list_abs_just(etudid=etudid, datedebut=datedebut) - absnonjust = sco_abs.list_abs_non_just(etudid=etudid, datedebut=datedebut) - # examens ces jours là ? - if with_evals: - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for a in absnonjust + absjust: - cursor.execute( - """SELECT eval.* - FROM notes_evaluation eval, notes_moduleimpl_inscription mi, notes_moduleimpl m - WHERE eval.jour = %(jour)s - and eval.moduleimpl_id = m.id - and mi.moduleimpl_id = m.id - and mi.etudid = %(etudid)s - """, - {"jour": a["jour"].strftime("%Y-%m-%d"), "etudid": etudid}, - ) - a["evals"] = cursor.dictfetchall() - cursor.execute( - """SELECT mi.moduleimpl_id - FROM absences abs, notes_moduleimpl_inscription mi, notes_moduleimpl m - WHERE abs.matin = %(matin)s - and abs.jour = %(jour)s - and abs.etudid = %(etudid)s - and abs.moduleimpl_id = mi.moduleimpl_id - and mi.moduleimpl_id = m.id - and mi.etudid = %(etudid)s - """, - { - "matin": bool(a["matin"]), - "jour": a["jour"].strftime("%Y-%m-%d"), - "etudid": etudid, - }, - ) - a["absent"] = cursor.dictfetchall() - - def matin(x): - if x: - return "matin" - else: - return "après-midi" - - def descr_exams(a): - if "evals" not in a: - return "" - ex = [] - for ev in a["evals"]: - mod = sco_moduleimpl.moduleimpl_withmodule_list( - moduleimpl_id=ev["moduleimpl_id"] - )[0] - if format == "html": - ex.append( - f"""{mod["module"]["code"] or "(module sans code)"}""" - ) - else: - ex.append(mod["module"]["code"] or "(module sans code)") - if ex: - return ", ".join(ex) - return "" - - def descr_abs(a): - ex = [] - for ev in a.get("absent", []): - mod = sco_moduleimpl.moduleimpl_withmodule_list( - moduleimpl_id=ev["moduleimpl_id"] - )[0] - if format == "html": - ex.append( - f"""{mod["module"]["code"] or '(module sans code)'}""" - ) - else: - ex.append(mod["module"]["code"] or "(module sans code)") - if ex: - return ", ".join(ex) - return "" - - # ajoute date formatée et évaluations - for L in (absnonjust, absjust): - for a in L: - if with_evals: - a["exams"] = descr_exams(a) - a["datedmy"] = a["jour"].strftime("%d/%m/%Y") - a["ampm"] = int(a["matin"]) - a["matin"] = matin(a["matin"]) - index = a["description"].find(")") - if index != -1: - a["motif"] = a["description"][1:index] - else: - a["motif"] = "" - a["description"] = descr_abs(a) or "" - - # ajoute lien pour justifier - if format == "html": - for a in absnonjust: - a["justlink"] = "justifier" - a[ - "_justlink_target" - ] = "doJustifAbsence?etudid=%s&datedebut=%s&datefin=%s&demijournee=%s" % ( - etudid, - a["datedmy"], - a["datedmy"], - a["ampm"], - ) - # - titles = { - "datedmy": "Date", - "matin": "", - "exams": "Examens ce jour", - "justlink": "", - "description": "Modules", - "motif": "Motif", - } - columns_ids = ["datedmy", "matin"] - if format in ("json", "xml"): - columns_ids += ["jour", "ampm"] - if with_evals: - columns_ids.append("exams") - - columns_ids.append("description") - columns_ids.append("motif") - if format == "html": - columns_ids.append("justlink") - - return titles, columns_ids, absnonjust, absjust +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Pages HTML gestion absences + (la plupart portées du DTML) +""" +import datetime + +from flask import url_for, g, request, abort + +from app import log +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import Identite, FormSemestre +import app.scodoc.sco_utils as scu +from app.scodoc import notesdb as ndb +from app.scodoc.scolog import logdb +from app.scodoc.gen_tables import GenTable +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_etud +from app.scodoc import sco_find_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_photos +from app.scodoc import sco_preferences +from app.scodoc.sco_exceptions import ScoValueError + + +def doSignaleAbsence( + datedebut, + datefin, + moduleimpl_id=None, + demijournee=2, + estjust=False, + description=None, + etudid=False, +): # etudid implied + """Signalement d'une absence. + + Args: + datedebut: dd/mm/yyyy + datefin: dd/mm/yyyy (non incluse) + moduleimpl_id: module auquel imputer les absences + demijournee: 2 si journée complète, 1 matin, 0 après-midi + estjust: absence justifiée + description: str + etudid: etudiant concerné. Si non spécifié, cherche dans + les paramètres de la requête courante. + """ + etud = Identite.from_request(etudid) + + if not moduleimpl_id: + moduleimpl_id = None + description_abs = description + dates = sco_abs.DateRangeISO(datedebut, datefin) + nbadded = 0 + demijournee = int(demijournee) + for jour in dates: + if demijournee == 2: + sco_abs.add_absence( + etud.id, + jour, + False, + estjust, + description_abs, + moduleimpl_id, + ) + sco_abs.add_absence( + etud.id, + jour, + True, + estjust, + description_abs, + moduleimpl_id, + ) + nbadded += 2 + else: + sco_abs.add_absence( + etud.id, + jour, + demijournee, + estjust, + description_abs, + moduleimpl_id, + ) + nbadded += 1 + # + if estjust: + J = "" + else: + J = "NON " + indication_module = "" + if moduleimpl_id and moduleimpl_id != "NULL": + mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] + formsemestre_id = mod["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ues = nt.get_ues_stat_dict() + for ue in ues: + modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) + for modimpl in modimpls: + if modimpl["moduleimpl_id"] == moduleimpl_id: + indication_module = "dans le module %s" % ( + modimpl["module"]["code"] or "(pas de code)" + ) + H = [ + html_sco_header.sco_header( + page_title=f"Signalement d'une absence pour {etud.nomprenom}", + ), + """

Signalement d'absences

""", + ] + if dates: + H.append( + """

Ajout de %d absences %sjustifiées du %s au %s %s

""" + % (nbadded, J, datedebut, datefin, indication_module) + ) + else: + H.append( + """

Aucune date ouvrable entre le %s et le %s !

""" + % (datedebut, datefin) + ) + + H.append( + f""" +
+ """ + ) + H.append(sco_find_etud.form_search_etud()) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def SignaleAbsenceEtud(): # etudid implied + """Formulaire individuel simple de signalement d'une absence""" + # brute-force portage from very old dtml code ... + etud = sco_etud.get_etud_info(filled=True)[0] + etudid = etud["etudid"] + disabled = False + if not etud["cursem"]: + require_module = sco_preferences.get_preference( + "abs_require_module" + ) # on utilise la pref globale car pas de sem courant + if require_module: + menu_module = """
Pas inscrit dans un semestre courant, + et l'indication du module est requise. Donc pas de saisie d'absence possible !
""" + disabled = True + else: + menu_module = "" + else: + formsemestre_id = etud["cursem"]["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ues = nt.get_ues_stat_dict() + require_module = sco_preferences.get_preference( + "abs_require_module", formsemestre_id + ) + if require_module: + menu_module = """ + +

Module: + """ + ) + menu_module += """""" + + for ue in ues: + modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) + for modimpl in modimpls: + menu_module += ( + """\n""" + % { + "modimpl_id": modimpl["moduleimpl_id"], + "modname": modimpl["module"]["code"] or "", + } + ) + menu_module += """

""" + + H = [ + html_sco_header.sco_header( + page_title="Signalement d'une absence pour %(nomprenom)s" % etud, + ), + """
+

Signalement d'une absence pour %(nomprenom)s

+
+ """ + % etud, + """""" + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]), + sco_photos.etud_photo_html( + etudid=etudid, + title="fiche de " + etud["nomprenom"], + ), + """
""", + """ +
+ +

+ + + + + + +
Date début : j/m/a   Date fin (optionnelle): j/m/a
+
+Journée(s) + Matin(s) + Après-midi + +%(menu_module)s + +

+Absence justifiée. +
+Raison: (optionnel) +

+ +

+ + +

Seuls les modules du semestre en cours apparaissent.

+

Évitez de saisir une absence pour un module qui n'est pas en place à cette date.

+

Toutes les dates sont au format jour/mois/annee.

+
+ +
+ """ + % { + "etudid": etud["etudid"], + "menu_module": menu_module, + "disabled": "disabled" if disabled else "", + }, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def doJustifAbsence( + datedebut, + datefin, + demijournee, + description=None, + etudid=False, +): # etudid implied + """Justification d'une absence + + Args: + datedebut: dd/mm/yyyy + datefin: dd/mm/yyyy (non incluse) + demijournee: 2 si journée complète, 1 matin, 0 après-midi + estjust: absence justifiée + description: str + etudid: etudiant concerné. Si non spécifié, cherche dans les + paramètres de la requête. + """ + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + etudid = etud["etudid"] + description_abs = description + dates = sco_abs.DateRangeISO(datedebut, datefin) + nbadded = 0 + demijournee = int(demijournee) + for jour in dates: + if demijournee == 2: + sco_abs.add_justif( + etudid=etudid, + jour=jour, + matin=False, + description=description_abs, + ) + sco_abs.add_justif( + etudid=etudid, + jour=jour, + matin=True, + description=description_abs, + ) + nbadded += 2 + else: + sco_abs.add_justif( + etudid=etudid, + jour=jour, + matin=demijournee, + description=description_abs, + ) + nbadded += 1 + # + H = [ + html_sco_header.sco_header( + page_title="Justification d'une absence pour %(nomprenom)s" % etud, + ), + """

Justification d'absences

""", + ] + if dates: + H.append( + """

Ajout de %d justifications du %s au %s

""" + % (nbadded, datedebut, datefin) + ) + else: + H.append( + """

Aucune date ouvrable entre le %s et le %s !

""" + % (datedebut, datefin) + ) + + H.append( + """ +
""" + % etud + ) + H.append(sco_find_etud.form_search_etud()) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def JustifAbsenceEtud(): # etudid implied + """Formulaire individuel simple de justification d'une absence""" + # brute-force portage from very old dtml code ... + etud = sco_etud.get_etud_info(filled=True)[0] + etudid = etud["etudid"] + H = [ + html_sco_header.sco_header( + page_title="Justification d'une absence pour %(nomprenom)s" % etud, + ), + """
+

Justification d'une absence pour %(nomprenom)s

+
+ """ + % etud, + """""" + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + sco_photos.etud_photo_html( + etudid=etudid, + title="fiche de " + etud["nomprenom"], + ), + """
""", + """ +
+ + +

+ + + + + + +
Date début : + +   Date Fin (optionnel):
+
+ +Journée(s) + Matin(s) + Après midi + +

+Raison: (optionnel) + +

+ + +

""" + % etud, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def doAnnuleAbsence(datedebut, datefin, demijournee, etudid=False): # etudid implied + """Annulation des absences pour une demi journée""" + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + etudid = etud["etudid"] + + dates = sco_abs.DateRangeISO(datedebut, datefin) + nbadded = 0 + demijournee = int(demijournee) + for jour in dates: + if demijournee == 2: + sco_abs.annule_absence(etudid, jour, False) + sco_abs.annule_absence(etudid, jour, True) + nbadded += 2 + else: + sco_abs.annule_absence(etudid, jour, demijournee) + nbadded += 1 + # + H = [ + html_sco_header.sco_header( + page_title="Annulation d'une absence pour %(nomprenom)s" % etud, + ), + """

Annulation d'absences pour %(nomprenom)s

""" % etud, + ] + if dates: + H.append( + "

Annulation sur %d demi-journées du %s au %s" + % (nbadded, datedebut, datefin) + ) + else: + H.append( + """

Aucune date ouvrable entre le %s et le %s !

""" + % (datedebut, datefin) + ) + + H.append( + """ +
""" + % etud + ) + H.append(sco_find_etud.form_search_etud()) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def AnnuleAbsenceEtud(): # etudid implied + """Formulaire individuel simple d'annulation d'une absence""" + # brute-force portage from very old dtml code ... + etud = sco_etud.get_etud_info(filled=True)[0] + etudid = etud["etudid"] + + H = [ + html_sco_header.sco_header( + page_title="Annulation d'une absence pour %(nomprenom)s" % etud, + ), + """
+

Annulation d'une absence pour %(nomprenom)s

+
+ """ + % etud, # " + """""" + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + sco_photos.etud_photo_html( + etudid=etudid, + title="fiche de " + etud["nomprenom"], + ), + """
""", + """

A n'utiliser que suite à une erreur de saisie ou lorsqu'il s'avère que l'étudiant était en fait présent.

+

Si plusieurs modules sont affectés, les absences seront toutes effacées.

+ """ + % etud, + """ + +
+
+ +

+ + + + + + +
Date début : + j/m/a +   Date Fin (optionnel): + j/m/a +
+ +journée(s) + Matin(s) + Après midi + + +

+ +

+
+
+ +

+ + + + + + +
Date début : + j/m/a +   Date Fin (optionnel): + j/m/a +
+

+ +journée(s) + Matin(s) + Après midi + + +

+ +(utiliser ceci en cas de justificatif erroné saisi indépendemment d'une absence) +

+
""" + % etud, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def doAnnuleJustif(datedebut0, datefin0, demijournee): # etudid implied + """Annulation d'une justification""" + etud = sco_etud.get_etud_info(filled=True)[0] + etudid = etud["etudid"] + dates = sco_abs.DateRangeISO(datedebut0, datefin0) + nbadded = 0 + demijournee = int(demijournee) + for jour in dates: + # Attention: supprime matin et après-midi + if demijournee == 2: + sco_abs.annule_justif(etudid, jour, False) + sco_abs.annule_justif(etudid, jour, True) + nbadded += 2 + else: + sco_abs.annule_justif(etudid, jour, demijournee) + nbadded += 1 + # + H = [ + html_sco_header.sco_header( + page_title="Annulation d'une justification pour %(nomprenom)s" % etud, + ), + """

Annulation de justifications pour %(nomprenom)s

""" % etud, + ] + + if dates: + H.append( + "

Annulation sur %d demi-journées du %s au %s" + % (nbadded, datedebut0, datefin0) + ) + else: + H.append( + """

Aucune date ouvrable entre le %s et le %s !

""" + % (datedebut0, datefin0) + ) + H.append( + """ +
""" + % etud + ) + H.append(sco_find_etud.form_search_etud()) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id=None): + """Supprime les absences non justifiées aux dates indiquées + Ne supprime pas les justificatifs éventuels. + Args: + etudid: l'étudiant + dates: liste de dates iso, eg [ "2000-01-15", "2000-01-16" ] + moduleimpl_id: si spécifié, n'affecte que les absences de ce module + + Returns: + None + """ + # log('AnnuleAbsencesDatesNoJust: moduleimpl_id=%s' % moduleimpl_id) + if not dates: + return + date0 = dates[0] + if len(date0.split(":")) == 2: + # am/pm is present + for date in dates: + jour, ampm = date.split(":") + if ampm == "am": + matin = 1 + elif ampm == "pm": + matin = 0 + else: + raise ValueError("invalid ampm !") + sco_abs.annule_absence(etudid, jour, matin, moduleimpl_id) + return + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + # supr les absences non justifiees + for date in dates: + cursor.execute( + """DELETE FROM absences + WHERE etudid=%(etudid)s and (not estjust) and jour=%(date)s and moduleimpl_id=%(moduleimpl_id)s + """, + vars(), + ) + sco_abs.invalidate_abs_etud_date(etudid, date) + # s'assure que les justificatifs ne sont pas "absents" + for date in dates: + cursor.execute( + """UPDATE absences SET estabs=FALSE + WHERE etudid=%(etudid)s AND jour=%(date)s AND moduleimpl_id=%(moduleimpl_id)s + """, + vars(), + ) + if dates: + date0 = dates[0] + else: + date0 = None + if len(dates) > 1: + date1 = dates[1] + else: + date1 = None + logdb( + cnx, + "AnnuleAbsencesDatesNoJust", + etudid=etudid, + msg="%s - %s - %s" % (date0, date1, moduleimpl_id), + ) + cnx.commit() + + +def EtatAbsences(): + """Etat des absences: choix du groupe""" + # crude portage from 1999 DTML + H = [ + html_sco_header.sco_header(page_title="Etat des absences"), + """

État des absences pour un groupe

+
""", + formChoixSemestreGroupe(), + """ + +
Date de début (j/m/a) : + + + +
Date de fin : + + + +
+
""" + % (scu.AnneeScolaire(), datetime.datetime.now().strftime("%d/%m/%Y")), + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def formChoixSemestreGroupe(all=False): + """partie de formulaire pour le choix d'un semestre et d'un groupe. + Si all, donne tous les semestres (même ceux verrouillés). + """ + # XXX assez primitif, à ameliorer TOTALEMENT OBSOLETE ! + if all: + sems = sco_formsemestre.do_formsemestre_list() + else: + sems = sco_formsemestre.do_formsemestre_list(args={"etat": "1"}) + if not sems: + raise ScoValueError("aucun semestre !") + H = ['") + return "\n".join(H) + + +def CalAbs(etudid, sco_year=None): + """Calendrier des absences d'un etudiant""" + # crude portage from 1999 DTML + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + etudid = etud["etudid"] + anneescolaire = int(scu.AnneeScolaire(sco_year)) + datedebut = str(anneescolaire) + "-08-01" + datefin = str(anneescolaire + 1) + "-07-31" + annee_courante = scu.AnneeScolaire() + nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) + nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) + events = [] + for a in sco_abs.list_abs_just(etudid=etudid, datedebut=datedebut): + events.append( + (str(a["jour"]), "a", "#F8B7B0", "", a["matin"], a["description"]) + ) + for a in sco_abs.list_abs_non_just(etudid=etudid, datedebut=datedebut): + events.append( + (str(a["jour"]), "A", "#EE0000", "", a["matin"], a["description"]) + ) + justifs_noabs = sco_abs.list_abs_justifs( + etudid=etudid, datedebut=datedebut, only_no_abs=True + ) + for a in justifs_noabs: + events.append( + (str(a["jour"]), "X", "#8EA2C6", "", a["matin"], a["description"]) + ) + CalHTML = sco_abs.YearTable(anneescolaire, events=events, halfday=1) + + # + H = [ + html_sco_header.sco_header( + page_title="Calendrier des absences de %(nomprenom)s" % etud, + cssstyles=["css/calabs.css"], + ), + """ + + +

Absences de %(nomprenom)s (%(inscription)s)

""" + % etud, + """A : absence NON justifiée
+ a : absence justifiée
+ X : justification sans absence
+ %d absences sur l'année, dont %d justifiées (soit %d non justifiées)
(%d justificatifs inutilisés) +

+ """ + % (nbabs, nbabsjust, nbabs - nbabsjust, len(justifs_noabs)), + """
%s
""" + % ( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + sco_photos.etud_photo_html( + etudid=etudid, + title="fiche de " + etud["nomprenom"], + ), + ), + CalHTML, + """
""", + """""" % etudid, + """Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), + """  Changer année:
""") + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def ListeAbsEtud( + etudid=None, + code_nip=None, + with_evals=True, + format="html", + absjust_only=0, + sco_year=None, +): + """Liste des absences d'un étudiant sur l'année en cours + En format 'html': page avec deux tableaux (non justifiées et justifiées). + En format json, xml, xls ou pdf: l'un ou l'autre des table, suivant absjust_only. + En format 'text': texte avec liste d'absences (pour mails). + + Args: + etudid: + with_evals: indique les evaluations aux dates d'absences + absjust_only: si vrai, renvoie table absences justifiées + sco_year: année scolaire à utiliser. Si non spécifier, utilie l'année en cours. e.g. "2005" + """ + # si absjust_only, table absjust seule (export xls ou pdf) + absjust_only = scu.to_bool(absjust_only) + datedebut = "%s-08-01" % scu.AnneeScolaire(sco_year=sco_year) + etudid = etudid or False + etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True) + if not etuds: + log(f"ListeAbsEtud: no etuds with etudid={etudid} or nip={code_nip}") + abort(404) + etud = etuds[0] + etudid = etud["etudid"] + # Liste des absences et titres colonnes tables: + titles, columns_ids, absnonjust, absjust = _tables_abs_etud( + etudid, datedebut, with_evals=with_evals, format=format + ) + if request.base_url: + base_url_nj = "%s?etudid=%s&absjust_only=0" % (request.base_url, etudid) + base_url_j = "%s?etudid=%s&absjust_only=1" % (request.base_url, etudid) + else: + base_url_nj = base_url_j = "" + tab_absnonjust = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=absnonjust, + html_class="table_leftalign", + table_id="tab_absnonjust", + base_url=base_url_nj, + filename="abs_" + scu.make_filename(etud["nomprenom"]), + caption="Absences non justifiées de %(nomprenom)s" % etud, + preferences=sco_preferences.SemPreferences(), + ) + tab_absjust = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=absjust, + html_class="table_leftalign", + table_id="tab_absjust", + base_url=base_url_j, + filename="absjust_" + scu.make_filename(etud["nomprenom"]), + caption="Absences justifiées de %(nomprenom)s" % etud, + preferences=sco_preferences.SemPreferences(), + ) + + # Formats non HTML et demande d'une seule table: + if format != "html" and format != "text": + if absjust_only == 1: + return tab_absjust.make_page(format=format) + else: + return tab_absnonjust.make_page(format=format) + + if format == "html": + # Mise en forme HTML: + H = [] + H.append( + html_sco_header.sco_header(page_title="Absences de %s" % etud["nomprenom"]) + ) + H.append( + """

Absences de %s (à partir du %s)

""" + % (etud["nomprenom"], ndb.DateISOtoDMY(datedebut)) + ) + + if len(absnonjust): + H.append("

%d absences non justifiées:

" % len(absnonjust)) + H.append(tab_absnonjust.html()) + else: + H.append("""

Pas d'absences non justifiées

""") + + if len(absjust): + H.append("""

%d absences justifiées:

""" % len(absjust)) + H.append(tab_absjust.html()) + else: + H.append("""

Pas d'absences justifiées

""") + return "\n".join(H) + html_sco_header.sco_footer() + + elif format == "text": + T = [] + if not len(absnonjust) and not len(absjust): + T.append( + """--- Pas d'absences enregistrées depuis le %s""" + % ndb.DateISOtoDMY(datedebut) + ) + else: + T.append( + """--- Absences enregistrées à partir du %s:""" + % ndb.DateISOtoDMY(datedebut) + ) + T.append("\n") + if len(absnonjust): + T.append("* %d absences non justifiées:" % len(absnonjust)) + T.append(tab_absnonjust.text()) + if len(absjust): + T.append("* %d absences justifiées:" % len(absjust)) + T.append(tab_absjust.text()) + return "\n".join(T) + else: + raise ValueError("Invalid format !") + + +def _tables_abs_etud( + etudid, + datedebut, + with_evals=True, + format="html", + absjust_only=0, +): + """Tables des absences justifiees et non justifiees d'un étudiant + sur l'année en cours + """ + absjust = sco_abs.list_abs_just(etudid=etudid, datedebut=datedebut) + absnonjust = sco_abs.list_abs_non_just(etudid=etudid, datedebut=datedebut) + # examens ces jours là ? + if with_evals: + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for a in absnonjust + absjust: + cursor.execute( + """SELECT eval.* + FROM notes_evaluation eval, notes_moduleimpl_inscription mi, notes_moduleimpl m + WHERE eval.jour = %(jour)s + and eval.moduleimpl_id = m.id + and mi.moduleimpl_id = m.id + and mi.etudid = %(etudid)s + """, + {"jour": a["jour"].strftime("%Y-%m-%d"), "etudid": etudid}, + ) + a["evals"] = cursor.dictfetchall() + cursor.execute( + """SELECT mi.moduleimpl_id + FROM absences abs, notes_moduleimpl_inscription mi, notes_moduleimpl m + WHERE abs.matin = %(matin)s + and abs.jour = %(jour)s + and abs.etudid = %(etudid)s + and abs.moduleimpl_id = mi.moduleimpl_id + and mi.moduleimpl_id = m.id + and mi.etudid = %(etudid)s + """, + { + "matin": bool(a["matin"]), + "jour": a["jour"].strftime("%Y-%m-%d"), + "etudid": etudid, + }, + ) + a["absent"] = cursor.dictfetchall() + + def matin(x): + if x: + return "matin" + else: + return "après-midi" + + def descr_exams(a): + if "evals" not in a: + return "" + ex = [] + for ev in a["evals"]: + mod = sco_moduleimpl.moduleimpl_withmodule_list( + moduleimpl_id=ev["moduleimpl_id"] + )[0] + if format == "html": + ex.append( + f"""{mod["module"]["code"] or "(module sans code)"}""" + ) + else: + ex.append(mod["module"]["code"] or "(module sans code)") + if ex: + return ", ".join(ex) + return "" + + def descr_abs(a): + ex = [] + for ev in a.get("absent", []): + mod = sco_moduleimpl.moduleimpl_withmodule_list( + moduleimpl_id=ev["moduleimpl_id"] + )[0] + if format == "html": + ex.append( + f"""{mod["module"]["code"] or '(module sans code)'}""" + ) + else: + ex.append(mod["module"]["code"] or "(module sans code)") + if ex: + return ", ".join(ex) + return "" + + # ajoute date formatée et évaluations + for L in (absnonjust, absjust): + for a in L: + if with_evals: + a["exams"] = descr_exams(a) + a["datedmy"] = a["jour"].strftime("%d/%m/%Y") + a["ampm"] = int(a["matin"]) + a["matin"] = matin(a["matin"]) + index = a["description"].find(")") + if index != -1: + a["motif"] = a["description"][1:index] + else: + a["motif"] = "" + a["description"] = descr_abs(a) or "" + + # ajoute lien pour justifier + if format == "html": + for a in absnonjust: + a["justlink"] = "justifier" + a[ + "_justlink_target" + ] = "doJustifAbsence?etudid=%s&datedebut=%s&datefin=%s&demijournee=%s" % ( + etudid, + a["datedmy"], + a["datedmy"], + a["ampm"], + ) + # + titles = { + "datedmy": "Date", + "matin": "", + "exams": "Examens ce jour", + "justlink": "", + "description": "Modules", + "motif": "Motif", + } + columns_ids = ["datedmy", "matin"] + if format in ("json", "xml"): + columns_ids += ["jour", "ampm"] + if with_evals: + columns_ids.append("exams") + + columns_ids.append("description") + columns_ids.append("motif") + if format == "html": + columns_ids.append("justlink") + + return titles, columns_ids, absnonjust, absjust diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 679ec778..db0a64c2 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -1,1358 +1,1358 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Exportation des résultats des étudiants vers Apogée. - -Ce code a été au départ inspiré par les travaux de Damien Mascré, scodoc2apogee (en Java). - -A utiliser en fin de semestre, après les jury. - -On communique avec Apogée via des fichiers CSV. - -Le fichier CSV, champs séparés par des tabulations, a la structure suivante: - -
- 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_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
- 
- - - 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 -
- apoL_c0001	VET	V1CJ ...
- 
- - -XXX A vérifier: - AJAC car 1 sem. validé et pas de NAR - -""" - -import collections -import datetime -from functools import reduce -import functools -import io -import os -import pprint -import re -import time -from zipfile import ZipFile - -from flask import send_file -import numpy as np - -# 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.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, Identite -from app.models.config import ScoDocSiteConfig -import app.scodoc.sco_utils as scu -from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_vdi import ApoEtapeVDI -from app.scodoc.sco_codes_parcours import code_semestre_validant -from app.scodoc.sco_codes_parcours import ( - DEF, - DEM, - NAR, - RAT, -) -from app.scodoc import sco_cursus -from app.scodoc import sco_formsemestre -from app.scodoc import sco_etud - -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" - - -def _apo_fmt_note(note, fmt="%3.2f"): - "Formatte une note pour Apogée (séparateur décimal: ',')" - # if not note and isinstance(note, float): changé le 31/1/2022, étrange ? - # return "" - try: - val = float(note) - except ValueError: - return "" - if np.isnan(val): - return "" - return (fmt % val).replace(".", APO_DECIMAL_SEP) - - -def guess_data_encoding(text, 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, -) -> bytes: - """Try to ensure that text is using dest_encoding - returns converted text, and a message describing the conversion. - """ - message = "" - detected_encoding = guess_data_encoding(text) - if not detected_encoding: - if default_source_encoding != dest_encoding: - message = "converting from %s to %s" % ( - default_source_encoding, - dest_encoding, - ) - text = text.decode(default_source_encoding).encode( - dest_encoding - ) # XXX #py3 #sco8 à tester - else: - if detected_encoding != dest_encoding: - message = "converting from detected %s to %s" % ( - detected_encoding, - dest_encoding, - ) - text = text.decode(detected_encoding).encode(dest_encoding) # XXX - return text, message - - -class StringIOFileLineWrapper(object): - def __init__(self, data: str): - self.f = io.StringIO(data) - self.lineno = 0 - - def close(self): - return self.f.close() - - def readline(self): - self.lineno += 1 - return self.f.readline() - - -class DictCol(dict): - "A dict, where we can add attributes" - pass - - -class ApoElt(object): - """Definition d'un Element Apogee - 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): - assert col["Code"] == self.code - if col["Type Objet"] != self.type_objet: - log( - "Warning: ApoElt: duplicate id %s (%s and %s)" - % (self.code, self.type_objet, col["Type Objet"]) - ) - self.type_objet = col["Type Objet"] - self.cols.append(col) - - def __repr__(self): - return "ApoElt(code='%s', cols=%s)" % (self.code, pprint.pformat(self.cols)) - - -class EtuCol(object): - """Valeurs colonnes d'un element pour un etudiant""" - - def __init__(self, nip, apo_elt, init_vals): - pass # XXX - - -ETUD_OK = "ok" -ETUD_ORPHELIN = "orphelin" -ETUD_NON_INSCRIT = "non_inscrit" - -VOID_APO_RES = dict(N="", B="", J="", R="", M="") - - -class ApoEtud(dict): - """Étudiant Apogee:""" - - def __init__( - self, - nip="", - nom="", - prenom="", - naissance="", - cols={}, - export_res_etape=True, - export_res_sem=True, - export_res_ues=True, - export_res_modules=True, - export_res_sdj=True, - export_res_rat=True, - ): - self["nip"] = nip - self["nom"] = nom - self["prenom"] = prenom - self["naissance"] = naissance - self.cols = cols - "{ col_id : value } colid = 'apoL_c0001'" - self.is_apc = None - "Vrai si BUT" - self.col_elts = {} - "{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}" - self.new_cols = {} # { col_id : value to record in csv } - self.etud: Identite = None - "etudiant ScoDoc associé" - self.etat = None # ETUD_OK, ... - self.is_NAR = False - "True si NARé dans un semestre" - self.log = [] - self.has_logged_no_decision = False - self.export_res_etape = export_res_etape # VET, ... - self.export_res_sem = export_res_sem # elt_sem_apo - self.export_res_ues = export_res_ues - self.export_res_modules = export_res_modules - self.export_res_sdj = export_res_sdj # export meme si pas de decision de jury - self.export_res_rat = export_res_rat - self.fmt_note = functools.partial( - _apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f" - ) - - def __repr__(self): - return f"""ApoEtud( nom='{self["nom"]}', nip='{self["nip"]}' )""" - - def lookup_scodoc(self, etape_formsemestre_ids): - """Cherche l'étudiant ScoDoc associé à cet étudiant Apogée. - S'il n'est pas trouvé (état "orphelin", dans Apo mais pas chez nous), - met .etud à None. - Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT. - """ - - # futur: #WIP - # etud: Identite = Identite.query.filter_by(code_nip=self["nip"]).first() - # self.etud = etud - etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True) - if not etuds: - # pas dans ScoDoc - self.etud = None - self.log.append("non inscrit dans ScoDoc") - self.etat = ETUD_ORPHELIN - else: - # futur: #WIP - # formsemestre_ids = { - # ins.formsemestre_id for ins in etud.formsemestre_inscriptions - # } - # in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) - self.etud = etuds[0] - # cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape: - formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]} - in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) - if not in_formsemestre_ids: - self.log.append( - "connu dans ScoDoc, mais pas inscrit dans un semestre de cette étape" - ) - self.etat = ETUD_NON_INSCRIT - else: - self.etat = ETUD_OK - - def associate_sco(self, apo_data: "ApoData"): - """Recherche les valeurs des éléments Apogée pour cet étudiant - Set .new_cols - """ - self.col_elts = {} # {'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}} - if self.etat is None: - self.lookup_scodoc(apo_data.etape_formsemestre_ids) - if self.etat != ETUD_OK: - self.new_cols = ( - self.cols - ) # etudiant inconnu, recopie les valeurs existantes dans Apo - else: - sco_elts = {} # valeurs trouvées dans ScoDoc code : { N, B, J, R } - for col_id in apo_data.col_ids[4:]: - code = apo_data.cols[col_id]["Code"] # 'V1RT' - el = sco_elts.get( - code, None - ) # {'R': ADM, 'J': '', 'B': 20, 'N': '12.14'} - if el is None: # pas déjà trouvé - cur_sem, autre_sem = self.etud_semestres_de_etape(apo_data) - for sem in apo_data.sems_etape: - el = self.search_elt_in_sem(code, sem, cur_sem, autre_sem) - if el is not None: - sco_elts[code] = el - break - self.col_elts[code] = el - if el is None: - self.new_cols[col_id] = self.cols[col_id] - else: - try: - self.new_cols[col_id] = sco_elts[code][ - apo_data.cols[col_id]["Type Rés."] - ] - except KeyError as exc: - log( - f"associate_sco: missing key, etud={self}\ncode='{code}'\netape='{apo_data.etape_apogee}'" - ) - raise ScoValueError( - f"""L'élément {code} n'a pas de résultat: peut-être une erreur - dans les codes sur le programme pédagogique - (vérifier qu'il est bien associé à une UE ou semestre)?""" - ) from exc - # recopie les 4 premieres colonnes (nom, ..., naissance): - for col_id in apo_data.col_ids[:4]: - self.new_cols[col_id] = self.cols[col_id] - - # def unassociated_codes(self, apo_data): - # "list of apo elements for this student without a value in ScoDoc" - # codes = set([apo_data.cols[col_id].code for col_id in apo_data.col_ids]) - # return codes - set(sco_elts) - - def search_elt_in_sem(self, code, sem, cur_sem, autre_sem) -> dict: - """ - VET code jury etape - ELP élément pédagogique: UE, module - Autres éléments: résultats du semestre ou de l'année scolaire: - => VRTW1: code additionnel au semestre ("code élement semestre", elt_sem_apo) - => VRT1A: le même que le VET: ("code élement annuel", elt_annee_apo) - Attention, si le semestre couvre plusieurs étapes, indiquer les codes des éléments, - séparés par des virgules. - - Args: - code (str): code apo de l'element cherché - sem (dict): semestre dans lequel on cherche l'élément - cur_sem (dict): semestre "courant" pour résultats annuels (VET) - autre_sem (dict): autre semestre utilisé pour calculé les résultats annuels (VET) - - Returns: - dict: with N, B, J, R keys, ou None si elt non trouvé - """ - etudid = self.etud["etudid"] - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - if etudid not in nt.identdict: - return None # etudiant non inscrit dans ce semestre - - decision = nt.get_etud_decision_sem(etudid) - if not self.export_res_sdj and not decision: - # pas de decision de jury, on n'enregistre rien - # (meme si démissionnaire) - if not self.has_logged_no_decision: - self.log.append("Pas de decision") - self.has_logged_no_decision = True - return VOID_APO_RES - - if decision and decision["code"] == NAR: - self.is_NAR = True - - # Element etape (annuel ou non): - if sco_formsemestre.sem_has_etape(sem, code) or ( - code in {x.strip() for x in sem["elt_annee_apo"].split(",")} - ): - export_res_etape = self.export_res_etape - if (not export_res_etape) and cur_sem: - # exporte toujours le résultat de l'étape si l'étudiant est diplômé - Se = sco_cursus.get_situation_etud_cursus( - self.etud, cur_sem["formsemestre_id"] - ) - export_res_etape = Se.all_other_validated() - - if export_res_etape: - return self.comp_elt_annuel(etudid, cur_sem, autre_sem) - else: - return VOID_APO_RES - - # Element semestre: - if code in {x.strip() for x in sem["elt_sem_apo"].split(",")}: - if self.export_res_sem: - return self.comp_elt_semestre(nt, decision, etudid) - else: - return VOID_APO_RES - - # Elements UE - decisions_ue = nt.get_etud_decision_ues(etudid) - for ue in nt.get_ues_stat_dict(): - if ue["code_apogee"] and code in { - x.strip() for x in ue["code_apogee"].split(",") - }: - if self.export_res_ues: - if decisions_ue and ue["ue_id"] in decisions_ue: - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) - code_decision_ue = decisions_ue[ue["ue_id"]]["code"] - return dict( - N=self.fmt_note(ue_status["moy"] if ue_status else ""), - B=20, - J="", - R=ScoDocSiteConfig.get_code_apo(code_decision_ue), - M="", - ) - else: - return VOID_APO_RES - else: - return VOID_APO_RES - - # Elements Modules - modimpls = nt.get_modimpls_dict() - module_code_found = False - for modimpl in modimpls: - module = modimpl["module"] - if module["code_apogee"] and code in { - x.strip() for x in module["code_apogee"].split(",") - }: - n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) - if n != "NI" and self.export_res_modules: - return dict(N=self.fmt_note(n), B=20, J="", R="") - else: - module_code_found = True - if module_code_found: - return VOID_APO_RES - # - return None # element Apogee non trouvé dans ce semestre - - def comp_elt_semestre(self, nt, decision, etudid): - """Calcul résultat apo semestre""" - if self.is_apc: - # pas de code semestre en APC ! - return dict(N="", B=20, J="", R="", M="") - if decision is None: - etud = Identite.query.get(etudid) - nomprenom = etud.nomprenom if etud else "(inconnu)" - raise ScoValueError( - f"decision absente pour l'étudiant {nomprenom} ({etudid})" - ) - # resultat du semestre - decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) - note = nt.get_etud_moy_gen(etudid) - if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF: - note_str = "0,01" # note non nulle pour les démissionnaires - else: - note_str = self.fmt_note(note) - return dict(N=note_str, B=20, J="", R=decision_apo, M="") - - def comp_elt_annuel(self, etudid, cur_sem, autre_sem): - """Calcul resultat annuel (VET) à partir du semestre courant - et de l'autre (le suivant ou le précédent complétant l'année scolaire) - """ - # Code annuel: - # - Note: moyenne des moyennes générales des deux semestres (pas vraiment de sens, mais faute de mieux) - # on pourrait aussi bien prendre seulement la note du dernier semestre (S2 ou S4). Paramétrable ? - # - Résultat jury: - # si l'autre est validé, code du semestre courant (ex: S1 (ADM), S2 (AJ) => année AJ) - # si l'autre n'est pas validé ou est DEF ou DEM, code de l'autre - # - # XXX cette règle est discutable, à valider - - # print 'comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']) - if not cur_sem: - # l'étudiant n'a pas de semestre courant ?! - log("comp_elt_annuel: etudid %s has no cur_sem" % etudid) - return VOID_APO_RES - cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"]) - cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre) - cur_decision = cur_nt.get_etud_decision_sem(etudid) - if not cur_decision: - # pas de decision => pas de résultat annuel - return VOID_APO_RES - - if (cur_decision["code"] == RAT) and not self.export_res_rat: - # ne touche pas aux RATs - return VOID_APO_RES - - if not autre_sem: - # formations monosemestre, ou code VET semestriel, - # ou jury intermediaire et etudiant non redoublant... - return self.comp_elt_semestre(cur_nt, cur_decision, etudid) - - decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"]) - - autre_formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"]) - autre_nt: NotesTableCompat = res_sem.load_formsemestre_results( - autre_formsemestre - ) - autre_decision = autre_nt.get_etud_decision_sem(etudid) - if not autre_decision: - # pas de decision dans l'autre => pas de résultat annuel - return VOID_APO_RES - autre_decision_apo = ScoDocSiteConfig.get_code_apo(autre_decision["code"]) - if ( - autre_decision_apo == "DEF" - or autre_decision["code"] == DEM - or autre_decision["code"] == DEF - ) or ( - decision_apo == "DEF" - or cur_decision["code"] == DEM - or cur_decision["code"] == DEF - ): - note_str = "0,01" # note non nulle pour les démissionnaires - else: - note = cur_nt.get_etud_moy_gen(etudid) - autre_note = autre_nt.get_etud_moy_gen(etudid) - # print 'note=%s autre_note=%s' % (note, autre_note) - try: - moy_annuelle = (note + autre_note) / 2 - except TypeError: - moy_annuelle = "" - note_str = self.fmt_note(moy_annuelle) - - if code_semestre_validant(autre_decision["code"]): - decision_apo_annuelle = decision_apo - else: - decision_apo_annuelle = autre_decision_apo - - return dict(N=note_str, B=20, J="", R=decision_apo_annuelle, M="") - - def etud_semestres_de_etape(self, apo_data): - """ - Lorsqu'on a une formation semestrialisée mais avec un code étape annuel, - il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer - le code annuel (VET ou VRT1A (voir elt_annee_apo)). - - Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même - étape lors d'une année précédente ? - - Renvoie le semestre "courant" et l'autre semestre, ou None s'il n'y en a pas. - """ - # Cherche le semestre "courant": - cur_sems = [ - sem - for sem in self.etud["sems"] - if ( - (sem["semestre_id"] == apo_data.cur_semestre_id) - and (apo_data.etape in sem["etapes"]) - and ( - sco_formsemestre.sem_in_annee_scolaire(sem, apo_data.annee_scolaire) - ) - ) - ] - if not cur_sems: - cur_sem = None - else: - # prend le plus recent avec decision - cur_sem = None - for sem in cur_sems: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - decision = nt.get_etud_decision_sem(self.etud["etudid"]) - if decision: - cur_sem = sem - break - if cur_sem is None: - cur_sem = cur_sems[0] # aucun avec decison, prend le plus recent - - if apo_data.cur_semestre_id <= 0: - return ( - cur_sem, - None, - ) # "autre_sem" non pertinent pour sessions sans semestres - - if apo_data.jury_intermediaire: # jury de janvier - # Le semestre suivant: exemple 2 si on est en jury de S1 - autre_semestre_id = apo_data.cur_semestre_id + 1 - else: - # Le précédent (S1 si on est en S2) - autre_semestre_id = apo_data.cur_semestre_id - 1 - - # L'autre semestre DOIT être antérieur au courant indiqué par apo_data - if apo_data.periode is not None: - if apo_data.periode == 1: - courant_annee_debut = apo_data.annee_scolaire - courant_mois_debut = 9 # periode = 1 (sept-jan) - elif apo_data.periode == 2: - courant_annee_debut = apo_data.annee_scolaire + 1 - courant_mois_debut = 1 # ou 2 (fev-jul) - else: - raise ValueError("invalid pediode value !") # bug ? - courant_date_debut = "%d-%02d-01" % ( - courant_annee_debut, - courant_mois_debut, - ) - else: - courant_date_debut = "9999-99-99" - - # etud['sems'] est la liste des semestres de l'étudiant, triés par date, - # le plus récemment effectué en tête. - # Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée - # s'il y en a plusieurs, choisit le plus récent ayant une décision - - autres_sems = [] - for sem in self.etud["sems"]: - if ( - sem["semestre_id"] == autre_semestre_id - and apo_data.etape_apogee in sem["etapes"] - ): - if ( - sem["date_debut_iso"] < courant_date_debut - ): # on demande juste qu'il ait démarré avant - autres_sems.append(sem) - if not autres_sems: - autre_sem = None - elif len(autres_sems) == 1: - autre_sem = autres_sems[0] - else: - autre_sem = None - for sem in autres_sems: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - decision = nt.get_etud_decision_sem(self.etud["etudid"]) - if decision: - autre_sem = sem - break - if autre_sem is None: - autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent - - return cur_sem, autre_sem - - -class ApoData(object): - def __init__( - self, - data: str, - periode=None, - export_res_etape=True, - export_res_sem=True, - export_res_ues=True, - export_res_modules=True, - export_res_sdj=True, - export_res_rat=True, - orig_filename=None, - ): - """Lecture du fichier CSV Apogée - Regroupe les élements importants d'un fichier CSV Apogée - periode = 1 (sept-jan) ou 2 (fev-jul), mais cette info n'est pas - (toujours) présente dans les CSV Apogée et doit être indiquée par l'utilisateur - Laisser periode à None si etape en 1 semestre (LP, décalés, ...) - """ - self.export_res_etape = export_res_etape # VET, ... - self.export_res_sem = export_res_sem # elt_sem_apo - self.export_res_ues = export_res_ues - self.export_res_modules = export_res_modules - self.export_res_sdj = export_res_sdj - self.export_res_rat = export_res_rat - self.orig_filename = orig_filename - self.periode = periode # - self.is_apc = None - "Vrai si BUT" - try: - self.read_csv(data) - except ScoFormatError as e: - # essaie de retrouver le nom du fichier pour enrichir le message d'erreur - filename = "" - if self.orig_filename is None: - if hasattr(self, "titles"): - filename = self.titles.get("apoC_Fichier_Exp", filename) - else: - filename = self.orig_filename - raise ScoFormatError( - "

Erreur lecture du fichier Apogée %s

" % filename - + e.args[0] - + "

" - ) from e - self.etape_apogee = self.get_etape_apogee() # 'V1RT' - self.vdi_apogee = self.get_vdi_apogee() # '111' - self.etape = ApoEtapeVDI(etape=self.etape_apogee, vdi=self.vdi_apogee) - self.cod_dip_apogee = self.get_cod_dip_apogee() - self.annee_scolaire = self.get_annee_scolaire() - self.jury_intermediaire = ( - False # True si jury à mi-étape, eg jury de S1 dans l'étape (S1, S2) - ) - - log( - "ApoData( periode=%s, annee_scolaire=%s )" - % (self.periode, self.annee_scolaire) - ) - - def set_periode(self, periode): # currently unused - self.periode = periode - - def setup(self): - """Recherche semestres ScoDoc concernés""" - self.sems_etape = comp_apo_sems(self.etape_apogee, self.annee_scolaire) - self.formsemestres_etape = [ - FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in self.sems_etape - ] - apcs = { - formsemestre.formation.is_apc() for formsemestre in self.formsemestres_etape - } - if len(apcs) != 1: - raise ScoValueError( - "l'ensemble mixe des semestres BUT (APC) et des semestres classiques !" - ) - self.is_apc = apcs.pop() - self.etape_formsemestre_ids = {s["formsemestre_id"] for s in self.sems_etape} - if self.periode is not None: - self.sems_periode = [ - s - for s in self.sems_etape - if (s["periode"] == self.periode) or s["semestre_id"] < 0 - ] - if not self.sems_periode: - log("** Warning: ApoData.setup: sems_periode is empty") - log( - "** (periode=%s, sems_etape [periode]=%s)" - % (self.periode, [s["periode"] for s in self.sems_etape]) - ) - self.sems_periode = None - self.cur_semestre_id = -1 # ? - else: - self.cur_semestre_id = self.sems_periode[0]["semestre_id"] - # Les semestres de la période ont le même indice, n'est-ce pas ? - if not all( - self.cur_semestre_id == s["semestre_id"] for s in self.sems_periode - ): - # debugging information - log( - f"""*** ApoData.set() error ! - ApoData( periode={self.periode}, annee_scolaire={self.annee_scolaire - }, cur_semestre_id={self.cur_semestre_id} ) - {len(self.sems_periode)} semestres dans la periode: - """ - ) - for s in self.sems_periode: - log(pprint.pformat(s)) - - raise ScoValueError( - f"""Incohérence détectée ! - - Les semestres de la période n'ont pas tous le même indice. - - Période: {self.periode}. Indice courant: {self.cur_semestre_id} - - (au besoin, contacter l'assistance sur {scu.SCO_DISCORD_ASSISTANCE}) - """ - ) - # Cette condition sera inadaptée si semestres décalés - # (mais ils n'ont pas d'étape annuelle, espérons!) - if self.cur_semestre_id >= 0: # non pertinent pour sessions sans semestres - self.jury_intermediaire = (self.cur_semestre_id % 2) != 0 - else: - self.sems_periode = None - - def read_csv(self, data: str): - if not data: - raise ScoFormatError("Fichier Apogée vide !") - f = StringIOFileLineWrapper(data) # pour traiter comme un fichier - # check that we are at the begining of Apogee CSV - line = f.readline().strip() - if line != "XX-APO_TITRES-XX": - raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX") - - # 1-- En-tête: du début jusqu'à la balise XX-APO_VALEURS-XX - try: - idx = data.index("XX-APO_VALEURS-XX") - except ValueError as exc: - raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") from exc - self.header = data[:idx] - - # 2-- 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 = _apo_read_TITRES(f) - - # 3-- La section XX-APO_TYP_RES-XX est ignorée: - line = f.readline().strip() - if line != "XX-APO_TYP_RES-XX": - raise ScoFormatError("format incorrect: pas de XX-APO_TYP_RES-XX") - _apo_skip_section(f) - - # 4-- Définition de colonnes: (on y trouve aussi l'étape) - line = f.readline().strip() - if line != "XX-APO_COLONNES-XX": - raise ScoFormatError("format incorrect: pas de XX-APO_COLONNES-XX") - self.cols = _apo_read_cols(f) - self.apo_elts = self._group_elt_cols(self.cols) - - # 5-- Section XX-APO_VALEURS-XX - # Lecture des étudiants et de leurs résultats - while True: # skip - line = f.readline() - if not line: - raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") - if line.strip() == "XX-APO_VALEURS-XX": - break - self.column_titles = f.readline() - self.col_ids = self.column_titles.strip().split() - self.etuds = self.apo_read_etuds(f) - self.etud_by_nip = {e["nip"]: e for e in self.etuds} - - def get_etud_by_nip(self, nip): - "returns ApoEtud with a given NIP code" - return self.etud_by_nip[nip] - - def _group_elt_cols(self, cols): - """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 = collections.OrderedDict() - 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_etuds(self, f) -> list[ApoEtud]: - """Lecture des etudiants (et resultats) du fichier CSV Apogée""" - L = [] - while True: - line = f.readline() - if not line: - break - if not line.strip(): - continue # silently ignore blank lines - line = line.strip(APO_NEWLINE) - fs = line.split(APO_SEP) - cols = {} # { col_id : value } - for i in range(len(fs)): - cols[self.col_ids[i]] = fs[i] - L.append( - ApoEtud( - nip=fs[0], # id etudiant - nom=fs[1], - prenom=fs[2], - naissance=fs[3], - cols=cols, - 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 L - - def get_etape_apogee(self): - """Le code etape: 'V1RT', donné par le code de l'élément VET""" - for elt in self.apo_elts.values(): - if elt.type_objet == "VET": - return elt.code - raise ScoValueError("Pas de code etape Apogee (manque élément VET)") - - def get_vdi_apogee(self): - """le VDI (version de diplôme), stocké dans l'élément VET - (note: on pourrait peut-être aussi bien le récupérer dans l'en-tête XX-APO_TITRES-XX apoC_cod_vdi) - """ - for elt in self.apo_elts.values(): - if elt.type_objet == "VET": - return elt.version - raise ScoValueError("Pas de VDI Apogee (manque élément VET)") - - def get_cod_dip_apogee(self): - """Le code diplôme, indiqué dans l'en-tête de la maquette - exemple: VDTRT - Retourne '' si absent. - """ - return self.titles.get("apoC_cod_dip", "") - - def get_annee_scolaire(self): - """Annee scolaire du fichier Apogee: un integer - = annee du mois de septembre de début - """ - m = re.match("[12][0-9]{3}", self.titles["apoC_annee"]) - if not m: - raise ScoFormatError( - 'Annee scolaire (apoC_annee) invalide: "%s"' % self.titles["apoC_annee"] - ) - return int(m.group(0)) - - def write_header(self, f): - """write apo CSV header on f - (beginning of CSV until columns titles just after XX-APO_VALEURS-XX line) - """ - f.write(self.header) - f.write(APO_NEWLINE) - f.write("XX-APO_VALEURS-XX" + APO_NEWLINE) - f.write(self.column_titles) - - def write_etuds(self, f): - """write apo CSV etuds on f""" - for e in self.etuds: - fs = [] # e['nip'], e['nom'], e['prenom'], e['naissance'] ] - for col_id in self.col_ids: - try: - fs.append(str(e.new_cols[col_id])) - except KeyError: - log( - "Error: %s %s missing column key %s" - % (e["nip"], e["nom"], col_id) - ) - log("Details:\ne = %s" % pprint.pformat(e)) - log("col_ids=%s" % pprint.pformat(self.col_ids)) - log("etudiant ignore.\n") - - f.write(APO_SEP.join(fs) + APO_NEWLINE) - - def list_unknown_elements(self): - """Liste des codes des elements Apogee non trouvés dans ScoDoc - (après traitement de tous les étudiants) - """ - s = set() - for e in self.etuds: - ul = [code for code in e.col_elts if e.col_elts[code] is None] - s.update(ul) - L = list(s) - L.sort() - return L - - def list_elements(self): - """Liste les codes des elements Apogée de la maquette - et ceux des semestres ScoDoc associés - Retourne deux ensembles - """ - try: - maq_elems = {self.cols[col_id]["Code"] for col_id in self.col_ids[4:]} - except KeyError: - # une colonne déclarée dans l'en-tête n'est pas présente - declared = self.col_ids[4:] # id des colones dans l'en-tête - present = sorted(self.cols.keys()) # colones presentes - log("Fichier Apogee invalide:") - log("Colonnes declarees: %s" % declared) - log("Colonnes presentes: %s" % present) - raise ScoFormatError( - """Fichier Apogee invalide
Colonnes declarees: %s -
Colonnes presentes: %s""" - % (declared, present) - ) - # l'ensemble de tous les codes des elements apo des semestres: - sem_elems = reduce(set.union, list(self.get_codes_by_sem().values()), set()) - - return maq_elems, sem_elems - - def get_codes_by_sem(self): - """Pour chaque semestre associé, donne l'ensemble des codes de cette maquette Apogée - qui s'y trouvent (dans le semestre, les UE ou les modules). - Return: { formsemestre_id : { 'code1', 'code2', ... }} - """ - codes_by_sem = {} - for sem in self.sems_etape: - formsemestre: FormSemestre = FormSemestre.query.get_or_404( - sem["formsemestre_id"] - ) - # L'ensemble des codes apo associés aux éléments: - codes_semestre = formsemestre.get_codes_apogee() - codes_modules = set().union( - *[ - modimpl.module.get_codes_apogee() - for modimpl in formsemestre.modimpls - ] - ) - codes_ues = set().union( - *[ - ue.get_codes_apogee() - for ue in formsemestre.query_ues(with_sport=True) - ] - ) - s = set() - codes_by_sem[sem["formsemestre_id"]] = s - for col_id in self.col_ids[4:]: - code = self.cols[col_id]["Code"] # 'V1RT' - # associé à l'étape, l'année ou le semestre: - if code in codes_semestre: - s.add(code) - continue - # associé à une UE: - if code in codes_ues: - s.add(code) - continue - # associé à un module: - if code in codes_modules: - s.add(code) - # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem)) - return codes_by_sem - - def build_cr_table(self): - """Table compte rendu des décisions""" - CR = [] # tableau compte rendu des decisions - for e in self.etuds: - cr = { - "NIP": e["nip"], - "nom": e["nom"], - "prenom": e["prenom"], - "est_NAR": e.is_NAR, - "commentaire": "; ".join(e.log), - } - if e.col_elts and e.col_elts[self.etape_apogee] != None: - cr["etape"] = e.col_elts[self.etape_apogee].get("R", "") - cr["etape_note"] = e.col_elts[self.etape_apogee].get("N", "") - else: - cr["etape"] = "" - cr["etape_note"] = "" - CR.append(cr) - - columns_ids = ["NIP", "nom", "prenom"] - columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire")) - - T = GenTable( - columns_ids=columns_ids, - titles=dict(zip(columns_ids, columns_ids)), - rows=CR, - xls_sheet_name="Decisions ScoDoc", - ) - return T - - -def _apo_read_cols(f): - """Lecture colonnes apo : - Démarre après la balise XX-APO_COLONNES-XX - et s'arrête après 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 = f.readline().strip(" " + APO_NEWLINE) - fs = line.split(APO_SEP) - if fs[0] != "apoL_a01_code": - raise ScoFormatError("invalid line: %s (expecting apoL_a01_code)" % line) - col_keys = fs - - while True: # skip premiere partie (apoL_a02_nom, ...) - line = f.readline().strip(" " + APO_NEWLINE) - if line == "APO_COL_VAL_DEB": - break - # après APO_COL_VAL_DEB - cols = {} - i = 0 - while True: - line = f.readline().strip(" " + APO_NEWLINE) - if line == "APO_COL_VAL_FIN": - break - i += 1 - fs = line.split(APO_SEP) - # print fs[0], len(fs) - # sanity check - col_id = fs[0] # apoL_c0001, ... - if col_id in cols: - raise ScoFormatError("duplicate column definition: %s" % col_id) - m = re.match(r"^apoL_c([0-9]{4})$", col_id) - if not m: - raise ScoFormatError( - "invalid column id: %s (expecting apoL_c%04d)" % (line, col_id) - ) - if int(m.group(1)) != i: - raise ScoFormatError("invalid column id: %s for index %s" % (col_id, i)) - - cols[col_id] = DictCol(list(zip(col_keys, fs))) - cols[col_id].lineno = f.lineno # for debuging purpose - - return cols - - -def _apo_read_TITRES(f): - "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("Error read CSV: \nline=%s\nfields=%s" % (line, fields)) - log(dir(f)) - raise ScoFormatError( - "Fichier Apogee incorrect (section titres, %d champs au lieu de 2)" - % len(fields) - ) - d[k] = v - # - if not d.get("apoC_Fichier_Exp", None): - raise ScoFormatError("Fichier Apogee incorrect: pas de titre apoC_Fichier_Exp") - # 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 _apo_skip_section(f): - "Saute section Apo: s'arrete apres ligne vide" - while True: - line = f.readline().strip() - if not line: - break - - -# ------------------------------------- - - -def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]: - """ - :param etape_apogee: etape (string or ApoEtapeVDI) - :param annee_scolaire: annee (int) - :return: list of sems for etape_apogee in annee_scolaire - """ - return sco_formsemestre.list_formsemestre_by_etape( - etape_apo=str(etape_apogee), annee_scolaire=annee_scolaire - ) - - -def nar_etuds_table(apo_data, NAR_Etuds): - """Liste les NAR -> excel table""" - code_etape = apo_data.etape_apogee - today = datetime.datetime.today().strftime("%d/%m/%y") - L = [] - NAR_Etuds.sort(key=lambda k: k["nom"]) - for e in NAR_Etuds: - L.append( - { - "nom": e["nom"], - "prenom": e["prenom"], - "c0": "", - "c1": "AD", - "etape": code_etape, - "c3": "", - "c4": "", - "c5": "", - "c6": "N", - "c7": "", - "c8": "", - "NIP": e["nip"], - "c10": "", - "c11": "", - "c12": "", - "c13": "NAR - Jury", - "date": today, - } - ) - - columns_ids = ( - "NIP", - "nom", - "prenom", - "etape", - "c0", - "c1", - "c3", - "c4", - "c5", - "c6", - "c7", - "c8", - "c10", - "c11", - "c12", - "c13", - "date", - ) - T = GenTable( - columns_ids=columns_ids, - titles=dict(zip(columns_ids, columns_ids)), - rows=L, - xls_sheet_name="NAR ScoDoc", - ) - return T.excel() - - -def export_csv_to_apogee( - apo_csv_data: str, - periode=None, - dest_zip=None, - export_res_etape=True, - export_res_sem=True, - export_res_ues=True, - export_res_modules=True, - export_res_sdj=True, - export_res_rat=True, -): - """Genere un fichier CSV Apogée - à partir d'un fichier CSV Apogée vide (ou partiellement rempli) - et des résultats ScoDoc. - Si dest_zip, ajoute les fichiers générés à ce zip - sinon crée un zip et le publie - """ - apo_data = ApoData( - apo_csv_data, - periode=periode, - export_res_etape=export_res_etape, - export_res_sem=export_res_sem, - export_res_ues=export_res_ues, - export_res_modules=export_res_modules, - export_res_sdj=export_res_sdj, - export_res_rat=export_res_rat, - ) - apo_data.setup() # -> .sems_etape - - for e in apo_data.etuds: - e.is_apc = apo_data.is_apc - e.lookup_scodoc(apo_data.etape_formsemestre_ids) - e.associate_sco(apo_data) - - # Ré-écrit le fichier Apogée - f = io.StringIO() - apo_data.write_header(f) - apo_data.write_etuds(f) - - # Table des NAR: - NAR_Etuds = [e for e in apo_data.etuds if e.is_NAR] - if NAR_Etuds: - nar_xls = nar_etuds_table(apo_data, NAR_Etuds) - else: - nar_xls = None - - # Journaux & Comptes-rendus - # Orphelins: etudiants dans fichier Apogée mais pas dans ScoDoc - Apo_Non_ScoDoc = [e for e in apo_data.etuds if e.etat == ETUD_ORPHELIN] - # Non inscrits: connus de ScoDoc mais pas inscrit dans l'étape cette année - Apo_Non_ScoDoc_Inscrits = [e for e in apo_data.etuds if e.etat == ETUD_NON_INSCRIT] - # CR table - cr_table = apo_data.build_cr_table() - cr_xls = cr_table.excel() - - # Create ZIP - if not dest_zip: - data = io.BytesIO() - dest_zip = ZipFile(data, "w") - my_zip = True - else: - my_zip = False - # Ensure unique filenames - filename = apo_data.titles["apoC_Fichier_Exp"] - basename, ext = os.path.splitext(filename) - csv_filename = filename - - if csv_filename in dest_zip.namelist(): - basename = filename + "-" + apo_data.vdi_apogee - csv_filename = basename + ext - nf = 1 - tmplname = basename - while csv_filename in dest_zip.namelist(): - basename = tmplname + "-%d" % nf - csv_filename = basename + ext - nf += 1 - - log_filename = "scodoc-" + basename + ".log.txt" - nar_filename = basename + "-nar" + scu.XLSX_SUFFIX - cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX - - logf = io.StringIO() - logf.write("export_to_apogee du %s\n\n" % time.ctime()) - logf.write("Semestres ScoDoc sources:\n") - for sem in apo_data.sems_etape: - logf.write("\t%(titremois)s\n" % sem) - logf.write("Periode: %s\n" % periode) - logf.write("export_res_etape: %s\n" % int(export_res_etape)) - logf.write("export_res_sem: %s\n" % int(export_res_sem)) - logf.write("export_res_ues: %s\n" % int(export_res_ues)) - logf.write("export_res_modules: %s\n" % int(export_res_modules)) - logf.write("export_res_sdj: %s\n" % int(export_res_sdj)) - logf.write( - "\nEtudiants Apogee non trouves dans ScoDoc:\n" - + "\n".join( - ["%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"]) for e in Apo_Non_ScoDoc] - ) - ) - logf.write( - "\nEtudiants Apogee non inscrits sur ScoDoc dans cette étape:\n" - + "\n".join( - [ - "%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"]) - for e in Apo_Non_ScoDoc_Inscrits - ] - ) - ) - - logf.write( - "\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n" - + "\n".join(apo_data.list_unknown_elements()) - ) - log(logf.getvalue()) # sortie aussi sur le log ScoDoc - - csv_data = f.getvalue().encode(APO_OUTPUT_ENCODING) - - # Write data to ZIP - dest_zip.writestr(csv_filename, csv_data) - dest_zip.writestr(log_filename, logf.getvalue()) - if nar_xls: - dest_zip.writestr(nar_filename, nar_xls) - dest_zip.writestr(cr_filename, cr_xls) - - if my_zip: - dest_zip.close() - data.seek(0) - return send_file( - data, - mimetype="application/zip", - download_name=scu.sanitize_filename(basename + "-scodoc.zip"), - as_attachment=True, - ) - else: - return None # zip modified in place +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Exportation des résultats des étudiants vers Apogée. + +Ce code a été au départ inspiré par les travaux de Damien Mascré, scodoc2apogee (en Java). + +A utiliser en fin de semestre, après les jury. + +On communique avec Apogée via des fichiers CSV. + +Le fichier CSV, champs séparés par des tabulations, a la structure suivante: + +
+ 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_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
+ 
+ + + 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 +
+ apoL_c0001	VET	V1CJ ...
+ 
+ + +XXX A vérifier: + AJAC car 1 sem. validé et pas de NAR + +""" + +import collections +import datetime +from functools import reduce +import functools +import io +import os +import pprint +import re +import time +from zipfile import ZipFile + +from flask import send_file +import numpy as np + +# 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.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre, Identite +from app.models.config import ScoDocSiteConfig +import app.scodoc.sco_utils as scu +from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_vdi import ApoEtapeVDI +from app.scodoc.sco_codes_parcours import code_semestre_validant +from app.scodoc.sco_codes_parcours import ( + DEF, + DEM, + NAR, + RAT, +) +from app.scodoc import sco_cursus +from app.scodoc import sco_formsemestre +from app.scodoc import sco_etud + +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" + + +def _apo_fmt_note(note, fmt="%3.2f"): + "Formatte une note pour Apogée (séparateur décimal: ',')" + # if not note and isinstance(note, float): changé le 31/1/2022, étrange ? + # return "" + try: + val = float(note) + except ValueError: + return "" + if np.isnan(val): + return "" + return (fmt % val).replace(".", APO_DECIMAL_SEP) + + +def guess_data_encoding(text, 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, +) -> bytes: + """Try to ensure that text is using dest_encoding + returns converted text, and a message describing the conversion. + """ + message = "" + detected_encoding = guess_data_encoding(text) + if not detected_encoding: + if default_source_encoding != dest_encoding: + message = "converting from %s to %s" % ( + default_source_encoding, + dest_encoding, + ) + text = text.decode(default_source_encoding).encode( + dest_encoding + ) # XXX #py3 #sco8 à tester + else: + if detected_encoding != dest_encoding: + message = "converting from detected %s to %s" % ( + detected_encoding, + dest_encoding, + ) + text = text.decode(detected_encoding).encode(dest_encoding) # XXX + return text, message + + +class StringIOFileLineWrapper(object): + def __init__(self, data: str): + self.f = io.StringIO(data) + self.lineno = 0 + + def close(self): + return self.f.close() + + def readline(self): + self.lineno += 1 + return self.f.readline() + + +class DictCol(dict): + "A dict, where we can add attributes" + pass + + +class ApoElt(object): + """Definition d'un Element Apogee + 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): + assert col["Code"] == self.code + if col["Type Objet"] != self.type_objet: + log( + "Warning: ApoElt: duplicate id %s (%s and %s)" + % (self.code, self.type_objet, col["Type Objet"]) + ) + self.type_objet = col["Type Objet"] + self.cols.append(col) + + def __repr__(self): + return "ApoElt(code='%s', cols=%s)" % (self.code, pprint.pformat(self.cols)) + + +class EtuCol(object): + """Valeurs colonnes d'un element pour un etudiant""" + + def __init__(self, nip, apo_elt, init_vals): + pass # XXX + + +ETUD_OK = "ok" +ETUD_ORPHELIN = "orphelin" +ETUD_NON_INSCRIT = "non_inscrit" + +VOID_APO_RES = dict(N="", B="", J="", R="", M="") + + +class ApoEtud(dict): + """Étudiant Apogee:""" + + def __init__( + self, + nip="", + nom="", + prenom="", + naissance="", + cols={}, + export_res_etape=True, + export_res_sem=True, + export_res_ues=True, + export_res_modules=True, + export_res_sdj=True, + export_res_rat=True, + ): + self["nip"] = nip + self["nom"] = nom + self["prenom"] = prenom + self["naissance"] = naissance + self.cols = cols + "{ col_id : value } colid = 'apoL_c0001'" + self.is_apc = None + "Vrai si BUT" + self.col_elts = {} + "{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}" + self.new_cols = {} # { col_id : value to record in csv } + self.etud: Identite = None + "etudiant ScoDoc associé" + self.etat = None # ETUD_OK, ... + self.is_NAR = False + "True si NARé dans un semestre" + self.log = [] + self.has_logged_no_decision = False + self.export_res_etape = export_res_etape # VET, ... + self.export_res_sem = export_res_sem # elt_sem_apo + self.export_res_ues = export_res_ues + self.export_res_modules = export_res_modules + self.export_res_sdj = export_res_sdj # export meme si pas de decision de jury + self.export_res_rat = export_res_rat + self.fmt_note = functools.partial( + _apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f" + ) + + def __repr__(self): + return f"""ApoEtud( nom='{self["nom"]}', nip='{self["nip"]}' )""" + + def lookup_scodoc(self, etape_formsemestre_ids): + """Cherche l'étudiant ScoDoc associé à cet étudiant Apogée. + S'il n'est pas trouvé (état "orphelin", dans Apo mais pas chez nous), + met .etud à None. + Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT. + """ + + # futur: #WIP + # etud: Identite = Identite.query.filter_by(code_nip=self["nip"]).first() + # self.etud = etud + etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True) + if not etuds: + # pas dans ScoDoc + self.etud = None + self.log.append("non inscrit dans ScoDoc") + self.etat = ETUD_ORPHELIN + else: + # futur: #WIP + # formsemestre_ids = { + # ins.formsemestre_id for ins in etud.formsemestre_inscriptions + # } + # in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) + self.etud = etuds[0] + # cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape: + formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]} + in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) + if not in_formsemestre_ids: + self.log.append( + "connu dans ScoDoc, mais pas inscrit dans un semestre de cette étape" + ) + self.etat = ETUD_NON_INSCRIT + else: + self.etat = ETUD_OK + + def associate_sco(self, apo_data: "ApoData"): + """Recherche les valeurs des éléments Apogée pour cet étudiant + Set .new_cols + """ + self.col_elts = {} # {'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}} + if self.etat is None: + self.lookup_scodoc(apo_data.etape_formsemestre_ids) + if self.etat != ETUD_OK: + self.new_cols = ( + self.cols + ) # etudiant inconnu, recopie les valeurs existantes dans Apo + else: + sco_elts = {} # valeurs trouvées dans ScoDoc code : { N, B, J, R } + for col_id in apo_data.col_ids[4:]: + code = apo_data.cols[col_id]["Code"] # 'V1RT' + el = sco_elts.get( + code, None + ) # {'R': ADM, 'J': '', 'B': 20, 'N': '12.14'} + if el is None: # pas déjà trouvé + cur_sem, autre_sem = self.etud_semestres_de_etape(apo_data) + for sem in apo_data.sems_etape: + el = self.search_elt_in_sem(code, sem, cur_sem, autre_sem) + if el is not None: + sco_elts[code] = el + break + self.col_elts[code] = el + if el is None: + self.new_cols[col_id] = self.cols[col_id] + else: + try: + self.new_cols[col_id] = sco_elts[code][ + apo_data.cols[col_id]["Type Rés."] + ] + except KeyError as exc: + log( + f"associate_sco: missing key, etud={self}\ncode='{code}'\netape='{apo_data.etape_apogee}'" + ) + raise ScoValueError( + f"""L'élément {code} n'a pas de résultat: peut-être une erreur + dans les codes sur le programme pédagogique + (vérifier qu'il est bien associé à une UE ou semestre)?""" + ) from exc + # recopie les 4 premieres colonnes (nom, ..., naissance): + for col_id in apo_data.col_ids[:4]: + self.new_cols[col_id] = self.cols[col_id] + + # def unassociated_codes(self, apo_data): + # "list of apo elements for this student without a value in ScoDoc" + # codes = set([apo_data.cols[col_id].code for col_id in apo_data.col_ids]) + # return codes - set(sco_elts) + + def search_elt_in_sem(self, code, sem, cur_sem, autre_sem) -> dict: + """ + VET code jury etape + ELP élément pédagogique: UE, module + Autres éléments: résultats du semestre ou de l'année scolaire: + => VRTW1: code additionnel au semestre ("code élement semestre", elt_sem_apo) + => VRT1A: le même que le VET: ("code élement annuel", elt_annee_apo) + Attention, si le semestre couvre plusieurs étapes, indiquer les codes des éléments, + séparés par des virgules. + + Args: + code (str): code apo de l'element cherché + sem (dict): semestre dans lequel on cherche l'élément + cur_sem (dict): semestre "courant" pour résultats annuels (VET) + autre_sem (dict): autre semestre utilisé pour calculé les résultats annuels (VET) + + Returns: + dict: with N, B, J, R keys, ou None si elt non trouvé + """ + etudid = self.etud["etudid"] + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + if etudid not in nt.identdict: + return None # etudiant non inscrit dans ce semestre + + decision = nt.get_etud_decision_sem(etudid) + if not self.export_res_sdj and not decision: + # pas de decision de jury, on n'enregistre rien + # (meme si démissionnaire) + if not self.has_logged_no_decision: + self.log.append("Pas de decision") + self.has_logged_no_decision = True + return VOID_APO_RES + + if decision and decision["code"] == NAR: + self.is_NAR = True + + # Element etape (annuel ou non): + if sco_formsemestre.sem_has_etape(sem, code) or ( + code in {x.strip() for x in sem["elt_annee_apo"].split(",")} + ): + export_res_etape = self.export_res_etape + if (not export_res_etape) and cur_sem: + # exporte toujours le résultat de l'étape si l'étudiant est diplômé + Se = sco_cursus.get_situation_etud_cursus( + self.etud, cur_sem["formsemestre_id"] + ) + export_res_etape = Se.all_other_validated() + + if export_res_etape: + return self.comp_elt_annuel(etudid, cur_sem, autre_sem) + else: + return VOID_APO_RES + + # Element semestre: + if code in {x.strip() for x in sem["elt_sem_apo"].split(",")}: + if self.export_res_sem: + return self.comp_elt_semestre(nt, decision, etudid) + else: + return VOID_APO_RES + + # Elements UE + decisions_ue = nt.get_etud_decision_ues(etudid) + for ue in nt.get_ues_stat_dict(): + if ue["code_apogee"] and code in { + x.strip() for x in ue["code_apogee"].split(",") + }: + if self.export_res_ues: + if decisions_ue and ue["ue_id"] in decisions_ue: + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + code_decision_ue = decisions_ue[ue["ue_id"]]["code"] + return dict( + N=self.fmt_note(ue_status["moy"] if ue_status else ""), + B=20, + J="", + R=ScoDocSiteConfig.get_code_apo(code_decision_ue), + M="", + ) + else: + return VOID_APO_RES + else: + return VOID_APO_RES + + # Elements Modules + modimpls = nt.get_modimpls_dict() + module_code_found = False + for modimpl in modimpls: + module = modimpl["module"] + if module["code_apogee"] and code in { + x.strip() for x in module["code_apogee"].split(",") + }: + n = nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) + if n != "NI" and self.export_res_modules: + return dict(N=self.fmt_note(n), B=20, J="", R="") + else: + module_code_found = True + if module_code_found: + return VOID_APO_RES + # + return None # element Apogee non trouvé dans ce semestre + + def comp_elt_semestre(self, nt, decision, etudid): + """Calcul résultat apo semestre""" + if self.is_apc: + # pas de code semestre en APC ! + return dict(N="", B=20, J="", R="", M="") + if decision is None: + etud = Identite.query.get(etudid) + nomprenom = etud.nomprenom if etud else "(inconnu)" + raise ScoValueError( + f"decision absente pour l'étudiant {nomprenom} ({etudid})" + ) + # resultat du semestre + decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) + note = nt.get_etud_moy_gen(etudid) + if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF: + note_str = "0,01" # note non nulle pour les démissionnaires + else: + note_str = self.fmt_note(note) + return dict(N=note_str, B=20, J="", R=decision_apo, M="") + + def comp_elt_annuel(self, etudid, cur_sem, autre_sem): + """Calcul resultat annuel (VET) à partir du semestre courant + et de l'autre (le suivant ou le précédent complétant l'année scolaire) + """ + # Code annuel: + # - Note: moyenne des moyennes générales des deux semestres (pas vraiment de sens, mais faute de mieux) + # on pourrait aussi bien prendre seulement la note du dernier semestre (S2 ou S4). Paramétrable ? + # - Résultat jury: + # si l'autre est validé, code du semestre courant (ex: S1 (ADM), S2 (AJ) => année AJ) + # si l'autre n'est pas validé ou est DEF ou DEM, code de l'autre + # + # XXX cette règle est discutable, à valider + + # print 'comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']) + if not cur_sem: + # l'étudiant n'a pas de semestre courant ?! + log("comp_elt_annuel: etudid %s has no cur_sem" % etudid) + return VOID_APO_RES + cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"]) + cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre) + cur_decision = cur_nt.get_etud_decision_sem(etudid) + if not cur_decision: + # pas de decision => pas de résultat annuel + return VOID_APO_RES + + if (cur_decision["code"] == RAT) and not self.export_res_rat: + # ne touche pas aux RATs + return VOID_APO_RES + + if not autre_sem: + # formations monosemestre, ou code VET semestriel, + # ou jury intermediaire et etudiant non redoublant... + return self.comp_elt_semestre(cur_nt, cur_decision, etudid) + + decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"]) + + autre_formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"]) + autre_nt: NotesTableCompat = res_sem.load_formsemestre_results( + autre_formsemestre + ) + autre_decision = autre_nt.get_etud_decision_sem(etudid) + if not autre_decision: + # pas de decision dans l'autre => pas de résultat annuel + return VOID_APO_RES + autre_decision_apo = ScoDocSiteConfig.get_code_apo(autre_decision["code"]) + if ( + autre_decision_apo == "DEF" + or autre_decision["code"] == DEM + or autre_decision["code"] == DEF + ) or ( + decision_apo == "DEF" + or cur_decision["code"] == DEM + or cur_decision["code"] == DEF + ): + note_str = "0,01" # note non nulle pour les démissionnaires + else: + note = cur_nt.get_etud_moy_gen(etudid) + autre_note = autre_nt.get_etud_moy_gen(etudid) + # print 'note=%s autre_note=%s' % (note, autre_note) + try: + moy_annuelle = (note + autre_note) / 2 + except TypeError: + moy_annuelle = "" + note_str = self.fmt_note(moy_annuelle) + + if code_semestre_validant(autre_decision["code"]): + decision_apo_annuelle = decision_apo + else: + decision_apo_annuelle = autre_decision_apo + + return dict(N=note_str, B=20, J="", R=decision_apo_annuelle, M="") + + def etud_semestres_de_etape(self, apo_data): + """ + Lorsqu'on a une formation semestrialisée mais avec un code étape annuel, + il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer + le code annuel (VET ou VRT1A (voir elt_annee_apo)). + + Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même + étape lors d'une année précédente ? + + Renvoie le semestre "courant" et l'autre semestre, ou None s'il n'y en a pas. + """ + # Cherche le semestre "courant": + cur_sems = [ + sem + for sem in self.etud["sems"] + if ( + (sem["semestre_id"] == apo_data.cur_semestre_id) + and (apo_data.etape in sem["etapes"]) + and ( + sco_formsemestre.sem_in_annee_scolaire(sem, apo_data.annee_scolaire) + ) + ) + ] + if not cur_sems: + cur_sem = None + else: + # prend le plus recent avec decision + cur_sem = None + for sem in cur_sems: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + decision = nt.get_etud_decision_sem(self.etud["etudid"]) + if decision: + cur_sem = sem + break + if cur_sem is None: + cur_sem = cur_sems[0] # aucun avec decison, prend le plus recent + + if apo_data.cur_semestre_id <= 0: + return ( + cur_sem, + None, + ) # "autre_sem" non pertinent pour sessions sans semestres + + if apo_data.jury_intermediaire: # jury de janvier + # Le semestre suivant: exemple 2 si on est en jury de S1 + autre_semestre_id = apo_data.cur_semestre_id + 1 + else: + # Le précédent (S1 si on est en S2) + autre_semestre_id = apo_data.cur_semestre_id - 1 + + # L'autre semestre DOIT être antérieur au courant indiqué par apo_data + if apo_data.periode is not None: + if apo_data.periode == 1: + courant_annee_debut = apo_data.annee_scolaire + courant_mois_debut = 9 # periode = 1 (sept-jan) + elif apo_data.periode == 2: + courant_annee_debut = apo_data.annee_scolaire + 1 + courant_mois_debut = 1 # ou 2 (fev-jul) + else: + raise ValueError("invalid pediode value !") # bug ? + courant_date_debut = "%d-%02d-01" % ( + courant_annee_debut, + courant_mois_debut, + ) + else: + courant_date_debut = "9999-99-99" + + # etud['sems'] est la liste des semestres de l'étudiant, triés par date, + # le plus récemment effectué en tête. + # Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée + # s'il y en a plusieurs, choisit le plus récent ayant une décision + + autres_sems = [] + for sem in self.etud["sems"]: + if ( + sem["semestre_id"] == autre_semestre_id + and apo_data.etape_apogee in sem["etapes"] + ): + if ( + sem["date_debut_iso"] < courant_date_debut + ): # on demande juste qu'il ait démarré avant + autres_sems.append(sem) + if not autres_sems: + autre_sem = None + elif len(autres_sems) == 1: + autre_sem = autres_sems[0] + else: + autre_sem = None + for sem in autres_sems: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + decision = nt.get_etud_decision_sem(self.etud["etudid"]) + if decision: + autre_sem = sem + break + if autre_sem is None: + autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent + + return cur_sem, autre_sem + + +class ApoData(object): + def __init__( + self, + data: str, + periode=None, + export_res_etape=True, + export_res_sem=True, + export_res_ues=True, + export_res_modules=True, + export_res_sdj=True, + export_res_rat=True, + orig_filename=None, + ): + """Lecture du fichier CSV Apogée + Regroupe les élements importants d'un fichier CSV Apogée + periode = 1 (sept-jan) ou 2 (fev-jul), mais cette info n'est pas + (toujours) présente dans les CSV Apogée et doit être indiquée par l'utilisateur + Laisser periode à None si etape en 1 semestre (LP, décalés, ...) + """ + self.export_res_etape = export_res_etape # VET, ... + self.export_res_sem = export_res_sem # elt_sem_apo + self.export_res_ues = export_res_ues + self.export_res_modules = export_res_modules + self.export_res_sdj = export_res_sdj + self.export_res_rat = export_res_rat + self.orig_filename = orig_filename + self.periode = periode # + self.is_apc = None + "Vrai si BUT" + try: + self.read_csv(data) + except ScoFormatError as e: + # essaie de retrouver le nom du fichier pour enrichir le message d'erreur + filename = "" + if self.orig_filename is None: + if hasattr(self, "titles"): + filename = self.titles.get("apoC_Fichier_Exp", filename) + else: + filename = self.orig_filename + raise ScoFormatError( + "

Erreur lecture du fichier Apogée %s

" % filename + + e.args[0] + + "

" + ) from e + self.etape_apogee = self.get_etape_apogee() # 'V1RT' + self.vdi_apogee = self.get_vdi_apogee() # '111' + self.etape = ApoEtapeVDI(etape=self.etape_apogee, vdi=self.vdi_apogee) + self.cod_dip_apogee = self.get_cod_dip_apogee() + self.annee_scolaire = self.get_annee_scolaire() + self.jury_intermediaire = ( + False # True si jury à mi-étape, eg jury de S1 dans l'étape (S1, S2) + ) + + log( + "ApoData( periode=%s, annee_scolaire=%s )" + % (self.periode, self.annee_scolaire) + ) + + def set_periode(self, periode): # currently unused + self.periode = periode + + def setup(self): + """Recherche semestres ScoDoc concernés""" + self.sems_etape = comp_apo_sems(self.etape_apogee, self.annee_scolaire) + self.formsemestres_etape = [ + FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in self.sems_etape + ] + apcs = { + formsemestre.formation.is_apc() for formsemestre in self.formsemestres_etape + } + if len(apcs) != 1: + raise ScoValueError( + "l'ensemble mixe des semestres BUT (APC) et des semestres classiques !" + ) + self.is_apc = apcs.pop() + self.etape_formsemestre_ids = {s["formsemestre_id"] for s in self.sems_etape} + if self.periode is not None: + self.sems_periode = [ + s + for s in self.sems_etape + if (s["periode"] == self.periode) or s["semestre_id"] < 0 + ] + if not self.sems_periode: + log("** Warning: ApoData.setup: sems_periode is empty") + log( + "** (periode=%s, sems_etape [periode]=%s)" + % (self.periode, [s["periode"] for s in self.sems_etape]) + ) + self.sems_periode = None + self.cur_semestre_id = -1 # ? + else: + self.cur_semestre_id = self.sems_periode[0]["semestre_id"] + # Les semestres de la période ont le même indice, n'est-ce pas ? + if not all( + self.cur_semestre_id == s["semestre_id"] for s in self.sems_periode + ): + # debugging information + log( + f"""*** ApoData.set() error ! + ApoData( periode={self.periode}, annee_scolaire={self.annee_scolaire + }, cur_semestre_id={self.cur_semestre_id} ) + {len(self.sems_periode)} semestres dans la periode: + """ + ) + for s in self.sems_periode: + log(pprint.pformat(s)) + + raise ScoValueError( + f"""Incohérence détectée ! + + Les semestres de la période n'ont pas tous le même indice. + + Période: {self.periode}. Indice courant: {self.cur_semestre_id} + + (au besoin, contacter l'assistance sur {scu.SCO_DISCORD_ASSISTANCE}) + """ + ) + # Cette condition sera inadaptée si semestres décalés + # (mais ils n'ont pas d'étape annuelle, espérons!) + if self.cur_semestre_id >= 0: # non pertinent pour sessions sans semestres + self.jury_intermediaire = (self.cur_semestre_id % 2) != 0 + else: + self.sems_periode = None + + def read_csv(self, data: str): + if not data: + raise ScoFormatError("Fichier Apogée vide !") + f = StringIOFileLineWrapper(data) # pour traiter comme un fichier + # check that we are at the begining of Apogee CSV + line = f.readline().strip() + if line != "XX-APO_TITRES-XX": + raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX") + + # 1-- En-tête: du début jusqu'à la balise XX-APO_VALEURS-XX + try: + idx = data.index("XX-APO_VALEURS-XX") + except ValueError as exc: + raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") from exc + self.header = data[:idx] + + # 2-- 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 = _apo_read_TITRES(f) + + # 3-- La section XX-APO_TYP_RES-XX est ignorée: + line = f.readline().strip() + if line != "XX-APO_TYP_RES-XX": + raise ScoFormatError("format incorrect: pas de XX-APO_TYP_RES-XX") + _apo_skip_section(f) + + # 4-- Définition de colonnes: (on y trouve aussi l'étape) + line = f.readline().strip() + if line != "XX-APO_COLONNES-XX": + raise ScoFormatError("format incorrect: pas de XX-APO_COLONNES-XX") + self.cols = _apo_read_cols(f) + self.apo_elts = self._group_elt_cols(self.cols) + + # 5-- Section XX-APO_VALEURS-XX + # Lecture des étudiants et de leurs résultats + while True: # skip + line = f.readline() + if not line: + raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") + if line.strip() == "XX-APO_VALEURS-XX": + break + self.column_titles = f.readline() + self.col_ids = self.column_titles.strip().split() + self.etuds = self.apo_read_etuds(f) + self.etud_by_nip = {e["nip"]: e for e in self.etuds} + + def get_etud_by_nip(self, nip): + "returns ApoEtud with a given NIP code" + return self.etud_by_nip[nip] + + def _group_elt_cols(self, cols): + """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 = collections.OrderedDict() + 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_etuds(self, f) -> list[ApoEtud]: + """Lecture des etudiants (et resultats) du fichier CSV Apogée""" + L = [] + while True: + line = f.readline() + if not line: + break + if not line.strip(): + continue # silently ignore blank lines + line = line.strip(APO_NEWLINE) + fs = line.split(APO_SEP) + cols = {} # { col_id : value } + for i in range(len(fs)): + cols[self.col_ids[i]] = fs[i] + L.append( + ApoEtud( + nip=fs[0], # id etudiant + nom=fs[1], + prenom=fs[2], + naissance=fs[3], + cols=cols, + 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 L + + def get_etape_apogee(self): + """Le code etape: 'V1RT', donné par le code de l'élément VET""" + for elt in self.apo_elts.values(): + if elt.type_objet == "VET": + return elt.code + raise ScoValueError("Pas de code etape Apogee (manque élément VET)") + + def get_vdi_apogee(self): + """le VDI (version de diplôme), stocké dans l'élément VET + (note: on pourrait peut-être aussi bien le récupérer dans l'en-tête XX-APO_TITRES-XX apoC_cod_vdi) + """ + for elt in self.apo_elts.values(): + if elt.type_objet == "VET": + return elt.version + raise ScoValueError("Pas de VDI Apogee (manque élément VET)") + + def get_cod_dip_apogee(self): + """Le code diplôme, indiqué dans l'en-tête de la maquette + exemple: VDTRT + Retourne '' si absent. + """ + return self.titles.get("apoC_cod_dip", "") + + def get_annee_scolaire(self): + """Annee scolaire du fichier Apogee: un integer + = annee du mois de septembre de début + """ + m = re.match("[12][0-9]{3}", self.titles["apoC_annee"]) + if not m: + raise ScoFormatError( + 'Annee scolaire (apoC_annee) invalide: "%s"' % self.titles["apoC_annee"] + ) + return int(m.group(0)) + + def write_header(self, f): + """write apo CSV header on f + (beginning of CSV until columns titles just after XX-APO_VALEURS-XX line) + """ + f.write(self.header) + f.write(APO_NEWLINE) + f.write("XX-APO_VALEURS-XX" + APO_NEWLINE) + f.write(self.column_titles) + + def write_etuds(self, f): + """write apo CSV etuds on f""" + for e in self.etuds: + fs = [] # e['nip'], e['nom'], e['prenom'], e['naissance'] ] + for col_id in self.col_ids: + try: + fs.append(str(e.new_cols[col_id])) + except KeyError: + log( + "Error: %s %s missing column key %s" + % (e["nip"], e["nom"], col_id) + ) + log("Details:\ne = %s" % pprint.pformat(e)) + log("col_ids=%s" % pprint.pformat(self.col_ids)) + log("etudiant ignore.\n") + + f.write(APO_SEP.join(fs) + APO_NEWLINE) + + def list_unknown_elements(self): + """Liste des codes des elements Apogee non trouvés dans ScoDoc + (après traitement de tous les étudiants) + """ + s = set() + for e in self.etuds: + ul = [code for code in e.col_elts if e.col_elts[code] is None] + s.update(ul) + L = list(s) + L.sort() + return L + + def list_elements(self): + """Liste les codes des elements Apogée de la maquette + et ceux des semestres ScoDoc associés + Retourne deux ensembles + """ + try: + maq_elems = {self.cols[col_id]["Code"] for col_id in self.col_ids[4:]} + except KeyError: + # une colonne déclarée dans l'en-tête n'est pas présente + declared = self.col_ids[4:] # id des colones dans l'en-tête + present = sorted(self.cols.keys()) # colones presentes + log("Fichier Apogee invalide:") + log("Colonnes declarees: %s" % declared) + log("Colonnes presentes: %s" % present) + raise ScoFormatError( + """Fichier Apogee invalide
Colonnes declarees: %s +
Colonnes presentes: %s""" + % (declared, present) + ) + # l'ensemble de tous les codes des elements apo des semestres: + sem_elems = reduce(set.union, list(self.get_codes_by_sem().values()), set()) + + return maq_elems, sem_elems + + def get_codes_by_sem(self): + """Pour chaque semestre associé, donne l'ensemble des codes de cette maquette Apogée + qui s'y trouvent (dans le semestre, les UE ou les modules). + Return: { formsemestre_id : { 'code1', 'code2', ... }} + """ + codes_by_sem = {} + for sem in self.sems_etape: + formsemestre: FormSemestre = FormSemestre.query.get_or_404( + sem["formsemestre_id"] + ) + # L'ensemble des codes apo associés aux éléments: + codes_semestre = formsemestre.get_codes_apogee() + codes_modules = set().union( + *[ + modimpl.module.get_codes_apogee() + for modimpl in formsemestre.modimpls + ] + ) + codes_ues = set().union( + *[ + ue.get_codes_apogee() + for ue in formsemestre.query_ues(with_sport=True) + ] + ) + s = set() + codes_by_sem[sem["formsemestre_id"]] = s + for col_id in self.col_ids[4:]: + code = self.cols[col_id]["Code"] # 'V1RT' + # associé à l'étape, l'année ou le semestre: + if code in codes_semestre: + s.add(code) + continue + # associé à une UE: + if code in codes_ues: + s.add(code) + continue + # associé à un module: + if code in codes_modules: + s.add(code) + # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem)) + return codes_by_sem + + def build_cr_table(self): + """Table compte rendu des décisions""" + CR = [] # tableau compte rendu des decisions + for e in self.etuds: + cr = { + "NIP": e["nip"], + "nom": e["nom"], + "prenom": e["prenom"], + "est_NAR": e.is_NAR, + "commentaire": "; ".join(e.log), + } + if e.col_elts and e.col_elts[self.etape_apogee] != None: + cr["etape"] = e.col_elts[self.etape_apogee].get("R", "") + cr["etape_note"] = e.col_elts[self.etape_apogee].get("N", "") + else: + cr["etape"] = "" + cr["etape_note"] = "" + CR.append(cr) + + columns_ids = ["NIP", "nom", "prenom"] + columns_ids.extend(("etape", "etape_note", "est_NAR", "commentaire")) + + T = GenTable( + columns_ids=columns_ids, + titles=dict(zip(columns_ids, columns_ids)), + rows=CR, + xls_sheet_name="Decisions ScoDoc", + ) + return T + + +def _apo_read_cols(f): + """Lecture colonnes apo : + Démarre après la balise XX-APO_COLONNES-XX + et s'arrête après 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 = f.readline().strip(" " + APO_NEWLINE) + fs = line.split(APO_SEP) + if fs[0] != "apoL_a01_code": + raise ScoFormatError("invalid line: %s (expecting apoL_a01_code)" % line) + col_keys = fs + + while True: # skip premiere partie (apoL_a02_nom, ...) + line = f.readline().strip(" " + APO_NEWLINE) + if line == "APO_COL_VAL_DEB": + break + # après APO_COL_VAL_DEB + cols = {} + i = 0 + while True: + line = f.readline().strip(" " + APO_NEWLINE) + if line == "APO_COL_VAL_FIN": + break + i += 1 + fs = line.split(APO_SEP) + # print fs[0], len(fs) + # sanity check + col_id = fs[0] # apoL_c0001, ... + if col_id in cols: + raise ScoFormatError("duplicate column definition: %s" % col_id) + m = re.match(r"^apoL_c([0-9]{4})$", col_id) + if not m: + raise ScoFormatError( + "invalid column id: %s (expecting apoL_c%04d)" % (line, col_id) + ) + if int(m.group(1)) != i: + raise ScoFormatError("invalid column id: %s for index %s" % (col_id, i)) + + cols[col_id] = DictCol(list(zip(col_keys, fs))) + cols[col_id].lineno = f.lineno # for debuging purpose + + return cols + + +def _apo_read_TITRES(f): + "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("Error read CSV: \nline=%s\nfields=%s" % (line, fields)) + log(dir(f)) + raise ScoFormatError( + "Fichier Apogee incorrect (section titres, %d champs au lieu de 2)" + % len(fields) + ) + d[k] = v + # + if not d.get("apoC_Fichier_Exp", None): + raise ScoFormatError("Fichier Apogee incorrect: pas de titre apoC_Fichier_Exp") + # 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 _apo_skip_section(f): + "Saute section Apo: s'arrete apres ligne vide" + while True: + line = f.readline().strip() + if not line: + break + + +# ------------------------------------- + + +def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]: + """ + :param etape_apogee: etape (string or ApoEtapeVDI) + :param annee_scolaire: annee (int) + :return: list of sems for etape_apogee in annee_scolaire + """ + return sco_formsemestre.list_formsemestre_by_etape( + etape_apo=str(etape_apogee), annee_scolaire=annee_scolaire + ) + + +def nar_etuds_table(apo_data, NAR_Etuds): + """Liste les NAR -> excel table""" + code_etape = apo_data.etape_apogee + today = datetime.datetime.today().strftime("%d/%m/%y") + L = [] + NAR_Etuds.sort(key=lambda k: k["nom"]) + for e in NAR_Etuds: + L.append( + { + "nom": e["nom"], + "prenom": e["prenom"], + "c0": "", + "c1": "AD", + "etape": code_etape, + "c3": "", + "c4": "", + "c5": "", + "c6": "N", + "c7": "", + "c8": "", + "NIP": e["nip"], + "c10": "", + "c11": "", + "c12": "", + "c13": "NAR - Jury", + "date": today, + } + ) + + columns_ids = ( + "NIP", + "nom", + "prenom", + "etape", + "c0", + "c1", + "c3", + "c4", + "c5", + "c6", + "c7", + "c8", + "c10", + "c11", + "c12", + "c13", + "date", + ) + T = GenTable( + columns_ids=columns_ids, + titles=dict(zip(columns_ids, columns_ids)), + rows=L, + xls_sheet_name="NAR ScoDoc", + ) + return T.excel() + + +def export_csv_to_apogee( + apo_csv_data: str, + periode=None, + dest_zip=None, + export_res_etape=True, + export_res_sem=True, + export_res_ues=True, + export_res_modules=True, + export_res_sdj=True, + export_res_rat=True, +): + """Genere un fichier CSV Apogée + à partir d'un fichier CSV Apogée vide (ou partiellement rempli) + et des résultats ScoDoc. + Si dest_zip, ajoute les fichiers générés à ce zip + sinon crée un zip et le publie + """ + apo_data = ApoData( + apo_csv_data, + periode=periode, + export_res_etape=export_res_etape, + export_res_sem=export_res_sem, + export_res_ues=export_res_ues, + export_res_modules=export_res_modules, + export_res_sdj=export_res_sdj, + export_res_rat=export_res_rat, + ) + apo_data.setup() # -> .sems_etape + + for e in apo_data.etuds: + e.is_apc = apo_data.is_apc + e.lookup_scodoc(apo_data.etape_formsemestre_ids) + e.associate_sco(apo_data) + + # Ré-écrit le fichier Apogée + f = io.StringIO() + apo_data.write_header(f) + apo_data.write_etuds(f) + + # Table des NAR: + NAR_Etuds = [e for e in apo_data.etuds if e.is_NAR] + if NAR_Etuds: + nar_xls = nar_etuds_table(apo_data, NAR_Etuds) + else: + nar_xls = None + + # Journaux & Comptes-rendus + # Orphelins: etudiants dans fichier Apogée mais pas dans ScoDoc + Apo_Non_ScoDoc = [e for e in apo_data.etuds if e.etat == ETUD_ORPHELIN] + # Non inscrits: connus de ScoDoc mais pas inscrit dans l'étape cette année + Apo_Non_ScoDoc_Inscrits = [e for e in apo_data.etuds if e.etat == ETUD_NON_INSCRIT] + # CR table + cr_table = apo_data.build_cr_table() + cr_xls = cr_table.excel() + + # Create ZIP + if not dest_zip: + data = io.BytesIO() + dest_zip = ZipFile(data, "w") + my_zip = True + else: + my_zip = False + # Ensure unique filenames + filename = apo_data.titles["apoC_Fichier_Exp"] + basename, ext = os.path.splitext(filename) + csv_filename = filename + + if csv_filename in dest_zip.namelist(): + basename = filename + "-" + apo_data.vdi_apogee + csv_filename = basename + ext + nf = 1 + tmplname = basename + while csv_filename in dest_zip.namelist(): + basename = tmplname + "-%d" % nf + csv_filename = basename + ext + nf += 1 + + log_filename = "scodoc-" + basename + ".log.txt" + nar_filename = basename + "-nar" + scu.XLSX_SUFFIX + cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX + + logf = io.StringIO() + logf.write("export_to_apogee du %s\n\n" % time.ctime()) + logf.write("Semestres ScoDoc sources:\n") + for sem in apo_data.sems_etape: + logf.write("\t%(titremois)s\n" % sem) + logf.write("Periode: %s\n" % periode) + logf.write("export_res_etape: %s\n" % int(export_res_etape)) + logf.write("export_res_sem: %s\n" % int(export_res_sem)) + logf.write("export_res_ues: %s\n" % int(export_res_ues)) + logf.write("export_res_modules: %s\n" % int(export_res_modules)) + logf.write("export_res_sdj: %s\n" % int(export_res_sdj)) + logf.write( + "\nEtudiants Apogee non trouves dans ScoDoc:\n" + + "\n".join( + ["%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"]) for e in Apo_Non_ScoDoc] + ) + ) + logf.write( + "\nEtudiants Apogee non inscrits sur ScoDoc dans cette étape:\n" + + "\n".join( + [ + "%s\t%s\t%s" % (e["nip"], e["nom"], e["prenom"]) + for e in Apo_Non_ScoDoc_Inscrits + ] + ) + ) + + logf.write( + "\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n" + + "\n".join(apo_data.list_unknown_elements()) + ) + log(logf.getvalue()) # sortie aussi sur le log ScoDoc + + csv_data = f.getvalue().encode(APO_OUTPUT_ENCODING) + + # Write data to ZIP + dest_zip.writestr(csv_filename, csv_data) + dest_zip.writestr(log_filename, logf.getvalue()) + if nar_xls: + dest_zip.writestr(nar_filename, nar_xls) + dest_zip.writestr(cr_filename, cr_xls) + + if my_zip: + dest_zip.close() + data.seek(0) + return send_file( + data, + mimetype="application/zip", + download_name=scu.sanitize_filename(basename + "-scodoc.zip"), + as_attachment=True, + ) + else: + return None # zip modified in place diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 08c7b85e..c0a40b14 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -1,386 +1,386 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""ScoDoc : gestion des fichiers archivés associés aux étudiants - Il s'agit de fichiers quelconques, généralement utilisés pour conserver - les dossiers d'admission et autres pièces utiles. -""" -import flask -from flask import url_for, render_template -from flask import g, request -from flask_login import current_user - -import app.scodoc.sco_utils as scu -from app.scodoc import sco_import_etuds -from app.scodoc import sco_groups -from app.scodoc import sco_trombino -from app.scodoc import sco_excel -from app.scodoc import sco_archives -from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import AccessDenied, ScoValueError -from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc import html_sco_header -from app.scodoc import sco_etud - - -class EtudsArchiver(sco_archives.BaseArchiver): - def __init__(self): - sco_archives.BaseArchiver.__init__(self, archive_type="docetuds") - - -EtudsArchive = EtudsArchiver() - - -def can_edit_etud_archive(authuser): - """True si l'utilisateur peut modifier les archives etudiantes""" - return authuser.has_permission(Permission.ScoEtudAddAnnotations) - - -def etud_list_archives_html(etudid): - """HTML snippet listing archives""" - can_edit = can_edit_etud_archive(current_user) - etuds = sco_etud.get_etud_info(etudid=etudid) - if not etuds: - raise ScoValueError("étudiant inexistant") - etud = etuds[0] - etud_archive_id = etudid - L = [] - for archive_id in EtudsArchive.list_obj_archives(etud_archive_id): - a = { - "archive_id": archive_id, - "description": EtudsArchive.get_archive_description(archive_id), - "date": EtudsArchive.get_archive_date(archive_id), - "content": EtudsArchive.list_archive(archive_id), - } - L.append(a) - delete_icon = scu.icontag( - "delete_small_img", title="Supprimer fichier", alt="supprimer" - ) - delete_disabled_icon = scu.icontag( - "delete_small_dis_img", title="Suppression non autorisée" - ) - H = ['
") - return "".join(H) - - -def add_archives_info_to_etud_list(etuds): - """Add key 'etudarchive' describing archive of etuds - (used to list all archives of a group) - """ - for etud in etuds: - l = [] - etud_archive_id = etud["etudid"] - for archive_id in EtudsArchive.list_obj_archives(etud_archive_id): - l.append( - "%s (%s)" - % ( - EtudsArchive.get_archive_description(archive_id), - EtudsArchive.list_archive(archive_id)[0], - ) - ) - etud["etudarchive"] = ", ".join(l) - - -def etud_upload_file_form(etudid): - """Page with a form to choose and upload a file, with a description.""" - # check permission - if not can_edit_etud_archive(current_user): - raise AccessDenied("opération non autorisée pour %s" % current_user) - etuds = sco_etud.get_etud_info(filled=True) - if not etuds: - raise ScoValueError("étudiant inexistant") - etud = etuds[0] - H = [ - html_sco_header.sco_header( - page_title="Chargement d'un document associé à %(nomprenom)s" % etud, - ), - """

Chargement d'un document associé à %(nomprenom)s

- """ - % etud, - """

Le fichier ne doit pas dépasser %sMo.

- """ - % (scu.CONFIG.ETUD_MAX_FILE_SIZE // (1024 * 1024)), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("etudid", {"default": etudid, "input_type": "hidden"}), - ("datafile", {"input_type": "file", "title": "Fichier", "size": 30}), - ( - "description", - { - "input_type": "textarea", - "rows": 4, - "cols": 77, - "title": "Description", - }, - ), - ), - submitlabel="Valider", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - else: - data = tf[2]["datafile"].read() - descr = tf[2]["description"] - filename = tf[2]["datafile"].filename - etud_archive_id = etud["etudid"] - _store_etud_file_to_new_archive( - etud_archive_id, data, filename, description=descr - ) - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -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 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(f"opération non autorisée pour {current_user}") - etuds = sco_etud.get_etud_info(filled=True) - if not etuds: - raise ScoValueError("étudiant inexistant") - etud = etuds[0] - etud_archive_id = etud["etudid"] - archive_id = EtudsArchive.get_id_from_name(etud_archive_id, archive_name) - if not dialog_confirmed: - return scu.confirm_dialog( - """

Confirmer la suppression des fichiers ?

-

Fichier associé le %s à l'étudiant %s

-

La suppression sera définitive.

""" - % ( - EtudsArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"), - etud["nomprenom"], - ), - dest_url="", - cancel_url=url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etudid, - head_message="annulation", - ), - parameters={"etudid": etudid, "archive_name": archive_name}, - ) - - EtudsArchive.delete_archive(archive_id) - return flask.redirect( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etudid, - head_message="Archive%20supprimée", - ) - ) - - -def etud_get_archived_file(etudid, archive_name, filename): - """Send file to client.""" - etuds = sco_etud.get_etud_info(etudid=etudid, filled=True) - if not etuds: - raise ScoValueError("étudiant inexistant") - etud = etuds[0] - etud_archive_id = etud["etudid"] - return EtudsArchive.get_archived_file(etud_archive_id, archive_name, filename) - - -# --- Upload d'un ensemble de fichiers (pour un groupe d'étudiants) -def etudarchive_generate_excel_sample(group_id=None): - """Feuille excel pour import fichiers etudiants (utilisé pour admissions)""" - fmt = sco_import_etuds.sco_import_format() - data = sco_import_etuds.sco_import_generate_excel_sample( - fmt, - group_ids=[group_id], - only_tables=["identite"], - exclude_cols=[ - "date_naissance", - "lieu_naissance", - "nationalite", - "statut", - "photo_filename", - ], - extra_cols=["fichier_a_charger"], - ) - return scu.send_file( - data, - "ImportFichiersEtudiants", - suffix=scu.XLSX_SUFFIX, - mime=scu.XLSX_MIMETYPE, - ) - - -def etudarchive_import_files_form(group_id): - """Formulaire pour importation fichiers d'un groupe""" - H = [ - html_sco_header.sco_header( - page_title="Import de fichiers associés aux étudiants" - ), - """

Téléchargement de fichier associés aux étudiants

-

Les fichiers associés (dossiers d'admission, certificats, ...), de - types quelconques (pdf, doc, images) sont accessibles aux utilisateurs via - la fiche individuelle de l'étudiant. -

-

Ne pas confondre avec les photos des étudiants, qui se - chargent via l'onglet "Photos".

-

Vous pouvez aussi charger à tout moment de nouveaux fichiers, ou en - supprimer, via la fiche de chaque étudiant. -

-

Cette page permet de charger en une seule fois les fichiers - de plusieurs étudiants.
- Il faut d'abord remplir une feuille excel donnant les noms - des fichiers (un fichier par étudiant). -

-

Ensuite, réunir vos fichiers dans un fichier zip, puis - télécharger simultanément le fichier excel et le fichier zip. -

-
    -
  1. - Obtenir la feuille excel à remplir -
  2. -
  3. - """ - % group_id, - ] - F = html_sco_header.sco_footer() - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("xlsfile", {"title": "Fichier Excel:", "input_type": "file", "size": 40}), - ("zipfile", {"title": "Fichier zip:", "input_type": "file", "size": 40}), - ( - "description", - { - "input_type": "textarea", - "rows": 4, - "cols": 77, - "title": "Description", - }, - ), - ("group_id", {"input_type": "hidden"}), - ), - ) - - if tf[0] == 0: - return "\n".join(H) + tf[1] + "
" + F - # retrouve le semestre à partir du groupe: - group = sco_groups.get_group(group_id) - if tf[0] == -1: - return flask.redirect( - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=group["formsemestre_id"], - ) - ) - else: - return etudarchive_import_files( - formsemestre_id=group["formsemestre_id"], - xlsfile=tf[2]["xlsfile"], - zipfile=tf[2]["zipfile"], - description=tf[2]["description"], - ) - - -def etudarchive_import_files( - formsemestre_id=None, xlsfile=None, zipfile=None, description="" -): - "Importe des fichiers" - - def callback(etud, data, filename): - return _store_etud_file_to_new_archive( - etud["etudid"], data, filename, description - ) - - # Utilise la fontion developpée au depart pour les photos - ( - ignored_zipfiles, - unmatched_files, - stored_etud_filename, - ) = sco_trombino.zip_excel_import_files( - xlsfile=xlsfile, - zipfile=zipfile, - callback=callback, - filename_title="fichier_a_charger", - ) - return render_template( - "scolar/photos_import_files.html", - page_title="Téléchargement de fichiers associés aux étudiants", - ignored_zipfiles=ignored_zipfiles, - unmatched_files=unmatched_files, - stored_etud_filename=stored_etud_filename, - next_page=url_for( - "scolar.groups_view", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""ScoDoc : gestion des fichiers archivés associés aux étudiants + Il s'agit de fichiers quelconques, généralement utilisés pour conserver + les dossiers d'admission et autres pièces utiles. +""" +import flask +from flask import url_for, render_template +from flask import g, request +from flask_login import current_user + +import app.scodoc.sco_utils as scu +from app.scodoc import sco_import_etuds +from app.scodoc import sco_groups +from app.scodoc import sco_trombino +from app.scodoc import sco_excel +from app.scodoc import sco_archives +from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +from app.scodoc.TrivialFormulator import TrivialFormulator +from app.scodoc import html_sco_header +from app.scodoc import sco_etud + + +class EtudsArchiver(sco_archives.BaseArchiver): + def __init__(self): + sco_archives.BaseArchiver.__init__(self, archive_type="docetuds") + + +EtudsArchive = EtudsArchiver() + + +def can_edit_etud_archive(authuser): + """True si l'utilisateur peut modifier les archives etudiantes""" + return authuser.has_permission(Permission.ScoEtudAddAnnotations) + + +def etud_list_archives_html(etudid): + """HTML snippet listing archives""" + can_edit = can_edit_etud_archive(current_user) + etuds = sco_etud.get_etud_info(etudid=etudid) + if not etuds: + raise ScoValueError("étudiant inexistant") + etud = etuds[0] + etud_archive_id = etudid + L = [] + for archive_id in EtudsArchive.list_obj_archives(etud_archive_id): + a = { + "archive_id": archive_id, + "description": EtudsArchive.get_archive_description(archive_id), + "date": EtudsArchive.get_archive_date(archive_id), + "content": EtudsArchive.list_archive(archive_id), + } + L.append(a) + delete_icon = scu.icontag( + "delete_small_img", title="Supprimer fichier", alt="supprimer" + ) + delete_disabled_icon = scu.icontag( + "delete_small_dis_img", title="Suppression non autorisée" + ) + H = ['
") + return "".join(H) + + +def add_archives_info_to_etud_list(etuds): + """Add key 'etudarchive' describing archive of etuds + (used to list all archives of a group) + """ + for etud in etuds: + l = [] + etud_archive_id = etud["etudid"] + for archive_id in EtudsArchive.list_obj_archives(etud_archive_id): + l.append( + "%s (%s)" + % ( + EtudsArchive.get_archive_description(archive_id), + EtudsArchive.list_archive(archive_id)[0], + ) + ) + etud["etudarchive"] = ", ".join(l) + + +def etud_upload_file_form(etudid): + """Page with a form to choose and upload a file, with a description.""" + # check permission + if not can_edit_etud_archive(current_user): + raise AccessDenied("opération non autorisée pour %s" % current_user) + etuds = sco_etud.get_etud_info(filled=True) + if not etuds: + raise ScoValueError("étudiant inexistant") + etud = etuds[0] + H = [ + html_sco_header.sco_header( + page_title="Chargement d'un document associé à %(nomprenom)s" % etud, + ), + """

Chargement d'un document associé à %(nomprenom)s

+ """ + % etud, + """

Le fichier ne doit pas dépasser %sMo.

+ """ + % (scu.CONFIG.ETUD_MAX_FILE_SIZE // (1024 * 1024)), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("etudid", {"default": etudid, "input_type": "hidden"}), + ("datafile", {"input_type": "file", "title": "Fichier", "size": 30}), + ( + "description", + { + "input_type": "textarea", + "rows": 4, + "cols": 77, + "title": "Description", + }, + ), + ), + submitlabel="Valider", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + else: + data = tf[2]["datafile"].read() + descr = tf[2]["description"] + filename = tf[2]["datafile"].filename + etud_archive_id = etud["etudid"] + _store_etud_file_to_new_archive( + etud_archive_id, data, filename, description=descr + ) + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +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 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(f"opération non autorisée pour {current_user}") + etuds = sco_etud.get_etud_info(filled=True) + if not etuds: + raise ScoValueError("étudiant inexistant") + etud = etuds[0] + etud_archive_id = etud["etudid"] + archive_id = EtudsArchive.get_id_from_name(etud_archive_id, archive_name) + if not dialog_confirmed: + return scu.confirm_dialog( + """

Confirmer la suppression des fichiers ?

+

Fichier associé le %s à l'étudiant %s

+

La suppression sera définitive.

""" + % ( + EtudsArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"), + etud["nomprenom"], + ), + dest_url="", + cancel_url=url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etudid, + head_message="annulation", + ), + parameters={"etudid": etudid, "archive_name": archive_name}, + ) + + EtudsArchive.delete_archive(archive_id) + return flask.redirect( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etudid, + head_message="Archive%20supprimée", + ) + ) + + +def etud_get_archived_file(etudid, archive_name, filename): + """Send file to client.""" + etuds = sco_etud.get_etud_info(etudid=etudid, filled=True) + if not etuds: + raise ScoValueError("étudiant inexistant") + etud = etuds[0] + etud_archive_id = etud["etudid"] + return EtudsArchive.get_archived_file(etud_archive_id, archive_name, filename) + + +# --- Upload d'un ensemble de fichiers (pour un groupe d'étudiants) +def etudarchive_generate_excel_sample(group_id=None): + """Feuille excel pour import fichiers etudiants (utilisé pour admissions)""" + fmt = sco_import_etuds.sco_import_format() + data = sco_import_etuds.sco_import_generate_excel_sample( + fmt, + group_ids=[group_id], + only_tables=["identite"], + exclude_cols=[ + "date_naissance", + "lieu_naissance", + "nationalite", + "statut", + "photo_filename", + ], + extra_cols=["fichier_a_charger"], + ) + return scu.send_file( + data, + "ImportFichiersEtudiants", + suffix=scu.XLSX_SUFFIX, + mime=scu.XLSX_MIMETYPE, + ) + + +def etudarchive_import_files_form(group_id): + """Formulaire pour importation fichiers d'un groupe""" + H = [ + html_sco_header.sco_header( + page_title="Import de fichiers associés aux étudiants" + ), + """

Téléchargement de fichier associés aux étudiants

+

Les fichiers associés (dossiers d'admission, certificats, ...), de + types quelconques (pdf, doc, images) sont accessibles aux utilisateurs via + la fiche individuelle de l'étudiant. +

+

Ne pas confondre avec les photos des étudiants, qui se + chargent via l'onglet "Photos".

+

Vous pouvez aussi charger à tout moment de nouveaux fichiers, ou en + supprimer, via la fiche de chaque étudiant. +

+

Cette page permet de charger en une seule fois les fichiers + de plusieurs étudiants.
+ Il faut d'abord remplir une feuille excel donnant les noms + des fichiers (un fichier par étudiant). +

+

Ensuite, réunir vos fichiers dans un fichier zip, puis + télécharger simultanément le fichier excel et le fichier zip. +

+
    +
  1. + Obtenir la feuille excel à remplir +
  2. +
  3. + """ + % group_id, + ] + F = html_sco_header.sco_footer() + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("xlsfile", {"title": "Fichier Excel:", "input_type": "file", "size": 40}), + ("zipfile", {"title": "Fichier zip:", "input_type": "file", "size": 40}), + ( + "description", + { + "input_type": "textarea", + "rows": 4, + "cols": 77, + "title": "Description", + }, + ), + ("group_id", {"input_type": "hidden"}), + ), + ) + + if tf[0] == 0: + return "\n".join(H) + tf[1] + "
" + F + # retrouve le semestre à partir du groupe: + group = sco_groups.get_group(group_id) + if tf[0] == -1: + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=group["formsemestre_id"], + ) + ) + else: + return etudarchive_import_files( + formsemestre_id=group["formsemestre_id"], + xlsfile=tf[2]["xlsfile"], + zipfile=tf[2]["zipfile"], + description=tf[2]["description"], + ) + + +def etudarchive_import_files( + formsemestre_id=None, xlsfile=None, zipfile=None, description="" +): + "Importe des fichiers" + + def callback(etud, data, filename): + return _store_etud_file_to_new_archive( + etud["etudid"], data, filename, description + ) + + # Utilise la fontion developpée au depart pour les photos + ( + ignored_zipfiles, + unmatched_files, + stored_etud_filename, + ) = sco_trombino.zip_excel_import_files( + xlsfile=xlsfile, + zipfile=zipfile, + callback=callback, + filename_title="fichier_a_charger", + ) + return render_template( + "scolar/photos_import_files.html", + page_title="Téléchargement de fichiers associés aux étudiants", + ignored_zipfiles=ignored_zipfiles, + unmatched_files=unmatched_files, + stored_etud_filename=stored_etud_filename, + next_page=url_for( + "scolar.groups_view", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + ) diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py index d1e09d05..4f40a53e 100644 --- a/app/scodoc/sco_bulletins_generator.py +++ b/app/scodoc/sco_bulletins_generator.py @@ -1,368 +1,368 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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@gmail.com -# -############################################################################## - -"""Génération des bulletins de note: super-classe pour les générateurs (HTML et PDF) - -class BulletinGenerator: - description - supported_formats = [ 'pdf', 'html' ] - .bul_title_pdf() - .bul_table(format) - .bul_part_below(format) - .bul_signatures_pdf() - - .__init__ et .generate(format) methodes appelees par le client (sco_bulletin) - -La préférence 'bul_class_name' donne le nom de la classe generateur. -La préférence 'bul_pdf_class_name' est obsolete (inutilisée). - - -""" -import collections -import io -import time -import traceback - - -import reportlab -from reportlab.platypus import ( - SimpleDocTemplate, - DocIf, - Paragraph, - Spacer, - Frame, - PageBreak, -) -from reportlab.platypus import Table, TableStyle, Image, KeepInFrame - -from flask import request -from flask_login import current_user - -from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import NoteProcessError -from app import log -from app.scodoc import sco_formsemestre -from app.scodoc import sco_pdf -from app.scodoc.sco_pdf import PDFLOCK -import sco_version - - -class BulletinGenerator: - "Virtual superclass for PDF bulletin generators" "" - # Here some helper methods - # see sco_bulletins_standard.BulletinGeneratorStandard subclass for real methods - supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ] - description = "superclass for bulletins" # description for user interface - list_in_menu = True # la classe doit-elle est montrée dans le menu de config ? - scale_table_in_page = True # rescale la table sur 1 page - multi_pages = False - - def __init__( - self, - infos, - authuser=None, - version="long", - filigranne=None, - server_name=None, - ): - from app.scodoc import sco_preferences - - if not version in scu.BULLETINS_VERSIONS: - raise ValueError("invalid version code !") - self.infos = infos - self.authuser = authuser # nécessaire pour version HTML qui contient liens dépendant de l'utilisateur - self.version = version - self.filigranne = filigranne - self.server_name = server_name - # Store preferences for convenience: - formsemestre_id = self.infos["formsemestre_id"] - self.preferences = sco_preferences.SemPreferences(formsemestre_id) - self.diagnostic = None # error message if any problem - # Common PDF styles: - # - Pour tous les champs du bulletin sauf les cellules de table: - self.FieldStyle = reportlab.lib.styles.ParagraphStyle({}) - self.FieldStyle.fontName = self.preferences["SCOLAR_FONT_BUL_FIELDS"] - self.FieldStyle.fontSize = self.preferences["SCOLAR_FONT_SIZE"] - self.FieldStyle.firstLineIndent = 0 - # - Pour les cellules de table: - self.CellStyle = reportlab.lib.styles.ParagraphStyle({}) - self.CellStyle.fontSize = self.preferences["SCOLAR_FONT_SIZE"] - self.CellStyle.fontName = self.preferences["SCOLAR_FONT"] - self.CellStyle.leading = ( - 1.0 * self.preferences["SCOLAR_FONT_SIZE"] - ) # vertical space - # Marges du document PDF - self.margins = ( - self.preferences["left_margin"], - self.preferences["top_margin"], - self.preferences["right_margin"], - self.preferences["bottom_margin"], - ) - - def get_filename(self): - """Build a filename to be proposed to the web client""" - sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"]) - return scu.bul_filename_old(sem, self.infos["etud"], "pdf") - - def generate(self, format="", stand_alone=True): - """Return bulletin in specified format""" - if not format in self.supported_formats: - raise ValueError("unsupported bulletin format (%s)" % format) - try: - PDFLOCK.acquire() # this lock is necessary since reportlab is not re-entrant - if format == "html": - return self.generate_html() - elif format == "pdf": - return self.generate_pdf(stand_alone=stand_alone) - else: - raise ValueError("invalid bulletin format (%s)" % format) - finally: - PDFLOCK.release() - - def generate_html(self): - """Return bulletin as an HTML string""" - H = ['
'] - # table des notes: - H.append(self.bul_table(format="html")) # pylint: disable=no-member - # infos sous la table: - H.append(self.bul_part_below(format="html")) # pylint: disable=no-member - H.append("
") - return "\n".join(H) - - def generate_pdf(self, stand_alone=True): - """Build PDF bulletin from distinct parts - Si stand_alone, génère un doc PDF complet et renvoie une string - Sinon, renvoie juste une liste d'objets PLATYPUS pour intégration - dans un autre document. - """ - from app.scodoc import sco_preferences - - formsemestre_id = self.infos["formsemestre_id"] - marque_debut_bulletin = sco_pdf.DebutBulletin( - self.infos["etud"]["nomprenom"], - filigranne=self.infos["filigranne"], - footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""", - ) - story = [] - # partie haute du bulletin - story += self.bul_title_pdf() # pylint: disable=no-member - index_obj_debut = len(story) - - # table des notes - story += self.bul_table(format="pdf") # pylint: disable=no-member - # infos sous la table - story += self.bul_part_below(format="pdf") # pylint: disable=no-member - # signatures - story += self.bul_signatures_pdf() # pylint: disable=no-member - if self.scale_table_in_page: - # Réduit sur une page - story = [marque_debut_bulletin, KeepInFrame(0, 0, story, mode="shrink")] - else: - # Insere notre marqueur qui permet de générer les bookmarks et filigrannes: - story.insert(index_obj_debut, marque_debut_bulletin) - # - # objects.append(sco_pdf.FinBulletin()) - if not stand_alone: - if self.multi_pages: - # Bulletins sur plusieurs page, force début suivant sur page impaire - story.append( - DocIf("doc.page%2 == 1", [PageBreak(), PageBreak()], [PageBreak()]) - ) - else: - story.append(PageBreak()) # insert page break at end - - return story - else: - # Generation du document PDF - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - report = io.BytesIO() # in-memory document, no disk file - document = sco_pdf.BaseDocTemplate(report) - document.addPageTemplates( - sco_pdf.ScoDocPageTemplate( - document, - author="%s %s (E. Viennet) [%s]" - % (sco_version.SCONAME, sco_version.SCOVERSION, self.description), - title="Bulletin %s de %s" - % (sem["titremois"], self.infos["etud"]["nomprenom"]), - subject="Bulletin de note", - margins=self.margins, - server_name=self.server_name, - filigranne=self.filigranne, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - ) - document.build(story) - data = report.getvalue() - return data - - def buildTableObject(self, P, pdfTableStyle, colWidths): - """Utility used by some old-style generators. - Build a platypus Table instance from a nested list of cells, style and widths. - P: table, as a list of lists - PdfTableStyle: commandes de style pour la table (reportlab) - """ - try: - # put each table cell in a Paragraph - Pt = [ - [Paragraph(sco_pdf.SU(x), self.CellStyle) for x in line] for line in P - ] - except: - # enquête sur exception intermittente... - log("*** bug in PDF buildTableObject:") - log("P=%s" % P) - # compris: reportlab is not thread safe ! - # see http://two.pairlist.net/pipermail/reportlab-users/2006-June/005037.html - # (donc maintenant protégé dans ScoDoc par un Lock global) - self.diagnostic = "erreur lors de la génération du PDF
" - self.diagnostic += "
" + traceback.format_exc() + "
" - return [] - return Table(Pt, colWidths=colWidths, style=pdfTableStyle) - - -# --------------------------------------------------------------------------- -def make_formsemestre_bulletinetud( - infos, - version=None, # short, long, selectedevals - format="pdf", # html, pdf - stand_alone=True, -): - """Bulletin de notes - - Appelle une fonction générant le bulletin au format spécifié à partir des informations infos, - selon les préférences du semestre. - - """ - from app.scodoc import sco_preferences - - version = version or "long" - if not version in scu.BULLETINS_VERSIONS: - raise ValueError("invalid version code !") - - formsemestre_id = infos["formsemestre_id"] - bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) - - gen_class = None - for bul_class_name in ( - sco_preferences.get_preference("bul_class_name", formsemestre_id), - # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut - bulletin_default_class_name(), - ): - if infos.get("type") == "BUT" and format.startswith("pdf"): - gen_class = bulletin_get_class(bul_class_name + "BUT") - if gen_class is None: - gen_class = bulletin_get_class(bul_class_name) - - if gen_class is None: - raise ValueError( - "Type de bulletin PDF invalide (paramètre: %s)" % bul_class_name - ) - - try: - PDFLOCK.acquire() - bul_generator = gen_class( - infos, - authuser=current_user, - version=version, - filigranne=infos["filigranne"], - server_name=request.url_root, - ) - if format not in bul_generator.supported_formats: - # use standard generator - log( - "Bulletin format %s not supported by %s, using %s" - % (format, bul_class_name, bulletin_default_class_name()) - ) - bul_class_name = bulletin_default_class_name() - gen_class = bulletin_get_class(bul_class_name) - bul_generator = gen_class( - infos, - authuser=current_user, - version=version, - filigranne=infos["filigranne"], - server_name=request.url_root, - ) - - data = bul_generator.generate(format=format, stand_alone=stand_alone) - finally: - PDFLOCK.release() - - if bul_generator.diagnostic: - log("bul_error: %s" % bul_generator.diagnostic) - raise NoteProcessError(bul_generator.diagnostic) - - filename = bul_generator.get_filename() - - return data, filename - - -#### - -# Liste des types des classes de générateurs de bulletins PDF: -BULLETIN_CLASSES = collections.OrderedDict() - - -def register_bulletin_class(klass): - BULLETIN_CLASSES[klass.__name__] = klass - - -def bulletin_class_descriptions(): - return [ - BULLETIN_CLASSES[class_name].description - for class_name in BULLETIN_CLASSES - if BULLETIN_CLASSES[class_name].list_in_menu - ] - - -def bulletin_class_names() -> list[str]: - "Liste les noms des classes de bulletins à présenter à l'utilisateur" - return [ - class_name - for class_name in BULLETIN_CLASSES - if BULLETIN_CLASSES[class_name].list_in_menu - ] - - -def bulletin_default_class_name(): - return bulletin_class_names()[0] - - -def bulletin_get_class(class_name: str) -> BulletinGenerator: - """La class de génération de bulletin de ce nom, - ou None si pas trouvée - """ - return BULLETIN_CLASSES.get(class_name) - - -def bulletin_get_class_name_displayed(formsemestre_id): - """Le nom du générateur utilisé, en clair""" - from app.scodoc import sco_preferences - - bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) - gen_class = bulletin_get_class(bul_class_name) - if gen_class is None: - return "invalide ! (voir paramètres)" - return gen_class.description +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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@gmail.com +# +############################################################################## + +"""Génération des bulletins de note: super-classe pour les générateurs (HTML et PDF) + +class BulletinGenerator: + description + supported_formats = [ 'pdf', 'html' ] + .bul_title_pdf() + .bul_table(format) + .bul_part_below(format) + .bul_signatures_pdf() + + .__init__ et .generate(format) methodes appelees par le client (sco_bulletin) + +La préférence 'bul_class_name' donne le nom de la classe generateur. +La préférence 'bul_pdf_class_name' est obsolete (inutilisée). + + +""" +import collections +import io +import time +import traceback + + +import reportlab +from reportlab.platypus import ( + SimpleDocTemplate, + DocIf, + Paragraph, + Spacer, + Frame, + PageBreak, +) +from reportlab.platypus import Table, TableStyle, Image, KeepInFrame + +from flask import request +from flask_login import current_user + +from app.scodoc import sco_utils as scu +from app.scodoc.sco_exceptions import NoteProcessError +from app import log +from app.scodoc import sco_formsemestre +from app.scodoc import sco_pdf +from app.scodoc.sco_pdf import PDFLOCK +import sco_version + + +class BulletinGenerator: + "Virtual superclass for PDF bulletin generators" "" + # Here some helper methods + # see sco_bulletins_standard.BulletinGeneratorStandard subclass for real methods + supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ] + description = "superclass for bulletins" # description for user interface + list_in_menu = True # la classe doit-elle est montrée dans le menu de config ? + scale_table_in_page = True # rescale la table sur 1 page + multi_pages = False + + def __init__( + self, + infos, + authuser=None, + version="long", + filigranne=None, + server_name=None, + ): + from app.scodoc import sco_preferences + + if not version in scu.BULLETINS_VERSIONS: + raise ValueError("invalid version code !") + self.infos = infos + self.authuser = authuser # nécessaire pour version HTML qui contient liens dépendant de l'utilisateur + self.version = version + self.filigranne = filigranne + self.server_name = server_name + # Store preferences for convenience: + formsemestre_id = self.infos["formsemestre_id"] + self.preferences = sco_preferences.SemPreferences(formsemestre_id) + self.diagnostic = None # error message if any problem + # Common PDF styles: + # - Pour tous les champs du bulletin sauf les cellules de table: + self.FieldStyle = reportlab.lib.styles.ParagraphStyle({}) + self.FieldStyle.fontName = self.preferences["SCOLAR_FONT_BUL_FIELDS"] + self.FieldStyle.fontSize = self.preferences["SCOLAR_FONT_SIZE"] + self.FieldStyle.firstLineIndent = 0 + # - Pour les cellules de table: + self.CellStyle = reportlab.lib.styles.ParagraphStyle({}) + self.CellStyle.fontSize = self.preferences["SCOLAR_FONT_SIZE"] + self.CellStyle.fontName = self.preferences["SCOLAR_FONT"] + self.CellStyle.leading = ( + 1.0 * self.preferences["SCOLAR_FONT_SIZE"] + ) # vertical space + # Marges du document PDF + self.margins = ( + self.preferences["left_margin"], + self.preferences["top_margin"], + self.preferences["right_margin"], + self.preferences["bottom_margin"], + ) + + def get_filename(self): + """Build a filename to be proposed to the web client""" + sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"]) + return scu.bul_filename_old(sem, self.infos["etud"], "pdf") + + def generate(self, format="", stand_alone=True): + """Return bulletin in specified format""" + if not format in self.supported_formats: + raise ValueError("unsupported bulletin format (%s)" % format) + try: + PDFLOCK.acquire() # this lock is necessary since reportlab is not re-entrant + if format == "html": + return self.generate_html() + elif format == "pdf": + return self.generate_pdf(stand_alone=stand_alone) + else: + raise ValueError("invalid bulletin format (%s)" % format) + finally: + PDFLOCK.release() + + def generate_html(self): + """Return bulletin as an HTML string""" + H = ['
'] + # table des notes: + H.append(self.bul_table(format="html")) # pylint: disable=no-member + # infos sous la table: + H.append(self.bul_part_below(format="html")) # pylint: disable=no-member + H.append("
") + return "\n".join(H) + + def generate_pdf(self, stand_alone=True): + """Build PDF bulletin from distinct parts + Si stand_alone, génère un doc PDF complet et renvoie une string + Sinon, renvoie juste une liste d'objets PLATYPUS pour intégration + dans un autre document. + """ + from app.scodoc import sco_preferences + + formsemestre_id = self.infos["formsemestre_id"] + marque_debut_bulletin = sco_pdf.DebutBulletin( + self.infos["etud"]["nomprenom"], + filigranne=self.infos["filigranne"], + footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""", + ) + story = [] + # partie haute du bulletin + story += self.bul_title_pdf() # pylint: disable=no-member + index_obj_debut = len(story) + + # table des notes + story += self.bul_table(format="pdf") # pylint: disable=no-member + # infos sous la table + story += self.bul_part_below(format="pdf") # pylint: disable=no-member + # signatures + story += self.bul_signatures_pdf() # pylint: disable=no-member + if self.scale_table_in_page: + # Réduit sur une page + story = [marque_debut_bulletin, KeepInFrame(0, 0, story, mode="shrink")] + else: + # Insere notre marqueur qui permet de générer les bookmarks et filigrannes: + story.insert(index_obj_debut, marque_debut_bulletin) + # + # objects.append(sco_pdf.FinBulletin()) + if not stand_alone: + if self.multi_pages: + # Bulletins sur plusieurs page, force début suivant sur page impaire + story.append( + DocIf("doc.page%2 == 1", [PageBreak(), PageBreak()], [PageBreak()]) + ) + else: + story.append(PageBreak()) # insert page break at end + + return story + else: + # Generation du document PDF + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + report = io.BytesIO() # in-memory document, no disk file + document = sco_pdf.BaseDocTemplate(report) + document.addPageTemplates( + sco_pdf.ScoDocPageTemplate( + document, + author="%s %s (E. Viennet) [%s]" + % (sco_version.SCONAME, sco_version.SCOVERSION, self.description), + title="Bulletin %s de %s" + % (sem["titremois"], self.infos["etud"]["nomprenom"]), + subject="Bulletin de note", + margins=self.margins, + server_name=self.server_name, + filigranne=self.filigranne, + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + ) + document.build(story) + data = report.getvalue() + return data + + def buildTableObject(self, P, pdfTableStyle, colWidths): + """Utility used by some old-style generators. + Build a platypus Table instance from a nested list of cells, style and widths. + P: table, as a list of lists + PdfTableStyle: commandes de style pour la table (reportlab) + """ + try: + # put each table cell in a Paragraph + Pt = [ + [Paragraph(sco_pdf.SU(x), self.CellStyle) for x in line] for line in P + ] + except: + # enquête sur exception intermittente... + log("*** bug in PDF buildTableObject:") + log("P=%s" % P) + # compris: reportlab is not thread safe ! + # see http://two.pairlist.net/pipermail/reportlab-users/2006-June/005037.html + # (donc maintenant protégé dans ScoDoc par un Lock global) + self.diagnostic = "erreur lors de la génération du PDF
" + self.diagnostic += "
" + traceback.format_exc() + "
" + return [] + return Table(Pt, colWidths=colWidths, style=pdfTableStyle) + + +# --------------------------------------------------------------------------- +def make_formsemestre_bulletinetud( + infos, + version=None, # short, long, selectedevals + format="pdf", # html, pdf + stand_alone=True, +): + """Bulletin de notes + + Appelle une fonction générant le bulletin au format spécifié à partir des informations infos, + selon les préférences du semestre. + + """ + from app.scodoc import sco_preferences + + version = version or "long" + if not version in scu.BULLETINS_VERSIONS: + raise ValueError("invalid version code !") + + formsemestre_id = infos["formsemestre_id"] + bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) + + gen_class = None + for bul_class_name in ( + sco_preferences.get_preference("bul_class_name", formsemestre_id), + # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut + bulletin_default_class_name(), + ): + if infos.get("type") == "BUT" and format.startswith("pdf"): + gen_class = bulletin_get_class(bul_class_name + "BUT") + if gen_class is None: + gen_class = bulletin_get_class(bul_class_name) + + if gen_class is None: + raise ValueError( + "Type de bulletin PDF invalide (paramètre: %s)" % bul_class_name + ) + + try: + PDFLOCK.acquire() + bul_generator = gen_class( + infos, + authuser=current_user, + version=version, + filigranne=infos["filigranne"], + server_name=request.url_root, + ) + if format not in bul_generator.supported_formats: + # use standard generator + log( + "Bulletin format %s not supported by %s, using %s" + % (format, bul_class_name, bulletin_default_class_name()) + ) + bul_class_name = bulletin_default_class_name() + gen_class = bulletin_get_class(bul_class_name) + bul_generator = gen_class( + infos, + authuser=current_user, + version=version, + filigranne=infos["filigranne"], + server_name=request.url_root, + ) + + data = bul_generator.generate(format=format, stand_alone=stand_alone) + finally: + PDFLOCK.release() + + if bul_generator.diagnostic: + log("bul_error: %s" % bul_generator.diagnostic) + raise NoteProcessError(bul_generator.diagnostic) + + filename = bul_generator.get_filename() + + return data, filename + + +#### + +# Liste des types des classes de générateurs de bulletins PDF: +BULLETIN_CLASSES = collections.OrderedDict() + + +def register_bulletin_class(klass): + BULLETIN_CLASSES[klass.__name__] = klass + + +def bulletin_class_descriptions(): + return [ + BULLETIN_CLASSES[class_name].description + for class_name in BULLETIN_CLASSES + if BULLETIN_CLASSES[class_name].list_in_menu + ] + + +def bulletin_class_names() -> list[str]: + "Liste les noms des classes de bulletins à présenter à l'utilisateur" + return [ + class_name + for class_name in BULLETIN_CLASSES + if BULLETIN_CLASSES[class_name].list_in_menu + ] + + +def bulletin_default_class_name(): + return bulletin_class_names()[0] + + +def bulletin_get_class(class_name: str) -> BulletinGenerator: + """La class de génération de bulletin de ce nom, + ou None si pas trouvée + """ + return BULLETIN_CLASSES.get(class_name) + + +def bulletin_get_class_name_displayed(formsemestre_id): + """Le nom du générateur utilisé, en clair""" + from app.scodoc import sco_preferences + + bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) + gen_class = bulletin_get_class(bul_class_name) + if gen_class is None: + return "invalide ! (voir paramètres)" + return gen_class.description diff --git a/app/scodoc/sco_cost_formation.py b/app/scodoc/sco_cost_formation.py index e5330e02..167ff770 100644 --- a/app/scodoc/sco_cost_formation.py +++ b/app/scodoc/sco_cost_formation.py @@ -1,197 +1,197 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Rapports estimation coût de formation basé sur le programme pédagogique - et les nombres de groupes. - - (coût théorique en heures équivalent TD) -""" -from flask import request - -import app.scodoc.sco_utils as scu -from app.scodoc.gen_tables import GenTable -from app.scodoc import sco_formsemestre -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_preferences -import sco_version - - -def formsemestre_table_estim_cost( - formsemestre_id, - n_group_td=1, - n_group_tp=1, - coef_tp=1, - coef_cours=1.5, -): - """ - Rapports estimation coût de formation basé sur le programme pédagogique - et les nombres de groupes. - Coût théorique en heures équivalent TD. - Attention: ne prend en compte que les modules utilisés dans ce semestre. - Attention: prend en compte _tous_ les modules utilisés dans ce semestre, ce qui - peut conduire à une sur-estimation du coût s'il y a des modules optionnels - (dans ce cas, retoucher le tableau excel exporté). - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - sco_formsemestre_status.fill_formsemestre(sem) - Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) - T = [] - for M in Mlist: - Mod = M["module"] - T.append( - { - "code": Mod["code"] or "", - "titre": Mod["titre"], - "heures_cours": Mod["heures_cours"], - "heures_td": Mod["heures_td"] * n_group_td, - "heures_tp": Mod["heures_tp"] * n_group_tp, - } - ) - - # calcul des heures: - for t in T: - t["HeqTD"] = ( - t["heures_td"] + coef_cours * t["heures_cours"] + coef_tp * t["heures_tp"] - ) - sum_cours = sum([t["heures_cours"] for t in T]) - sum_td = sum([t["heures_td"] for t in T]) - sum_tp = sum([t["heures_tp"] for t in T]) - sum_heqtd = sum_td + coef_cours * sum_cours + coef_tp * sum_tp - assert abs(sum([t["HeqTD"] for t in T]) - sum_heqtd) < 0.01, "%s != %s" % ( - sum([t["HeqTD"] for t in T]), - sum_heqtd, - ) - - T.append( - { - "code": "TOTAL SEMESTRE", - "heures_cours": sum_cours, - "heures_td": sum_td, - "heures_tp": sum_tp, - "HeqTD": sum_heqtd, - "_table_part": "foot", - } - ) - - titles = { - "code": "Code", - "titre": "Titre", - "heures_cours": "Cours", - "heures_td": "TD", - "heures_tp": "TP", - "HeqTD": "HeqTD", - } - - tab = GenTable( - titles=titles, - columns_ids=( - "code", - "titre", - "heures_cours", - "heures_td", - "heures_tp", - "HeqTD", - ), - rows=T, - html_sortable=True, - preferences=sco_preferences.SemPreferences(formsemestre_id), - html_class="table_leftalign table_listegroupe", - xls_before_table=[ - ["%(titre)s %(num_sem)s %(modalitestr)s" % sem], - ["Formation %(titre)s version %(version)s" % sem["formation"]], - [], - ["", "TD", "TP"], - ["Nombre de groupes", n_group_td, n_group_tp], - [], - [], - ], - html_caption="""
- Estimation du coût de formation basé sur le programme pédagogique - et les nombres de groupes.
- Coût théorique en heures équivalent TD.
- Attention: ne prend en compte que les modules utilisés dans ce semestre.
- Attention: prend en compte tous les modules utilisés dans ce semestre, ce qui - peut conduire à une sur-estimation du coût s'il y a des modules optionnels - (dans ce cas, retoucher le tableau excel exporté). -
- """, - origin="Généré par %s le " % sco_version.SCONAME - + scu.timedate_human_repr() - + "", - filename="EstimCout-S%s" % sem["semestre_id"], - ) - return tab - - -def formsemestre_estim_cost( - formsemestre_id, - n_group_td=1, - n_group_tp=1, - coef_tp=1, - coef_cours=1.5, - format="html", -): - """Page (formulaire) estimation coûts""" - - n_group_td = int(n_group_td) - n_group_tp = int(n_group_tp) - coef_tp = float(coef_tp) - coef_cours = float(coef_cours) - - tab = formsemestre_table_estim_cost( - formsemestre_id, - n_group_td=n_group_td, - n_group_tp=n_group_tp, - coef_tp=coef_tp, - coef_cours=coef_cours, - ) - h = """ -
- - Nombre de groupes de TD:
- Nombre de groupes de TP: -  Coefficient heures TP: -
-
- """ % ( - request.base_url, - formsemestre_id, - n_group_td, - n_group_tp, - coef_tp, - ) - tab.html_before_table = h - tab.base_url = "%s?formsemestre_id=%s&n_group_td=%s&n_group_tp=%s&coef_tp=%s" % ( - request.base_url, - formsemestre_id, - n_group_td, - n_group_tp, - coef_tp, - ) - - return tab.make_page(format=format) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Rapports estimation coût de formation basé sur le programme pédagogique + et les nombres de groupes. + + (coût théorique en heures équivalent TD) +""" +from flask import request + +import app.scodoc.sco_utils as scu +from app.scodoc.gen_tables import GenTable +from app.scodoc import sco_formsemestre +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_formsemestre_status +from app.scodoc import sco_preferences +import sco_version + + +def formsemestre_table_estim_cost( + formsemestre_id, + n_group_td=1, + n_group_tp=1, + coef_tp=1, + coef_cours=1.5, +): + """ + Rapports estimation coût de formation basé sur le programme pédagogique + et les nombres de groupes. + Coût théorique en heures équivalent TD. + Attention: ne prend en compte que les modules utilisés dans ce semestre. + Attention: prend en compte _tous_ les modules utilisés dans ce semestre, ce qui + peut conduire à une sur-estimation du coût s'il y a des modules optionnels + (dans ce cas, retoucher le tableau excel exporté). + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + sco_formsemestre_status.fill_formsemestre(sem) + Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) + T = [] + for M in Mlist: + Mod = M["module"] + T.append( + { + "code": Mod["code"] or "", + "titre": Mod["titre"], + "heures_cours": Mod["heures_cours"], + "heures_td": Mod["heures_td"] * n_group_td, + "heures_tp": Mod["heures_tp"] * n_group_tp, + } + ) + + # calcul des heures: + for t in T: + t["HeqTD"] = ( + t["heures_td"] + coef_cours * t["heures_cours"] + coef_tp * t["heures_tp"] + ) + sum_cours = sum([t["heures_cours"] for t in T]) + sum_td = sum([t["heures_td"] for t in T]) + sum_tp = sum([t["heures_tp"] for t in T]) + sum_heqtd = sum_td + coef_cours * sum_cours + coef_tp * sum_tp + assert abs(sum([t["HeqTD"] for t in T]) - sum_heqtd) < 0.01, "%s != %s" % ( + sum([t["HeqTD"] for t in T]), + sum_heqtd, + ) + + T.append( + { + "code": "TOTAL SEMESTRE", + "heures_cours": sum_cours, + "heures_td": sum_td, + "heures_tp": sum_tp, + "HeqTD": sum_heqtd, + "_table_part": "foot", + } + ) + + titles = { + "code": "Code", + "titre": "Titre", + "heures_cours": "Cours", + "heures_td": "TD", + "heures_tp": "TP", + "HeqTD": "HeqTD", + } + + tab = GenTable( + titles=titles, + columns_ids=( + "code", + "titre", + "heures_cours", + "heures_td", + "heures_tp", + "HeqTD", + ), + rows=T, + html_sortable=True, + preferences=sco_preferences.SemPreferences(formsemestre_id), + html_class="table_leftalign table_listegroupe", + xls_before_table=[ + ["%(titre)s %(num_sem)s %(modalitestr)s" % sem], + ["Formation %(titre)s version %(version)s" % sem["formation"]], + [], + ["", "TD", "TP"], + ["Nombre de groupes", n_group_td, n_group_tp], + [], + [], + ], + html_caption="""
+ Estimation du coût de formation basé sur le programme pédagogique + et les nombres de groupes.
+ Coût théorique en heures équivalent TD.
+ Attention: ne prend en compte que les modules utilisés dans ce semestre.
+ Attention: prend en compte tous les modules utilisés dans ce semestre, ce qui + peut conduire à une sur-estimation du coût s'il y a des modules optionnels + (dans ce cas, retoucher le tableau excel exporté). +
+ """, + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + filename="EstimCout-S%s" % sem["semestre_id"], + ) + return tab + + +def formsemestre_estim_cost( + formsemestre_id, + n_group_td=1, + n_group_tp=1, + coef_tp=1, + coef_cours=1.5, + format="html", +): + """Page (formulaire) estimation coûts""" + + n_group_td = int(n_group_td) + n_group_tp = int(n_group_tp) + coef_tp = float(coef_tp) + coef_cours = float(coef_cours) + + tab = formsemestre_table_estim_cost( + formsemestre_id, + n_group_td=n_group_td, + n_group_tp=n_group_tp, + coef_tp=coef_tp, + coef_cours=coef_cours, + ) + h = """ +
+ + Nombre de groupes de TD:
+ Nombre de groupes de TP: +  Coefficient heures TP: +
+
+ """ % ( + request.base_url, + formsemestre_id, + n_group_td, + n_group_tp, + coef_tp, + ) + tab.html_before_table = h + tab.base_url = "%s?formsemestre_id=%s&n_group_td=%s&n_group_tp=%s&coef_tp=%s" % ( + request.base_url, + formsemestre_id, + n_group_td, + n_group_tp, + coef_tp, + ) + + return tab.make_page(format=format) diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py index d1545f4f..e31752a0 100644 --- a/app/scodoc/sco_dept.py +++ b/app/scodoc/sco_dept.py @@ -1,399 +1,399 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Page accueil département (liste des semestres, etc) -""" - -from flask import g, request -from flask import url_for -from flask_login import current_user - -import app -from app.models import ScolarNews -import app.scodoc.sco_utils as scu -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_permissions import Permission -from app.scodoc import html_sco_header -import app.scodoc.notesdb as ndb -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_modalites -from app.scodoc import sco_preferences -from app.scodoc import sco_users - - -def index_html(showcodes=0, showsemtable=0): - "Page accueil département (liste des semestres)" - showcodes = int(showcodes) - showsemtable = int(showsemtable) - H = [] - - # News: - H.append(ScolarNews.scolar_news_summary_html()) - - # Avertissement de mise à jour: - H.append("""
""") - - # Liste de toutes les sessions: - sems = sco_formsemestre.do_formsemestre_list() - cursems = [] # semestres "courants" - othersems = [] # autres (verrouillés) - # icon image: - groupicon = scu.icontag("groupicon_img", title="Inscrits", border="0") - emptygroupicon = scu.icontag( - "emptygroupicon_img", title="Pas d'inscrits", border="0" - ) - lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") - # Sélection sur l'etat du semestre - for sem in sems: - if sem["etat"] and sem["modalite"] != "EXT": - sem["lockimg"] = "" - cursems.append(sem) - else: - sem["lockimg"] = lockicon - othersems.append(sem) - # Responsable de formation: - sco_formsemestre.sem_set_responsable_name(sem) - - if showcodes: - sem["tmpcode"] = f"{sem['formsemestre_id']}" - else: - sem["tmpcode"] = "" - # Nombre d'inscrits: - args = {"formsemestre_id": sem["formsemestre_id"]} - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(args=args) - nb = len(ins) # nb etudiants - sem["nb_inscrits"] = nb - if nb > 0: - sem["groupicon"] = groupicon - else: - sem["groupicon"] = emptygroupicon - - # S'il n'y a pas d'utilisateurs dans la base, affiche message - if not sco_users.get_user_list(dept=g.scodoc_dept): - H.append( - """

Aucun utilisateur défini !

Pour définir des utilisateurs - passez par la page Utilisateurs. -
- Définissez au moins un utilisateur avec le rôle AdminXXX (le responsable du département XXX). -

- """ - ) - - # Liste des formsemestres "courants" - if cursems: - H.append('

Sessions en cours

') - H.append(_sem_table(cursems)) - else: - # aucun semestre courant: affiche aide - H.append( - """

Aucune session en cours !

-

Pour ajouter une session, aller dans Programmes, - choisissez une formation, puis suivez le lien "UE, modules, semestres". -

- Là, en bas de page, suivez le lien - "Mettre en place un nouveau semestre de formation..." -

""" - ) - - if showsemtable: - H.append( - f"""
-

Semestres de {sco_preferences.get_preference("DeptName")}

- """ - ) - H.append(_sem_table_gt(sems, showcodes=showcodes).html()) - H.append("") - if not showsemtable: - H.append( - f"""
-

Voir table des semestres (dont {len(othersems)} - verrouillé{'s' if len(othersems) else ''}) -

""" - ) - - H.append( - f"""

-

- Chercher étape courante: - -
-

""" - ) - # - if current_user.has_permission(Permission.ScoEtudInscrit): - H.append( - """
-

Gestion des étudiants

- - """ - ) - # - if current_user.has_permission(Permission.ScoEditApo): - H.append( - f"""
-

Exports Apogée

- - """ - ) - # - H.append( - """
-

Assistance

- - """ - ) - # - return ( - html_sco_header.sco_header( - page_title=f"ScoDoc {g.scodoc_dept}", javascripts=["js/scolar_index.js"] - ) - + "\n".join(H) - + html_sco_header.sco_footer() - ) - - -def _sem_table(sems): - """Affiche liste des semestres, utilisée pour semestres en cours""" - tmpl = """%(tmpcode)s - %(lockimg)s %(groupicon)s - %(mois_debut)s - %(mois_fin)s - %(titre_num)s - (%(responsable_name)s) - - - """ - - # Liste des semestres, groupés par modalités - sems_by_mod, modalites = sco_modalites.group_sems_by_modalite(sems) - - H = [''] - for modalite in modalites: - if len(modalites) > 1: - H.append('' % modalite["titre"]) - - if sems_by_mod[modalite["modalite"]]: - cur_idx = sems_by_mod[modalite["modalite"]][0]["semestre_id"] - for sem in sems_by_mod[modalite["modalite"]]: - if cur_idx != sem["semestre_id"]: - sem["trclass"] = "firstsem" # separe les groupes de semestres - cur_idx = sem["semestre_id"] - else: - sem["trclass"] = "" - sem["notes_url"] = scu.NotesURL() - H.append(tmpl % sem) - H.append("
%s
") - return "\n".join(H) - - -def _sem_table_gt(sems, showcodes=False): - """Nouvelle version de la table des semestres - Utilise une datatables. - """ - _style_sems(sems) - columns_ids = ( - "lockimg", - "semestre_id_n", - "modalite", - #'mois_debut', - "dash_mois_fin", - "titre_resp", - "nb_inscrits", - "etapes_apo_str", - "elt_annee_apo", - "elt_sem_apo", - ) - if showcodes: - columns_ids = ("formsemestre_id",) + columns_ids - - html_class = "stripe cell-border compact hover order-column table_leftalign semlist" - if current_user.has_permission(Permission.ScoEditApo): - html_class += " apo_editable" - tab = GenTable( - titles={ - "formsemestre_id": "id", - "semestre_id_n": "S#", - "modalite": "", - "mois_debut": "Début", - "dash_mois_fin": "Année", - "titre_resp": "Semestre", - "nb_inscrits": "N", - "etapes_apo_str": "Étape Apo.", - "elt_annee_apo": "Elt. année Apo.", - "elt_sem_apo": "Elt. sem. Apo.", - }, - columns_ids=columns_ids, - rows=sems, - table_id="semlist", - html_class_ignore_default=True, - html_class=html_class, - html_sortable=True, - html_table_attrs=f""" - data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}" - data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}" - data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}" - """, - html_with_td_classes=True, - preferences=sco_preferences.SemPreferences(), - ) - - return tab - - -def _style_sems(sems): - """ajoute quelques attributs de présentation pour la table""" - for sem in sems: - sem["notes_url"] = scu.NotesURL() - sem["_groupicon_target"] = ( - "%(notes_url)s/formsemestre_status?formsemestre_id=%(formsemestre_id)s" - % sem - ) - sem["_formsemestre_id_class"] = "blacktt" - sem["dash_mois_fin"] = ' %(anneescolaire)s' % sem - sem["_dash_mois_fin_class"] = "datesem" - sem["titre_resp"] = ( - """%(titre_num)s - (%(responsable_name)s)""" - % sem - ) - sem["_css_row_class"] = "css_S%d css_M%s" % ( - sem["semestre_id"], - sem["modalite"], - ) - sem["_semestre_id_class"] = "semestre_id" - sem["_modalite_class"] = "modalite" - if sem["semestre_id"] == -1: - sem["semestre_id_n"] = "" - else: - sem["semestre_id_n"] = sem["semestre_id"] - # pour édition codes Apogée: - sem[ - "_etapes_apo_str_td_attrs" - ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['etapes_apo_str']}" """ - sem[ - "_elt_annee_apo_td_attrs" - ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_annee_apo']}" """ - sem[ - "_elt_sem_apo_td_attrs" - ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_sem_apo']}" """ - - -def delete_dept(dept_id: int): - """Suppression irréversible d'un département et de tous les objets rattachés""" - assert isinstance(dept_id, int) - - # Un peu complexe, merci JMP :) - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor() - try: - # 1- Create temp tables to store ids - reqs = [ - "create temp table etudids_temp as select id from identite where dept_id = %(dept_id)s", - "create temp table formsemestres_temp as select id from notes_formsemestre where dept_id = %(dept_id)s", - "create temp table moduleimpls_temp as select id from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", - "create temp table formations_temp as select id from notes_formations where dept_id = %(dept_id)s", - "create temp table tags_temp as select id from notes_tags where dept_id = %(dept_id)s", - ] - for r in reqs: - cursor.execute(r, {"dept_id": dept_id}) - - # 2- Delete student-related informations - # ordered list of tables - etud_tables = [ - "notes_notes", - "group_membership", - "admissions", - "billet_absence", - "adresse", - "absences", - "notes_notes_log", - "notes_moduleimpl_inscription", - "itemsuivi", - "notes_appreciations", - "scolar_autorisation_inscription", - "absences_notifications", - "notes_formsemestre_inscription", - "scolar_formsemestre_validation", - "scolar_events", - ] - for table in etud_tables: - cursor.execute( - f"delete from {table} where etudid in (select id from etudids_temp)" - ) - - reqs = [ - "delete from identite where dept_id = %(dept_id)s", - "delete from sco_prefs where dept_id = %(dept_id)s", - "delete from notes_semset_formsemestre where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_evaluation where moduleimpl_id in (select id from moduleimpls_temp)", - "delete from notes_modules_enseignants where moduleimpl_id in (select id from moduleimpls_temp)", - "delete from notes_formsemestre_uecoef where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_formsemestre_ue_computation_expr where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_formsemestre_responsables where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_modules_tags where tag_id in (select id from tags_temp)", - "delete from notes_tags where dept_id = %(dept_id)s", - "delete from notes_modules where formation_id in (select id from formations_temp)", - "delete from notes_matieres where ue_id in (select id from notes_ue where formation_id in (select id from formations_temp))", - "delete from notes_formsemestre_etapes where formsemestre_id in (select id from formsemestres_temp)", - "delete from group_descr where partition_id in (select id from partition where formsemestre_id in (select id from formsemestres_temp))", - "delete from partition where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_formsemestre_custommenu where formsemestre_id in (select id from formsemestres_temp)", - "delete from notes_ue where formation_id in (select id from formations_temp)", - "delete from notes_formsemestre where dept_id = %(dept_id)s", - "delete from scolar_news where dept_id = %(dept_id)s", - "delete from notes_semset where dept_id = %(dept_id)s", - "delete from notes_formations where dept_id = %(dept_id)s", - "delete from departement where id = %(dept_id)s", - "drop table tags_temp", - "drop table formations_temp", - "drop table moduleimpls_temp", - "drop table etudids_temp", - "drop table formsemestres_temp", - ] - for r in reqs: - cursor.execute(r, {"dept_id": dept_id}) - except: - cnx.rollback() - finally: - cnx.commit() - app.clear_scodoc_cache() +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Page accueil département (liste des semestres, etc) +""" + +from flask import g, request +from flask import url_for +from flask_login import current_user + +import app +from app.models import ScolarNews +import app.scodoc.sco_utils as scu +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_permissions import Permission +from app.scodoc import html_sco_header +import app.scodoc.notesdb as ndb +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_modalites +from app.scodoc import sco_preferences +from app.scodoc import sco_users + + +def index_html(showcodes=0, showsemtable=0): + "Page accueil département (liste des semestres)" + showcodes = int(showcodes) + showsemtable = int(showsemtable) + H = [] + + # News: + H.append(ScolarNews.scolar_news_summary_html()) + + # Avertissement de mise à jour: + H.append("""
""") + + # Liste de toutes les sessions: + sems = sco_formsemestre.do_formsemestre_list() + cursems = [] # semestres "courants" + othersems = [] # autres (verrouillés) + # icon image: + groupicon = scu.icontag("groupicon_img", title="Inscrits", border="0") + emptygroupicon = scu.icontag( + "emptygroupicon_img", title="Pas d'inscrits", border="0" + ) + lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") + # Sélection sur l'etat du semestre + for sem in sems: + if sem["etat"] and sem["modalite"] != "EXT": + sem["lockimg"] = "" + cursems.append(sem) + else: + sem["lockimg"] = lockicon + othersems.append(sem) + # Responsable de formation: + sco_formsemestre.sem_set_responsable_name(sem) + + if showcodes: + sem["tmpcode"] = f"{sem['formsemestre_id']}" + else: + sem["tmpcode"] = "" + # Nombre d'inscrits: + args = {"formsemestre_id": sem["formsemestre_id"]} + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(args=args) + nb = len(ins) # nb etudiants + sem["nb_inscrits"] = nb + if nb > 0: + sem["groupicon"] = groupicon + else: + sem["groupicon"] = emptygroupicon + + # S'il n'y a pas d'utilisateurs dans la base, affiche message + if not sco_users.get_user_list(dept=g.scodoc_dept): + H.append( + """

Aucun utilisateur défini !

Pour définir des utilisateurs + passez par la page Utilisateurs. +
+ Définissez au moins un utilisateur avec le rôle AdminXXX (le responsable du département XXX). +

+ """ + ) + + # Liste des formsemestres "courants" + if cursems: + H.append('

Sessions en cours

') + H.append(_sem_table(cursems)) + else: + # aucun semestre courant: affiche aide + H.append( + """

Aucune session en cours !

+

Pour ajouter une session, aller dans Programmes, + choisissez une formation, puis suivez le lien "UE, modules, semestres". +

+ Là, en bas de page, suivez le lien + "Mettre en place un nouveau semestre de formation..." +

""" + ) + + if showsemtable: + H.append( + f"""
+

Semestres de {sco_preferences.get_preference("DeptName")}

+ """ + ) + H.append(_sem_table_gt(sems, showcodes=showcodes).html()) + H.append("") + if not showsemtable: + H.append( + f"""
+

Voir table des semestres (dont {len(othersems)} + verrouillé{'s' if len(othersems) else ''}) +

""" + ) + + H.append( + f"""

+

+ Chercher étape courante: + +
+

""" + ) + # + if current_user.has_permission(Permission.ScoEtudInscrit): + H.append( + """
+

Gestion des étudiants

+ + """ + ) + # + if current_user.has_permission(Permission.ScoEditApo): + H.append( + f"""
+

Exports Apogée

+ + """ + ) + # + H.append( + """
+

Assistance

+ + """ + ) + # + return ( + html_sco_header.sco_header( + page_title=f"ScoDoc {g.scodoc_dept}", javascripts=["js/scolar_index.js"] + ) + + "\n".join(H) + + html_sco_header.sco_footer() + ) + + +def _sem_table(sems): + """Affiche liste des semestres, utilisée pour semestres en cours""" + tmpl = """%(tmpcode)s + %(lockimg)s %(groupicon)s + %(mois_debut)s - %(mois_fin)s + %(titre_num)s + (%(responsable_name)s) + + + """ + + # Liste des semestres, groupés par modalités + sems_by_mod, modalites = sco_modalites.group_sems_by_modalite(sems) + + H = [''] + for modalite in modalites: + if len(modalites) > 1: + H.append('' % modalite["titre"]) + + if sems_by_mod[modalite["modalite"]]: + cur_idx = sems_by_mod[modalite["modalite"]][0]["semestre_id"] + for sem in sems_by_mod[modalite["modalite"]]: + if cur_idx != sem["semestre_id"]: + sem["trclass"] = "firstsem" # separe les groupes de semestres + cur_idx = sem["semestre_id"] + else: + sem["trclass"] = "" + sem["notes_url"] = scu.NotesURL() + H.append(tmpl % sem) + H.append("
%s
") + return "\n".join(H) + + +def _sem_table_gt(sems, showcodes=False): + """Nouvelle version de la table des semestres + Utilise une datatables. + """ + _style_sems(sems) + columns_ids = ( + "lockimg", + "semestre_id_n", + "modalite", + #'mois_debut', + "dash_mois_fin", + "titre_resp", + "nb_inscrits", + "etapes_apo_str", + "elt_annee_apo", + "elt_sem_apo", + ) + if showcodes: + columns_ids = ("formsemestre_id",) + columns_ids + + html_class = "stripe cell-border compact hover order-column table_leftalign semlist" + if current_user.has_permission(Permission.ScoEditApo): + html_class += " apo_editable" + tab = GenTable( + titles={ + "formsemestre_id": "id", + "semestre_id_n": "S#", + "modalite": "", + "mois_debut": "Début", + "dash_mois_fin": "Année", + "titre_resp": "Semestre", + "nb_inscrits": "N", + "etapes_apo_str": "Étape Apo.", + "elt_annee_apo": "Elt. année Apo.", + "elt_sem_apo": "Elt. sem. Apo.", + }, + columns_ids=columns_ids, + rows=sems, + table_id="semlist", + html_class_ignore_default=True, + html_class=html_class, + html_sortable=True, + html_table_attrs=f""" + data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}" + data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}" + data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}" + """, + html_with_td_classes=True, + preferences=sco_preferences.SemPreferences(), + ) + + return tab + + +def _style_sems(sems): + """ajoute quelques attributs de présentation pour la table""" + for sem in sems: + sem["notes_url"] = scu.NotesURL() + sem["_groupicon_target"] = ( + "%(notes_url)s/formsemestre_status?formsemestre_id=%(formsemestre_id)s" + % sem + ) + sem["_formsemestre_id_class"] = "blacktt" + sem["dash_mois_fin"] = ' %(anneescolaire)s' % sem + sem["_dash_mois_fin_class"] = "datesem" + sem["titre_resp"] = ( + """%(titre_num)s + (%(responsable_name)s)""" + % sem + ) + sem["_css_row_class"] = "css_S%d css_M%s" % ( + sem["semestre_id"], + sem["modalite"], + ) + sem["_semestre_id_class"] = "semestre_id" + sem["_modalite_class"] = "modalite" + if sem["semestre_id"] == -1: + sem["semestre_id_n"] = "" + else: + sem["semestre_id_n"] = sem["semestre_id"] + # pour édition codes Apogée: + sem[ + "_etapes_apo_str_td_attrs" + ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['etapes_apo_str']}" """ + sem[ + "_elt_annee_apo_td_attrs" + ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_annee_apo']}" """ + sem[ + "_elt_sem_apo_td_attrs" + ] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_sem_apo']}" """ + + +def delete_dept(dept_id: int): + """Suppression irréversible d'un département et de tous les objets rattachés""" + assert isinstance(dept_id, int) + + # Un peu complexe, merci JMP :) + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor() + try: + # 1- Create temp tables to store ids + reqs = [ + "create temp table etudids_temp as select id from identite where dept_id = %(dept_id)s", + "create temp table formsemestres_temp as select id from notes_formsemestre where dept_id = %(dept_id)s", + "create temp table moduleimpls_temp as select id from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", + "create temp table formations_temp as select id from notes_formations where dept_id = %(dept_id)s", + "create temp table tags_temp as select id from notes_tags where dept_id = %(dept_id)s", + ] + for r in reqs: + cursor.execute(r, {"dept_id": dept_id}) + + # 2- Delete student-related informations + # ordered list of tables + etud_tables = [ + "notes_notes", + "group_membership", + "admissions", + "billet_absence", + "adresse", + "absences", + "notes_notes_log", + "notes_moduleimpl_inscription", + "itemsuivi", + "notes_appreciations", + "scolar_autorisation_inscription", + "absences_notifications", + "notes_formsemestre_inscription", + "scolar_formsemestre_validation", + "scolar_events", + ] + for table in etud_tables: + cursor.execute( + f"delete from {table} where etudid in (select id from etudids_temp)" + ) + + reqs = [ + "delete from identite where dept_id = %(dept_id)s", + "delete from sco_prefs where dept_id = %(dept_id)s", + "delete from notes_semset_formsemestre where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_evaluation where moduleimpl_id in (select id from moduleimpls_temp)", + "delete from notes_modules_enseignants where moduleimpl_id in (select id from moduleimpls_temp)", + "delete from notes_formsemestre_uecoef where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_formsemestre_ue_computation_expr where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_formsemestre_responsables where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_moduleimpl where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_modules_tags where tag_id in (select id from tags_temp)", + "delete from notes_tags where dept_id = %(dept_id)s", + "delete from notes_modules where formation_id in (select id from formations_temp)", + "delete from notes_matieres where ue_id in (select id from notes_ue where formation_id in (select id from formations_temp))", + "delete from notes_formsemestre_etapes where formsemestre_id in (select id from formsemestres_temp)", + "delete from group_descr where partition_id in (select id from partition where formsemestre_id in (select id from formsemestres_temp))", + "delete from partition where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_formsemestre_custommenu where formsemestre_id in (select id from formsemestres_temp)", + "delete from notes_ue where formation_id in (select id from formations_temp)", + "delete from notes_formsemestre where dept_id = %(dept_id)s", + "delete from scolar_news where dept_id = %(dept_id)s", + "delete from notes_semset where dept_id = %(dept_id)s", + "delete from notes_formations where dept_id = %(dept_id)s", + "delete from departement where id = %(dept_id)s", + "drop table tags_temp", + "drop table formations_temp", + "drop table moduleimpls_temp", + "drop table etudids_temp", + "drop table formsemestres_temp", + ] + for r in reqs: + cursor.execute(r, {"dept_id": dept_id}) + except: + cnx.rollback() + finally: + cnx.commit() + app.clear_scodoc_cache() diff --git a/app/scodoc/sco_etape_bilan.py b/app/scodoc/sco_etape_bilan.py index 5d4904e2..a2dd56bb 100644 --- a/app/scodoc/sco_etape_bilan.py +++ b/app/scodoc/sco_etape_bilan.py @@ -1,773 +1,773 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -""" -# Outil de comparaison Apogée/ScoDoc (J.-M. Place, Jan 2020) - -## fonctionalités - -Le menu 'synchronisation avec Apogée' ne permet pas de traiter facilement les cas -où un même code étape est implementé dans des semestres (au sens ScoDoc) différents. - -La proposition est d'ajouter à la page de description des ensembles de semestres -une section permettant de faire le point sur les cas particuliers. - -Cette section est composée de deux parties: -* Une partie effectif où figurent le nombre d'étudiants selon un répartition par - semestre (en ligne) et par code étape (en colonne). On ajoute également des - colonnes/lignes correspondant à des anomalies (étudiant sans code étape, sans - semestre, avec deux semestres, sans NIP, etc.). - - * La seconde partie présente la liste des étudiants. Il est possible qu'un - même nom figure deux fois dans la liste (si on a pas pu faire la correspondance - entre une inscription apogée et un étudiant d'un semestre, par exemple). - - L'activation d'un des nombres du tableau 'effectifs' restreint l'affichage de - la liste aux étudiants qui contribuent à ce nombre. - -## Réalisation - -Les modifications logicielles portent sur: - -### La création d'une classe sco_etape_bilan.py - -Cette classe compile la totalité des données: - -** Liste des semestres - -** Listes des étapes - -** Liste des étudiants - -** constitution des listes d'anomalies - -Cette classe explore la suite semestres du semset. -Pour chaque semestre, elle recense les étudiants du semestre et -les codes étapes concernés. - -puis tous les codes étapes (toujours en important les étudiants de l'étape -via le portail) - -enfin on dispatch chaque étudiant dans une case - soit ordinaire, soit -correspondant à une anomalie. - -### Modification de sco_etape_apogee_view.py - -Pour insertion de l'affichage ajouté - -### Modification de sco_semset.py - -Affichage proprement dit - -### Modification de scp_formsemestre.py - -Modification/ajout de la méthode sem_in_semestre_scolaire pour permettre -l'inscrition de semestres décalés (S1 en septembre, ...). -Le filtrage s'effctue sur la date et non plus sur la parité du semestre (1-3/2-4). -""" - -import json - -from flask import url_for, g - -from app.scodoc.sco_portal_apogee import get_inscrits_etape -from app import log -from app.scodoc.sco_utils import annee_scolaire_debut -from app.scodoc.gen_tables import GenTable - -COL_PREFIX = "COL_" - -# Les indicatifs sont des marqueurs de classe CSS insérés dans la table étudiant -# et utilisés par le javascript pour permettre un filtrage de la liste étudiants -# sur un 'cas' considéré - -# indicatifs -COL_CUMUL = "C9" -ROW_CUMUL = "R9" - -# Constante d'anomalie -PAS_DE_NIP = "C1" -PAS_D_ETAPE = "C2" -PLUSIEURS_ETAPES = "C3" -PAS_DE_SEMESTRE = "R4" -PLUSIEURS_SEMESTRES = "R5" -NIP_NON_UNIQUE = "U" - -FLAG = { - PAS_DE_NIP: "A", - PAS_D_ETAPE: "B", - PLUSIEURS_ETAPES: "C", - PAS_DE_SEMESTRE: "D", - PLUSIEURS_SEMESTRES: "E", - NIP_NON_UNIQUE: "U", -} - - -class DataEtudiant(object): - """ - Structure de donnée des informations pour un étudiant - """ - - def __init__(self, nip="", etudid=""): - self.nip = nip - self.etudid = etudid - self.data_apogee = None - self.data_scodoc = None - self.etapes = set() # l'ensemble des étapes où il est inscrit - self.semestres = set() # l'ensemble des semestres où il est inscrit - self.tags = set() # les anomalies relevées - self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne) - self.ind_col = "-" - - def add_etape(self, etape): - self.etapes.add(etape) - - def add_semestre(self, semestre): - self.semestres.add(semestre) - - def set_apogee(self, data_apogee): - self.data_apogee = data_apogee - - def set_scodoc(self, data_scodoc): - self.data_scodoc = data_scodoc - - def add_tag(self, tag): - self.tags.add(tag) - - def set_ind_row(self, indicatif): - self.ind_row = indicatif - - def set_ind_col(self, indicatif): - self.ind_col = indicatif - - def get_identity(self): - """ - Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée) - :return: L'identité calculée - """ - if self.data_scodoc is not None: - return self.data_scodoc["nom"] + self.data_scodoc["prenom"] - else: - return self.data_apogee["nom"] + self.data_apogee["prenom"] - - -def help(): - return """ -
Explications sur les tableaux des effectifs et liste des - étudiants -

Le tableau des effectifs présente le nombre d'étudiants selon deux critères:

-
    -
  • En colonne le statut de l'étudiant par rapport à Apogée: -
      -
    • Hors Apogée - (anomalie A): Le NIP de l'étudiant n'est pas connu d'apogée ou - l'étudiant n'a pas de NIP
    • -
    • Pas d'étape (anomalie B): Le NIP de - l'étudiant ne correspond à aucune des étapes connues pour cet ensemble de semestre. Il est - possible qu'il soit inscrit ailleurs (dans une autre ensemble de semestres, un autre département, - une autre composante de l'université) ou en mobilité internationale.
    • -
    • Plusieurs étapes (anomalie C): - Les étudiants inscrits dans plusieurs étapes apogée de l'ensemble de semestres
    • -
    • Un des codes étapes connus (la liste des codes étapes connus est l'union des codes étapes - déclarés pour chaque semestre particpant
    • -
    • Total semestre: cumul des effectifs de la ligne
    • -
    -
  • -
  • En ligne le statut de l'étudiant par rapport à ScoDoc: -
      -
    • Inscription dans un des semestres de l'ensemble
    • -
    • Hors semestre (anomalie D): - L'étudiant, bien qu'enregistré par apogée dans un des codes étapes connus, ne figure dans aucun - des semestres de l'ensemble. On y trouve par exemple les étudiants régulièrement inscrits - mais non présents à la rentrée (donc non enregistrés dans ScoDoc)

      Note: On ne considère - ici que les semestres de l'ensemble (l'inscription de l'étudiant dans un semestre étranger à - l'ensemble actuel n'est pas vérifiée).

    • -
    • Plusieurs semestres (anomalie E): - L'étudiant est enregistré dans plusieurs semestres de l'ensemble.
    • -
    • Total: cumul des effectifs de la colonne
    • -
    -
  • -
  • (anomalie U) On présente également les cas où un même NIP est affecté - à deux dossiers différents (Un dossier d'apogée et un dossier de ScoDoc). Un tel cas compte pour - deux unités dans le tableau des effcetifs et engendre 2 lignes distinctes dans la liste des étudiants
  • -
-
-
""" - - -def entete_liste_etudiant(): - return """ -

Liste des étudiants - -

- """ - - -class EtapeBilan(object): - """ - Structure de donnée représentation l'état global de la comparaison ScoDoc/Apogée - """ - - def __init__(self): - self.semestres = ( - {} - ) # Dictionnaire des formsemestres du semset (formsemestre_id -> semestre) - self.etapes = [] # Liste des étapes apogées du semset (clé_apogée) - # pour les descriptions qui suivents: - # cle_etu = nip si non vide, sinon etudid - # data_etu = { nip, etudid, data_apogee, data_scodoc } - self.etudiants = {} # cle_etu -> data_etu - self.keys_etu = {} # nip -> [ etudid* ] - self.etu_semestre = {} # semestre -> { key_etu } - self.etu_etapes = {} # etape -> { key_etu } - self.repartition = {} # (ind_row, ind_col) -> nombre d étudiants - self.tag_count = {} # nombre d'animalies détectées (par type d'anomalie) - - # on collectionne les indicatifs trouvés pour n'afficher que les indicatifs 'utiles' - self.indicatifs = {} - self.top_row = 0 - self.top_col = 0 - self.all_rows_ind = [PAS_DE_SEMESTRE, PLUSIEURS_SEMESTRES] - self.all_cols_ind = [PAS_DE_NIP, PAS_D_ETAPE, PLUSIEURS_ETAPES] - self.all_rows_str = None - self.all_cols_str = None - self.titres = { - PAS_DE_NIP: "PAS_DE_NIP", - PAS_D_ETAPE: "PAS_D_ETAPE", - PLUSIEURS_ETAPES: "PLUSIEURS_ETAPES", - PAS_DE_SEMESTRE: "PAS_DE_SEMESTRE", - PLUSIEURS_SEMESTRES: "PLUSIEURS_SEMESTRES", - NIP_NON_UNIQUE: "NIP_NON_UNIQUE", - } - - def inc_tag_count(self, tag): - if tag not in self.tag_count: - self.tag_count[tag] = 0 - self.tag_count[tag] += 1 - - def set_indicatif(self, item, as_row): # item = semestre ou key_etape - if as_row: - indicatif = "R" + chr(self.top_row + 97) - self.all_rows_ind.append(indicatif) - self.top_row += 1 - else: - indicatif = "C" + chr(self.top_col + 97) - self.all_cols_ind.append(indicatif) - self.top_col += 1 - self.indicatifs[item] = indicatif - if self.top_row > 26: - log("Dépassement (plus de 26 semestres dans la table diagnostic") - if self.top_col > 26: - log("Dépassement (plus de 26 étapes dans la table diagnostic") - - def add_sem(self, semestre): - """ - Prise en compte d'un semestre dans le bilan. - * ajoute le semestre et les étudiants du semestre - * ajoute les étapes du semestre et (via portail) les étudiants pour ces codes étapes - :param semestre: Le semestre à prendre en compte - :return: None - """ - self.semestres[semestre["formsemestre_id"]] = semestre - # if anneeapogee == None: # année d'inscription par défaut - anneeapogee = str( - annee_scolaire_debut(semestre["annee_debut"], semestre["mois_debut_ord"]) - ) - self.set_indicatif(semestre["formsemestre_id"], True) - for etape in semestre["etapes"]: - self.add_etape(etape.etape_vdi, anneeapogee) - - def add_etape(self, etape_str, anneeapogee): - """ - Prise en compte d'une étape apogée - :param etape_str: La clé de l'étape à prendre en compte - :param anneeapogee: l'année de l'étape à prendre en compte - :return: None - """ - if etape_str != "": - key_etape = etape_to_key(anneeapogee, etape_str) - if key_etape not in self.etapes: - self.etapes.append(key_etape) - self.set_indicatif( - key_etape, False - ) # ajout de la colonne/indicatif supplémentaire - - def compute_key_etu(self, nip, etudid): - """ - Calcul de la clé étudiant: - * Le nip si il existe - * sinon l'identifiant ScoDoc - Tient à jour le dictionnaire key_etu (référentiel des étudiants) - La problèmatique est de gérer toutes les anomalies possibles: - - étudiant sans nip, - - plusieurs étudiants avec le même nip, - - etc. - :param nip: le nip de l'étudiant - :param etudid: l'identifiant ScoDoc - :return: L'identifiant unique de l'étudiant - """ - if nip not in self.keys_etu: - self.keys_etu[nip] = [] - if etudid not in self.keys_etu[nip]: - if etudid is None: - if len(self.keys_etu[nip]) == 1: - etudid = self.keys_etu[nip][0] - else: # nip non trouvé ou utilisé par plusieurs étudiants - self.keys_etu[nip].append(None) - else: - self.keys_etu[nip].append(etudid) - return nip, etudid - - def register_etud_apogee(self, etud, etape): - """ - Enregistrement des données de l'étudiant par rapport à apogée. - L'étudiant peut avoir été déjà enregistré auparavant (par exemple connu par son semestre) - Dans ce cas, on ne met à jour que son association à l'étape apogée - :param etud: les données étudiant - :param etape: l'étape apogée - :return: - """ - nip = etud["nip"] - key_etu = self.compute_key_etu(nip, None) - if key_etu not in self.etudiants: - data = DataEtudiant(nip) - data.set_apogee(etud) - data.add_etape(etape) - self.etudiants[key_etu] = data - else: - self.etudiants[key_etu].set_apogee(etud) - self.etudiants[key_etu].add_etape(etape) - return key_etu - - def register_etud_scodoc(self, etud, semestre): - """ - Enregistrement de l'étudiant par rapport à son semestre - :param etud: Les données de l'étudiant - :param semestre: Le semestre où il est à enregistrer - :return: la clé unique pour cet étudiant - """ - nip = etud["code_nip"] - etudid = etud["etudid"] - key_etu = self.compute_key_etu(nip, etudid) - if key_etu not in self.etudiants: - data = DataEtudiant(nip, etudid) - data.set_scodoc(etud) - data.add_semestre(semestre) - self.etudiants[key_etu] = data - else: - self.etudiants[key_etu].add_semestre(semestre) - return key_etu - - def load_listes(self): - """ - Inventaire complet des étudiants: - * Pour tous les semestres d'abord - * Puis pour toutes les étapes - :return: None - """ - for semestre in self.semestres: - etuds = self.semestres[semestre]["etuds"] - self.etu_semestre[semestre] = set() - for etud in etuds: - key_etu = self.register_etud_scodoc(etud, semestre) - self.etu_semestre[semestre].add(key_etu) - - for key_etape in self.etapes: - anneeapogee, etapestr = key_to_values(key_etape) - self.etu_etapes[key_etape] = set() - for etud in get_inscrits_etape(etapestr, anneeapogee): - key_etu = self.register_etud_apogee(etud, key_etape) - self.etu_etapes[key_etape].add(key_etu) - - def dispatch(self): - """ - Réparti l'ensemble des étudiants selon les lignes (semestres) et les colonnes (étapes). - - :return: None - """ - # Initialisation des cumuls - self.repartition[ROW_CUMUL, COL_CUMUL] = 0 - self.repartition[PAS_DE_SEMESTRE, COL_CUMUL] = 0 - self.repartition[PLUSIEURS_SEMESTRES, COL_CUMUL] = 0 - self.repartition[ROW_CUMUL, PAS_DE_NIP] = 0 - self.repartition[ROW_CUMUL, PAS_D_ETAPE] = 0 - self.repartition[ROW_CUMUL, PLUSIEURS_ETAPES] = 0 - for semestre in self.semestres: - self.repartition[self.indicatifs[semestre], COL_CUMUL] = 0 - for key_etape in self.etapes: - self.repartition[ROW_CUMUL, self.indicatifs[key_etape]] = 0 - - # recherche des nip identiques - for nip in self.keys_etu: - if nip != "": - nbnips = len(self.keys_etu[nip]) - if nbnips > 1: - for i, etudid in enumerate(self.keys_etu[nip]): - data_etu = self.etudiants[nip, etudid] - data_etu.add_tag(NIP_NON_UNIQUE) - data_etu.nip = data_etu.nip + " (%d/%d)" % (i + 1, nbnips) - self.inc_tag_count(NIP_NON_UNIQUE) - for nip in self.keys_etu: - for etudid in self.keys_etu[nip]: - key_etu = (nip, etudid) - data_etu = self.etudiants[key_etu] - ind_col = "-" - ind_row = "-" - - # calcul de la colonne - if len(data_etu.etapes) == 1: - ind_col = self.indicatifs[list(data_etu.etapes)[0]] - elif nip == "": - data_etu.add_tag(FLAG[PAS_DE_NIP]) - ind_col = PAS_DE_NIP - elif len(data_etu.etapes) == 0: - self.etudiants[key_etu].add_tag(FLAG[PAS_D_ETAPE]) - ind_col = PAS_D_ETAPE - if len(data_etu.etapes) > 1: - data_etu.add_tag(FLAG[PLUSIEURS_ETAPES]) - ind_col = PLUSIEURS_ETAPES - - if len(data_etu.semestres) == 1: - ind_row = self.indicatifs[list(data_etu.semestres)[0]] - elif len(data_etu.semestres) > 1: - data_etu.add_tag(FLAG[PLUSIEURS_SEMESTRES]) - ind_row = PLUSIEURS_SEMESTRES - elif len(data_etu.semestres) < 1: - self.etudiants[key_etu].add_tag(FLAG[PAS_DE_SEMESTRE]) - ind_row = PAS_DE_SEMESTRE - - data_etu.set_ind_col(ind_col) - data_etu.set_ind_row(ind_row) - self._inc_count(ind_row, ind_col) - self.inc_tag_count(ind_row) - self.inc_tag_count(ind_col) - - def html_diagnostic(self): - """ - affichage de l'html - :return: Le code html à afficher - """ - self.load_listes() # chargement des données - self.dispatch() # analyse et répartition - # calcul de la liste des colonnes et des lignes de la table des effectifs - self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'" - self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'" - - H = [ - '

Tableau des effectifs

', - self._diagtable(), - self.display_tags(), - entete_liste_etudiant(), - self.table_effectifs(), - help(), - ] - - return "\n".join(H) - - def _inc_count(self, ind_row, ind_col): - if (ind_row, ind_col) not in self.repartition: - self.repartition[ind_row, ind_col] = 0 - self.repartition[ind_row, ind_col] += 1 - self.repartition[ROW_CUMUL, ind_col] += 1 - self.repartition[ind_row, COL_CUMUL] += 1 - self.repartition[ROW_CUMUL, COL_CUMUL] += 1 - - def _get_count(self, ind_row, ind_col): - if (ind_row, ind_col) in self.repartition: - count = self.repartition[ind_row, ind_col] - if count > 1: - comptage = "(%d étudiants)" % count - else: - comptage = "(1 étudiant)" - else: - count = 0 - return "" - - # Ajoute l'appel à la routine javascript de filtrage (apo_semset_maq_status.js - # signature: - # function show_css(elt, all_rows, all_cols, row, col, precision) - # elt: le lien cliqué - # all_rows: la liste de toutes les lignes existantes dans le tableau répartition - # (exemple: ".Rb,.R1,.R2,.R3") - # all_cols: la liste de toutes les colonnes existantes dans le tableau répartition - # (exemple: ".Ca,.C1,.C2,.C3") - # row: la ligne sélectionnée (sélecteur css) (expl: ".R1") - # ; '*' si pas de sélection sur la ligne - # col: la (les) colonnes sélectionnées (sélecteur css) (exple: ".C2") - # ; '*' si pas de sélection sur colonne - # precision: ajout sur le titre (en général, le nombre d'étudiant) - # filtre_row: explicitation du filtre ligne éventuelle - # filtre_col: explicitation du filtre colonne évnetuelle - if ind_row == ROW_CUMUL and ind_col == COL_CUMUL: - javascript = "doFiltrage(%s, %s, '*', '*', '%s', '%s', '%s');" % ( - self.all_rows_str, - self.all_cols_str, - comptage, - "", - "", - ) - elif ind_row == ROW_CUMUL: - javascript = "doFiltrage(%s, %s, '*', '.%s', '%s', '%s', '%s');" % ( - self.all_rows_str, - self.all_cols_str, - ind_col, - comptage, - "", - json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1], - ) - elif ind_col == COL_CUMUL: - javascript = "doFiltrage(%s, %s, '.%s', '*', '%s', '%s', '%s');" % ( - self.all_rows_str, - self.all_cols_str, - ind_row, - " (%d étudiants)" % count, - json.dumps(self.titres[ind_row])[1:-1], - "", - ) - else: - javascript = "doFiltrage(%s, %s, '.%s', '.%s', '%s', '%s', '%s');" % ( - self.all_rows_str, - self.all_cols_str, - ind_row, - ind_col, - comptage, - json.dumps(self.titres[ind_row])[1:-1], - json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1], - ) - return '%d' % (javascript, count) - - def _diagtable(self): - H = [] - - liste_semestres = sorted(self.semestres.keys()) - liste_etapes = [] - for key_etape in self.etapes: - liste_etapes.append(key_etape) - liste_etapes.sort(key=lambda key: etape_to_col(key_etape)) - - col_ids = [] - if PAS_DE_NIP in self.tag_count: - col_ids.append(PAS_DE_NIP) - if PAS_D_ETAPE in self.tag_count: - col_ids.append(PAS_D_ETAPE) - if PLUSIEURS_ETAPES in self.tag_count: - col_ids.append(PLUSIEURS_ETAPES) - self.titres["row_title"] = "Semestre" - self.titres[PAS_DE_NIP] = "Hors Apogée (" + FLAG[PAS_DE_NIP] + ")" - self.titres[PAS_D_ETAPE] = "Pas d'étape (" + FLAG[PAS_D_ETAPE] + ")" - self.titres[PLUSIEURS_ETAPES] = ( - "Plusieurs etapes (" + FLAG[PLUSIEURS_ETAPES] + ")" - ) - for key_etape in liste_etapes: - col_id = self.indicatifs[key_etape] - col_ids.append(col_id) - self.titres[col_id] = "%s
%s" % key_to_values(key_etape) - col_ids.append(COL_CUMUL) - self.titres[COL_CUMUL] = "Total
semestre" - - rows = [] - for semestre in liste_semestres: - ind_row = self.indicatifs[semestre] - self.titres[ind_row] = ( - "%(titre_num)s (%(formsemestre_id)s)" % self.semestres[semestre] - ) - row = { - "row_title": self.link_semestre(semestre), - PAS_DE_NIP: self._get_count(ind_row, PAS_DE_NIP), - PAS_D_ETAPE: self._get_count(ind_row, PAS_D_ETAPE), - PLUSIEURS_ETAPES: self._get_count(ind_row, PLUSIEURS_ETAPES), - COL_CUMUL: self._get_count(ind_row, COL_CUMUL), - "_css_row_class": ind_row, - } - for key_etape in liste_etapes: - ind_col = self.indicatifs[key_etape] - row[ind_col] = self._get_count(ind_row, ind_col) - rows.append(row) - - if PAS_DE_SEMESTRE in self.tag_count: - row = { - "row_title": "Hors semestres (" + FLAG[PAS_DE_SEMESTRE] + ")", - PAS_DE_NIP: "", - PAS_D_ETAPE: "", - PLUSIEURS_ETAPES: "", - COL_CUMUL: self._get_count(PAS_DE_SEMESTRE, COL_CUMUL), - "_css_row_class": PAS_DE_SEMESTRE, - } - for key_etape in liste_etapes: - ind_col = self.indicatifs[key_etape] - row[ind_col] = self._get_count(PAS_DE_SEMESTRE, ind_col) - rows.append(row) - - if PLUSIEURS_SEMESTRES in self.tag_count: - row = { - "row_title": "Plusieurs semestres (" + FLAG[PLUSIEURS_SEMESTRES] + ")", - PAS_DE_NIP: "", - PAS_D_ETAPE: "", - PLUSIEURS_ETAPES: "", - COL_CUMUL: self._get_count(PLUSIEURS_SEMESTRES, COL_CUMUL), - "_css_row_class": PLUSIEURS_SEMESTRES, - } - for key_etape in liste_etapes: - ind_col = self.indicatifs[key_etape] - row[ind_col] = self._get_count(PLUSIEURS_SEMESTRES, ind_col) - rows.append(row) - - row = { - "row_title": "Total", - PAS_DE_NIP: self._get_count(ROW_CUMUL, PAS_DE_NIP), - PAS_D_ETAPE: self._get_count(ROW_CUMUL, PAS_D_ETAPE), - PLUSIEURS_ETAPES: self._get_count(ROW_CUMUL, PLUSIEURS_ETAPES), - COL_CUMUL: self._get_count(ROW_CUMUL, COL_CUMUL), - "_css_row_class": COL_CUMUL, - } - for key_etape in liste_etapes: - ind_col = self.indicatifs[key_etape] - row[ind_col] = self._get_count(ROW_CUMUL, ind_col) - rows.append(row) - - H.append( - GenTable( - rows, - col_ids, - self.titres, - html_class="repartition", - html_with_td_classes=True, - ).gen(format="html") - ) - return "\n".join(H) - - def display_tags(self): - H = [] - if NIP_NON_UNIQUE in self.tag_count: - H.append("

Anomalies

") - javascript = "show_tag(%s, %s, '%s');" % ( - self.all_rows_str, - self.all_cols_str, - NIP_NON_UNIQUE, - ) - H.append( - 'Code(s) nip) partagé(s) par %d étudiants
' - % (javascript, self.tag_count[NIP_NON_UNIQUE]) - ) - return "\n".join(H) - - @staticmethod - def link_etu(etudid, nom): - return '%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - nom, - ) - - def link_semestre(self, semestre, short=False): - if short: - return ( - '%(' - "formsemestre_id)s " % self.semestres[semestre] - ) - else: - return ( - '%(titre_num)s' - " %(mois_debut)s - %(mois_fin)s)" % self.semestres[semestre] - ) - - def table_effectifs(self): - H = [] - - col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"] - titles = { - "tag": "Etat", - "etudiant": "Nom", - "prenom": "Prenom", - "nip": "code nip", - "semestre": "semestre", - "annee": "année", - "apogee": "etape", - } - rows = [] - - for data_etu in sorted( - list(self.etudiants.values()), key=lambda etu: etu.get_identity() - ): - nip = data_etu.nip - etudid = data_etu.etudid - if data_etu.data_scodoc is None: - nom = data_etu.data_apogee["nom"] - prenom = data_etu.data_apogee["prenom"] - link = nom - else: - nom = data_etu.data_scodoc["nom"] - prenom = data_etu.data_scodoc["prenom"] - link = self.link_etu(etudid, nom) - tag = ", ".join([tag for tag in sorted(data_etu.tags)]) - semestre = "
".join( - [self.link_semestre(sem, True) for sem in data_etu.semestres] - ) - annees = "
".join([etape[0] for etape in data_etu.etapes]) - etapes = "
".join([etape[1] for etape in data_etu.etapes]) - classe = data_etu.ind_row + data_etu.ind_col - if NIP_NON_UNIQUE in data_etu.tags: - classe += " " + NIP_NON_UNIQUE - row = { - "tag": tag, - "etudiant": link, - "prenom": prenom.capitalize(), - "nip": nip, - "semestre": semestre, - "annee": annees, - "apogee": etapes, - "_css_row_class": classe, - } - rows.append(row) - - H.append( - GenTable( - rows, - col_ids, - titles, - table_id="detail", - html_class="table_leftalign", - html_sortable=True, - ).gen(format="html") - ) - return "\n".join(H) - - -def etape_to_key(anneeapogee, etapestr): - return anneeapogee, etapestr - - -def key_to_values(key_etape): - return key_etape - - -def etape_to_col(key_etape): - return "%s@%s" % key_etape +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +""" +# Outil de comparaison Apogée/ScoDoc (J.-M. Place, Jan 2020) + +## fonctionalités + +Le menu 'synchronisation avec Apogée' ne permet pas de traiter facilement les cas +où un même code étape est implementé dans des semestres (au sens ScoDoc) différents. + +La proposition est d'ajouter à la page de description des ensembles de semestres +une section permettant de faire le point sur les cas particuliers. + +Cette section est composée de deux parties: +* Une partie effectif où figurent le nombre d'étudiants selon un répartition par + semestre (en ligne) et par code étape (en colonne). On ajoute également des + colonnes/lignes correspondant à des anomalies (étudiant sans code étape, sans + semestre, avec deux semestres, sans NIP, etc.). + + * La seconde partie présente la liste des étudiants. Il est possible qu'un + même nom figure deux fois dans la liste (si on a pas pu faire la correspondance + entre une inscription apogée et un étudiant d'un semestre, par exemple). + + L'activation d'un des nombres du tableau 'effectifs' restreint l'affichage de + la liste aux étudiants qui contribuent à ce nombre. + +## Réalisation + +Les modifications logicielles portent sur: + +### La création d'une classe sco_etape_bilan.py + +Cette classe compile la totalité des données: + +** Liste des semestres + +** Listes des étapes + +** Liste des étudiants + +** constitution des listes d'anomalies + +Cette classe explore la suite semestres du semset. +Pour chaque semestre, elle recense les étudiants du semestre et +les codes étapes concernés. + +puis tous les codes étapes (toujours en important les étudiants de l'étape +via le portail) + +enfin on dispatch chaque étudiant dans une case - soit ordinaire, soit +correspondant à une anomalie. + +### Modification de sco_etape_apogee_view.py + +Pour insertion de l'affichage ajouté + +### Modification de sco_semset.py + +Affichage proprement dit + +### Modification de scp_formsemestre.py + +Modification/ajout de la méthode sem_in_semestre_scolaire pour permettre +l'inscrition de semestres décalés (S1 en septembre, ...). +Le filtrage s'effctue sur la date et non plus sur la parité du semestre (1-3/2-4). +""" + +import json + +from flask import url_for, g + +from app.scodoc.sco_portal_apogee import get_inscrits_etape +from app import log +from app.scodoc.sco_utils import annee_scolaire_debut +from app.scodoc.gen_tables import GenTable + +COL_PREFIX = "COL_" + +# Les indicatifs sont des marqueurs de classe CSS insérés dans la table étudiant +# et utilisés par le javascript pour permettre un filtrage de la liste étudiants +# sur un 'cas' considéré + +# indicatifs +COL_CUMUL = "C9" +ROW_CUMUL = "R9" + +# Constante d'anomalie +PAS_DE_NIP = "C1" +PAS_D_ETAPE = "C2" +PLUSIEURS_ETAPES = "C3" +PAS_DE_SEMESTRE = "R4" +PLUSIEURS_SEMESTRES = "R5" +NIP_NON_UNIQUE = "U" + +FLAG = { + PAS_DE_NIP: "A", + PAS_D_ETAPE: "B", + PLUSIEURS_ETAPES: "C", + PAS_DE_SEMESTRE: "D", + PLUSIEURS_SEMESTRES: "E", + NIP_NON_UNIQUE: "U", +} + + +class DataEtudiant(object): + """ + Structure de donnée des informations pour un étudiant + """ + + def __init__(self, nip="", etudid=""): + self.nip = nip + self.etudid = etudid + self.data_apogee = None + self.data_scodoc = None + self.etapes = set() # l'ensemble des étapes où il est inscrit + self.semestres = set() # l'ensemble des semestres où il est inscrit + self.tags = set() # les anomalies relevées + self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne) + self.ind_col = "-" + + def add_etape(self, etape): + self.etapes.add(etape) + + def add_semestre(self, semestre): + self.semestres.add(semestre) + + def set_apogee(self, data_apogee): + self.data_apogee = data_apogee + + def set_scodoc(self, data_scodoc): + self.data_scodoc = data_scodoc + + def add_tag(self, tag): + self.tags.add(tag) + + def set_ind_row(self, indicatif): + self.ind_row = indicatif + + def set_ind_col(self, indicatif): + self.ind_col = indicatif + + def get_identity(self): + """ + Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée) + :return: L'identité calculée + """ + if self.data_scodoc is not None: + return self.data_scodoc["nom"] + self.data_scodoc["prenom"] + else: + return self.data_apogee["nom"] + self.data_apogee["prenom"] + + +def help(): + return """ +
Explications sur les tableaux des effectifs et liste des + étudiants +

Le tableau des effectifs présente le nombre d'étudiants selon deux critères:

+
    +
  • En colonne le statut de l'étudiant par rapport à Apogée: +
      +
    • Hors Apogée + (anomalie A): Le NIP de l'étudiant n'est pas connu d'apogée ou + l'étudiant n'a pas de NIP
    • +
    • Pas d'étape (anomalie B): Le NIP de + l'étudiant ne correspond à aucune des étapes connues pour cet ensemble de semestre. Il est + possible qu'il soit inscrit ailleurs (dans une autre ensemble de semestres, un autre département, + une autre composante de l'université) ou en mobilité internationale.
    • +
    • Plusieurs étapes (anomalie C): + Les étudiants inscrits dans plusieurs étapes apogée de l'ensemble de semestres
    • +
    • Un des codes étapes connus (la liste des codes étapes connus est l'union des codes étapes + déclarés pour chaque semestre particpant
    • +
    • Total semestre: cumul des effectifs de la ligne
    • +
    +
  • +
  • En ligne le statut de l'étudiant par rapport à ScoDoc: +
      +
    • Inscription dans un des semestres de l'ensemble
    • +
    • Hors semestre (anomalie D): + L'étudiant, bien qu'enregistré par apogée dans un des codes étapes connus, ne figure dans aucun + des semestres de l'ensemble. On y trouve par exemple les étudiants régulièrement inscrits + mais non présents à la rentrée (donc non enregistrés dans ScoDoc)

      Note: On ne considère + ici que les semestres de l'ensemble (l'inscription de l'étudiant dans un semestre étranger à + l'ensemble actuel n'est pas vérifiée).

    • +
    • Plusieurs semestres (anomalie E): + L'étudiant est enregistré dans plusieurs semestres de l'ensemble.
    • +
    • Total: cumul des effectifs de la colonne
    • +
    +
  • +
  • (anomalie U) On présente également les cas où un même NIP est affecté + à deux dossiers différents (Un dossier d'apogée et un dossier de ScoDoc). Un tel cas compte pour + deux unités dans le tableau des effcetifs et engendre 2 lignes distinctes dans la liste des étudiants
  • +
+
+
""" + + +def entete_liste_etudiant(): + return """ +

Liste des étudiants +
    +
  • Pas de filtrage: Cliquez sur un des nombres du tableau ci-dessus pour + n'afficher que les étudiants correspondants
  • + + +
+

+ """ + + +class EtapeBilan(object): + """ + Structure de donnée représentation l'état global de la comparaison ScoDoc/Apogée + """ + + def __init__(self): + self.semestres = ( + {} + ) # Dictionnaire des formsemestres du semset (formsemestre_id -> semestre) + self.etapes = [] # Liste des étapes apogées du semset (clé_apogée) + # pour les descriptions qui suivents: + # cle_etu = nip si non vide, sinon etudid + # data_etu = { nip, etudid, data_apogee, data_scodoc } + self.etudiants = {} # cle_etu -> data_etu + self.keys_etu = {} # nip -> [ etudid* ] + self.etu_semestre = {} # semestre -> { key_etu } + self.etu_etapes = {} # etape -> { key_etu } + self.repartition = {} # (ind_row, ind_col) -> nombre d étudiants + self.tag_count = {} # nombre d'animalies détectées (par type d'anomalie) + + # on collectionne les indicatifs trouvés pour n'afficher que les indicatifs 'utiles' + self.indicatifs = {} + self.top_row = 0 + self.top_col = 0 + self.all_rows_ind = [PAS_DE_SEMESTRE, PLUSIEURS_SEMESTRES] + self.all_cols_ind = [PAS_DE_NIP, PAS_D_ETAPE, PLUSIEURS_ETAPES] + self.all_rows_str = None + self.all_cols_str = None + self.titres = { + PAS_DE_NIP: "PAS_DE_NIP", + PAS_D_ETAPE: "PAS_D_ETAPE", + PLUSIEURS_ETAPES: "PLUSIEURS_ETAPES", + PAS_DE_SEMESTRE: "PAS_DE_SEMESTRE", + PLUSIEURS_SEMESTRES: "PLUSIEURS_SEMESTRES", + NIP_NON_UNIQUE: "NIP_NON_UNIQUE", + } + + def inc_tag_count(self, tag): + if tag not in self.tag_count: + self.tag_count[tag] = 0 + self.tag_count[tag] += 1 + + def set_indicatif(self, item, as_row): # item = semestre ou key_etape + if as_row: + indicatif = "R" + chr(self.top_row + 97) + self.all_rows_ind.append(indicatif) + self.top_row += 1 + else: + indicatif = "C" + chr(self.top_col + 97) + self.all_cols_ind.append(indicatif) + self.top_col += 1 + self.indicatifs[item] = indicatif + if self.top_row > 26: + log("Dépassement (plus de 26 semestres dans la table diagnostic") + if self.top_col > 26: + log("Dépassement (plus de 26 étapes dans la table diagnostic") + + def add_sem(self, semestre): + """ + Prise en compte d'un semestre dans le bilan. + * ajoute le semestre et les étudiants du semestre + * ajoute les étapes du semestre et (via portail) les étudiants pour ces codes étapes + :param semestre: Le semestre à prendre en compte + :return: None + """ + self.semestres[semestre["formsemestre_id"]] = semestre + # if anneeapogee == None: # année d'inscription par défaut + anneeapogee = str( + annee_scolaire_debut(semestre["annee_debut"], semestre["mois_debut_ord"]) + ) + self.set_indicatif(semestre["formsemestre_id"], True) + for etape in semestre["etapes"]: + self.add_etape(etape.etape_vdi, anneeapogee) + + def add_etape(self, etape_str, anneeapogee): + """ + Prise en compte d'une étape apogée + :param etape_str: La clé de l'étape à prendre en compte + :param anneeapogee: l'année de l'étape à prendre en compte + :return: None + """ + if etape_str != "": + key_etape = etape_to_key(anneeapogee, etape_str) + if key_etape not in self.etapes: + self.etapes.append(key_etape) + self.set_indicatif( + key_etape, False + ) # ajout de la colonne/indicatif supplémentaire + + def compute_key_etu(self, nip, etudid): + """ + Calcul de la clé étudiant: + * Le nip si il existe + * sinon l'identifiant ScoDoc + Tient à jour le dictionnaire key_etu (référentiel des étudiants) + La problèmatique est de gérer toutes les anomalies possibles: + - étudiant sans nip, + - plusieurs étudiants avec le même nip, + - etc. + :param nip: le nip de l'étudiant + :param etudid: l'identifiant ScoDoc + :return: L'identifiant unique de l'étudiant + """ + if nip not in self.keys_etu: + self.keys_etu[nip] = [] + if etudid not in self.keys_etu[nip]: + if etudid is None: + if len(self.keys_etu[nip]) == 1: + etudid = self.keys_etu[nip][0] + else: # nip non trouvé ou utilisé par plusieurs étudiants + self.keys_etu[nip].append(None) + else: + self.keys_etu[nip].append(etudid) + return nip, etudid + + def register_etud_apogee(self, etud, etape): + """ + Enregistrement des données de l'étudiant par rapport à apogée. + L'étudiant peut avoir été déjà enregistré auparavant (par exemple connu par son semestre) + Dans ce cas, on ne met à jour que son association à l'étape apogée + :param etud: les données étudiant + :param etape: l'étape apogée + :return: + """ + nip = etud["nip"] + key_etu = self.compute_key_etu(nip, None) + if key_etu not in self.etudiants: + data = DataEtudiant(nip) + data.set_apogee(etud) + data.add_etape(etape) + self.etudiants[key_etu] = data + else: + self.etudiants[key_etu].set_apogee(etud) + self.etudiants[key_etu].add_etape(etape) + return key_etu + + def register_etud_scodoc(self, etud, semestre): + """ + Enregistrement de l'étudiant par rapport à son semestre + :param etud: Les données de l'étudiant + :param semestre: Le semestre où il est à enregistrer + :return: la clé unique pour cet étudiant + """ + nip = etud["code_nip"] + etudid = etud["etudid"] + key_etu = self.compute_key_etu(nip, etudid) + if key_etu not in self.etudiants: + data = DataEtudiant(nip, etudid) + data.set_scodoc(etud) + data.add_semestre(semestre) + self.etudiants[key_etu] = data + else: + self.etudiants[key_etu].add_semestre(semestre) + return key_etu + + def load_listes(self): + """ + Inventaire complet des étudiants: + * Pour tous les semestres d'abord + * Puis pour toutes les étapes + :return: None + """ + for semestre in self.semestres: + etuds = self.semestres[semestre]["etuds"] + self.etu_semestre[semestre] = set() + for etud in etuds: + key_etu = self.register_etud_scodoc(etud, semestre) + self.etu_semestre[semestre].add(key_etu) + + for key_etape in self.etapes: + anneeapogee, etapestr = key_to_values(key_etape) + self.etu_etapes[key_etape] = set() + for etud in get_inscrits_etape(etapestr, anneeapogee): + key_etu = self.register_etud_apogee(etud, key_etape) + self.etu_etapes[key_etape].add(key_etu) + + def dispatch(self): + """ + Réparti l'ensemble des étudiants selon les lignes (semestres) et les colonnes (étapes). + + :return: None + """ + # Initialisation des cumuls + self.repartition[ROW_CUMUL, COL_CUMUL] = 0 + self.repartition[PAS_DE_SEMESTRE, COL_CUMUL] = 0 + self.repartition[PLUSIEURS_SEMESTRES, COL_CUMUL] = 0 + self.repartition[ROW_CUMUL, PAS_DE_NIP] = 0 + self.repartition[ROW_CUMUL, PAS_D_ETAPE] = 0 + self.repartition[ROW_CUMUL, PLUSIEURS_ETAPES] = 0 + for semestre in self.semestres: + self.repartition[self.indicatifs[semestre], COL_CUMUL] = 0 + for key_etape in self.etapes: + self.repartition[ROW_CUMUL, self.indicatifs[key_etape]] = 0 + + # recherche des nip identiques + for nip in self.keys_etu: + if nip != "": + nbnips = len(self.keys_etu[nip]) + if nbnips > 1: + for i, etudid in enumerate(self.keys_etu[nip]): + data_etu = self.etudiants[nip, etudid] + data_etu.add_tag(NIP_NON_UNIQUE) + data_etu.nip = data_etu.nip + " (%d/%d)" % (i + 1, nbnips) + self.inc_tag_count(NIP_NON_UNIQUE) + for nip in self.keys_etu: + for etudid in self.keys_etu[nip]: + key_etu = (nip, etudid) + data_etu = self.etudiants[key_etu] + ind_col = "-" + ind_row = "-" + + # calcul de la colonne + if len(data_etu.etapes) == 1: + ind_col = self.indicatifs[list(data_etu.etapes)[0]] + elif nip == "": + data_etu.add_tag(FLAG[PAS_DE_NIP]) + ind_col = PAS_DE_NIP + elif len(data_etu.etapes) == 0: + self.etudiants[key_etu].add_tag(FLAG[PAS_D_ETAPE]) + ind_col = PAS_D_ETAPE + if len(data_etu.etapes) > 1: + data_etu.add_tag(FLAG[PLUSIEURS_ETAPES]) + ind_col = PLUSIEURS_ETAPES + + if len(data_etu.semestres) == 1: + ind_row = self.indicatifs[list(data_etu.semestres)[0]] + elif len(data_etu.semestres) > 1: + data_etu.add_tag(FLAG[PLUSIEURS_SEMESTRES]) + ind_row = PLUSIEURS_SEMESTRES + elif len(data_etu.semestres) < 1: + self.etudiants[key_etu].add_tag(FLAG[PAS_DE_SEMESTRE]) + ind_row = PAS_DE_SEMESTRE + + data_etu.set_ind_col(ind_col) + data_etu.set_ind_row(ind_row) + self._inc_count(ind_row, ind_col) + self.inc_tag_count(ind_row) + self.inc_tag_count(ind_col) + + def html_diagnostic(self): + """ + affichage de l'html + :return: Le code html à afficher + """ + self.load_listes() # chargement des données + self.dispatch() # analyse et répartition + # calcul de la liste des colonnes et des lignes de la table des effectifs + self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'" + self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'" + + H = [ + '

Tableau des effectifs

', + self._diagtable(), + self.display_tags(), + entete_liste_etudiant(), + self.table_effectifs(), + help(), + ] + + return "\n".join(H) + + def _inc_count(self, ind_row, ind_col): + if (ind_row, ind_col) not in self.repartition: + self.repartition[ind_row, ind_col] = 0 + self.repartition[ind_row, ind_col] += 1 + self.repartition[ROW_CUMUL, ind_col] += 1 + self.repartition[ind_row, COL_CUMUL] += 1 + self.repartition[ROW_CUMUL, COL_CUMUL] += 1 + + def _get_count(self, ind_row, ind_col): + if (ind_row, ind_col) in self.repartition: + count = self.repartition[ind_row, ind_col] + if count > 1: + comptage = "(%d étudiants)" % count + else: + comptage = "(1 étudiant)" + else: + count = 0 + return "" + + # Ajoute l'appel à la routine javascript de filtrage (apo_semset_maq_status.js + # signature: + # function show_css(elt, all_rows, all_cols, row, col, precision) + # elt: le lien cliqué + # all_rows: la liste de toutes les lignes existantes dans le tableau répartition + # (exemple: ".Rb,.R1,.R2,.R3") + # all_cols: la liste de toutes les colonnes existantes dans le tableau répartition + # (exemple: ".Ca,.C1,.C2,.C3") + # row: la ligne sélectionnée (sélecteur css) (expl: ".R1") + # ; '*' si pas de sélection sur la ligne + # col: la (les) colonnes sélectionnées (sélecteur css) (exple: ".C2") + # ; '*' si pas de sélection sur colonne + # precision: ajout sur le titre (en général, le nombre d'étudiant) + # filtre_row: explicitation du filtre ligne éventuelle + # filtre_col: explicitation du filtre colonne évnetuelle + if ind_row == ROW_CUMUL and ind_col == COL_CUMUL: + javascript = "doFiltrage(%s, %s, '*', '*', '%s', '%s', '%s');" % ( + self.all_rows_str, + self.all_cols_str, + comptage, + "", + "", + ) + elif ind_row == ROW_CUMUL: + javascript = "doFiltrage(%s, %s, '*', '.%s', '%s', '%s', '%s');" % ( + self.all_rows_str, + self.all_cols_str, + ind_col, + comptage, + "", + json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1], + ) + elif ind_col == COL_CUMUL: + javascript = "doFiltrage(%s, %s, '.%s', '*', '%s', '%s', '%s');" % ( + self.all_rows_str, + self.all_cols_str, + ind_row, + " (%d étudiants)" % count, + json.dumps(self.titres[ind_row])[1:-1], + "", + ) + else: + javascript = "doFiltrage(%s, %s, '.%s', '.%s', '%s', '%s', '%s');" % ( + self.all_rows_str, + self.all_cols_str, + ind_row, + ind_col, + comptage, + json.dumps(self.titres[ind_row])[1:-1], + json.dumps(self.titres[ind_col].replace("
", " / "))[1:-1], + ) + return '%d' % (javascript, count) + + def _diagtable(self): + H = [] + + liste_semestres = sorted(self.semestres.keys()) + liste_etapes = [] + for key_etape in self.etapes: + liste_etapes.append(key_etape) + liste_etapes.sort(key=lambda key: etape_to_col(key_etape)) + + col_ids = [] + if PAS_DE_NIP in self.tag_count: + col_ids.append(PAS_DE_NIP) + if PAS_D_ETAPE in self.tag_count: + col_ids.append(PAS_D_ETAPE) + if PLUSIEURS_ETAPES in self.tag_count: + col_ids.append(PLUSIEURS_ETAPES) + self.titres["row_title"] = "Semestre" + self.titres[PAS_DE_NIP] = "Hors Apogée (" + FLAG[PAS_DE_NIP] + ")" + self.titres[PAS_D_ETAPE] = "Pas d'étape (" + FLAG[PAS_D_ETAPE] + ")" + self.titres[PLUSIEURS_ETAPES] = ( + "Plusieurs etapes (" + FLAG[PLUSIEURS_ETAPES] + ")" + ) + for key_etape in liste_etapes: + col_id = self.indicatifs[key_etape] + col_ids.append(col_id) + self.titres[col_id] = "%s
%s" % key_to_values(key_etape) + col_ids.append(COL_CUMUL) + self.titres[COL_CUMUL] = "Total
semestre" + + rows = [] + for semestre in liste_semestres: + ind_row = self.indicatifs[semestre] + self.titres[ind_row] = ( + "%(titre_num)s (%(formsemestre_id)s)" % self.semestres[semestre] + ) + row = { + "row_title": self.link_semestre(semestre), + PAS_DE_NIP: self._get_count(ind_row, PAS_DE_NIP), + PAS_D_ETAPE: self._get_count(ind_row, PAS_D_ETAPE), + PLUSIEURS_ETAPES: self._get_count(ind_row, PLUSIEURS_ETAPES), + COL_CUMUL: self._get_count(ind_row, COL_CUMUL), + "_css_row_class": ind_row, + } + for key_etape in liste_etapes: + ind_col = self.indicatifs[key_etape] + row[ind_col] = self._get_count(ind_row, ind_col) + rows.append(row) + + if PAS_DE_SEMESTRE in self.tag_count: + row = { + "row_title": "Hors semestres (" + FLAG[PAS_DE_SEMESTRE] + ")", + PAS_DE_NIP: "", + PAS_D_ETAPE: "", + PLUSIEURS_ETAPES: "", + COL_CUMUL: self._get_count(PAS_DE_SEMESTRE, COL_CUMUL), + "_css_row_class": PAS_DE_SEMESTRE, + } + for key_etape in liste_etapes: + ind_col = self.indicatifs[key_etape] + row[ind_col] = self._get_count(PAS_DE_SEMESTRE, ind_col) + rows.append(row) + + if PLUSIEURS_SEMESTRES in self.tag_count: + row = { + "row_title": "Plusieurs semestres (" + FLAG[PLUSIEURS_SEMESTRES] + ")", + PAS_DE_NIP: "", + PAS_D_ETAPE: "", + PLUSIEURS_ETAPES: "", + COL_CUMUL: self._get_count(PLUSIEURS_SEMESTRES, COL_CUMUL), + "_css_row_class": PLUSIEURS_SEMESTRES, + } + for key_etape in liste_etapes: + ind_col = self.indicatifs[key_etape] + row[ind_col] = self._get_count(PLUSIEURS_SEMESTRES, ind_col) + rows.append(row) + + row = { + "row_title": "Total", + PAS_DE_NIP: self._get_count(ROW_CUMUL, PAS_DE_NIP), + PAS_D_ETAPE: self._get_count(ROW_CUMUL, PAS_D_ETAPE), + PLUSIEURS_ETAPES: self._get_count(ROW_CUMUL, PLUSIEURS_ETAPES), + COL_CUMUL: self._get_count(ROW_CUMUL, COL_CUMUL), + "_css_row_class": COL_CUMUL, + } + for key_etape in liste_etapes: + ind_col = self.indicatifs[key_etape] + row[ind_col] = self._get_count(ROW_CUMUL, ind_col) + rows.append(row) + + H.append( + GenTable( + rows, + col_ids, + self.titres, + html_class="repartition", + html_with_td_classes=True, + ).gen(format="html") + ) + return "\n".join(H) + + def display_tags(self): + H = [] + if NIP_NON_UNIQUE in self.tag_count: + H.append("

Anomalies

") + javascript = "show_tag(%s, %s, '%s');" % ( + self.all_rows_str, + self.all_cols_str, + NIP_NON_UNIQUE, + ) + H.append( + 'Code(s) nip) partagé(s) par %d étudiants
' + % (javascript, self.tag_count[NIP_NON_UNIQUE]) + ) + return "\n".join(H) + + @staticmethod + def link_etu(etudid, nom): + return '%s' % ( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + nom, + ) + + def link_semestre(self, semestre, short=False): + if short: + return ( + '%(' + "formsemestre_id)s " % self.semestres[semestre] + ) + else: + return ( + '%(titre_num)s' + " %(mois_debut)s - %(mois_fin)s)" % self.semestres[semestre] + ) + + def table_effectifs(self): + H = [] + + col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"] + titles = { + "tag": "Etat", + "etudiant": "Nom", + "prenom": "Prenom", + "nip": "code nip", + "semestre": "semestre", + "annee": "année", + "apogee": "etape", + } + rows = [] + + for data_etu in sorted( + list(self.etudiants.values()), key=lambda etu: etu.get_identity() + ): + nip = data_etu.nip + etudid = data_etu.etudid + if data_etu.data_scodoc is None: + nom = data_etu.data_apogee["nom"] + prenom = data_etu.data_apogee["prenom"] + link = nom + else: + nom = data_etu.data_scodoc["nom"] + prenom = data_etu.data_scodoc["prenom"] + link = self.link_etu(etudid, nom) + tag = ", ".join([tag for tag in sorted(data_etu.tags)]) + semestre = "
".join( + [self.link_semestre(sem, True) for sem in data_etu.semestres] + ) + annees = "
".join([etape[0] for etape in data_etu.etapes]) + etapes = "
".join([etape[1] for etape in data_etu.etapes]) + classe = data_etu.ind_row + data_etu.ind_col + if NIP_NON_UNIQUE in data_etu.tags: + classe += " " + NIP_NON_UNIQUE + row = { + "tag": tag, + "etudiant": link, + "prenom": prenom.capitalize(), + "nip": nip, + "semestre": semestre, + "annee": annees, + "apogee": etapes, + "_css_row_class": classe, + } + rows.append(row) + + H.append( + GenTable( + rows, + col_ids, + titles, + table_id="detail", + html_class="table_leftalign", + html_sortable=True, + ).gen(format="html") + ) + return "\n".join(H) + + +def etape_to_key(anneeapogee, etapestr): + return anneeapogee, etapestr + + +def key_to_values(key_etape): + return key_etape + + +def etape_to_col(key_etape): + return "%s@%s" % key_etape diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index bb869d5a..632f5b72 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -907,7 +907,7 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) if etud["villelycee"]: etud["ilycee"] += " (%s)" % etud.get("villelycee", "") - etud["ilycee"] += "
" + etud["ilycee"] += "
" else: if etud.get("codelycee"): etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) diff --git a/app/scodoc/sco_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py index 5779892d..21e10f53 100644 --- a/app/scodoc/sco_evaluation_check_abs.py +++ b/app/scodoc/sco_evaluation_check_abs.py @@ -1,257 +1,257 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Vérification des absences à une évaluation -""" -from flask import url_for, g - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_etud -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl - -# matin et/ou après-midi ? -def _eval_demijournee(E): - "1 si matin, 0 si apres midi, 2 si toute la journee" - am, pm = False, False - if E["heure_debut"] < "13:00": - am = True - if E["heure_fin"] > "13:00": - pm = True - if am and pm: - demijournee = 2 - elif am: - demijournee = 1 - else: - demijournee = 0 - pm = True - return am, pm, demijournee - - -def evaluation_check_absences(evaluation_id): - """Vérifie les absences au moment de cette évaluation. - Cas incohérents que l'on peut rencontrer pour chaque étudiant: - note et absent - ABS et pas noté absent - ABS et absent justifié - EXC et pas noté absent - EXC et pas justifie - Ramene 3 listes d'etudid - """ - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - if not E["jour"]: - return [], [], [], [], [] # evaluation sans date - - am, pm, demijournee = _eval_demijournee(E) - - # Liste les absences à ce moment: - A = sco_abs.list_abs_jour(ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm) - As = set([x["etudid"] for x in A]) # ensemble des etudiants absents - NJ = sco_abs.list_abs_non_just_jour(ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm) - NJs = set([x["etudid"] for x in NJ]) # ensemble des etudiants absents non justifies - Just = sco_abs.list_abs_jour( - ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm, is_abs=None, is_just=True - ) - Justs = set([x["etudid"] for x in Just]) # ensemble des etudiants avec justif - - # Les notes: - notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) - ValButAbs = [] # une note mais noté absent - AbsNonSignalee = [] # note ABS mais pas noté absent - ExcNonSignalee = [] # note EXC mais pas noté absent - ExcNonJust = [] # note EXC mais absent non justifie - AbsButExc = [] # note ABS mais justifié - for etudid, _ in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, getallstudents=True - ): - if etudid in notes_db: - val = notes_db[etudid]["value"] - if ( - val != None and val != scu.NOTES_NEUTRALISE and val != scu.NOTES_ATTENTE - ) and etudid in As: - # note valide et absent - ValButAbs.append(etudid) - if val is None and not etudid in As: - # absent mais pas signale comme tel - AbsNonSignalee.append(etudid) - if val == scu.NOTES_NEUTRALISE and not etudid in As: - # Neutralisé mais pas signale absent - ExcNonSignalee.append(etudid) - if val == scu.NOTES_NEUTRALISE and etudid in NJs: - # EXC mais pas justifié - ExcNonJust.append(etudid) - if val is None and etudid in Justs: - # ABS mais justificatif - AbsButExc.append(etudid) - - return ValButAbs, AbsNonSignalee, ExcNonSignalee, ExcNonJust, AbsButExc - - -def evaluation_check_absences_html(evaluation_id, with_header=True, show_ok=True): - """Affiche état vérification absences d'une évaluation""" - - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - am, pm, demijournee = _eval_demijournee(E) - - ( - ValButAbs, - AbsNonSignalee, - ExcNonSignalee, - ExcNonJust, - AbsButExc, - ) = evaluation_check_absences(evaluation_id) - - if with_header: - H = [ - html_sco_header.html_sem_header("Vérification absences à l'évaluation"), - sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), - """

Vérification de la cohérence entre les notes saisies et les absences signalées.

""", - ] - else: - # pas de header, mais un titre - H = [ - """

%s du %s """ - % (E["description"], E["jour"]) - ] - if ( - not ValButAbs - and not AbsNonSignalee - and not ExcNonSignalee - and not ExcNonJust - ): - H.append(': ok') - H.append("

") - - def etudlist(etudids, linkabs=False): - H.append("") - - if ValButAbs or show_ok: - H.append( - "

Etudiants ayant une note alors qu'ils sont signalés absents:

" - ) - etudlist(ValButAbs) - - if AbsNonSignalee or show_ok: - H.append( - """

Etudiants avec note "ABS" alors qu'ils ne sont pas signalés absents:

""" - ) - etudlist(AbsNonSignalee, linkabs=True) - - if ExcNonSignalee or show_ok: - H.append( - """

Etudiants avec note "EXC" alors qu'ils ne sont pas signalés absents:

""" - ) - etudlist(ExcNonSignalee) - - if ExcNonJust or show_ok: - H.append( - """

Etudiants avec note "EXC" alors qu'ils sont absents non justifiés:

""" - ) - etudlist(ExcNonJust) - - if AbsButExc or show_ok: - H.append( - """

Etudiants avec note "ABS" alors qu'ils ont une justification:

""" - ) - etudlist(AbsButExc) - - if with_header: - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def formsemestre_check_absences_html(formsemestre_id): - """Affiche etat verification absences pour toutes les evaluations du semestre !""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - H = [ - html_sco_header.html_sem_header( - "Vérification absences aux évaluations de ce semestre", - ), - """

Vérification de la cohérence entre les notes saisies et les absences signalées. - Sont listés tous les modules avec des évaluations.
Aucune action n'est effectuée: - il vous appartient de corriger les erreurs détectées si vous le jugez nécessaire. -

""", - ] - # Modules, dans l'ordre - Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) - for M in Mlist: - evals = sco_evaluation_db.do_evaluation_list( - {"moduleimpl_id": M["moduleimpl_id"]} - ) - if evals: - H.append( - '

%s: %s

' - % ( - M["moduleimpl_id"], - M["module"]["code"] or "", - M["module"]["abbrev"] or "", - ) - ) - for E in evals: - H.append( - evaluation_check_absences_html( - E["evaluation_id"], - with_header=False, - show_ok=False, - ) - ) - if evals: - H.append("
") - H.append(html_sco_header.sco_footer()) - return "\n".join(H) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Vérification des absences à une évaluation +""" +from flask import url_for, g + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_etud +from app.scodoc import sco_evaluations +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl + +# matin et/ou après-midi ? +def _eval_demijournee(E): + "1 si matin, 0 si apres midi, 2 si toute la journee" + am, pm = False, False + if E["heure_debut"] < "13:00": + am = True + if E["heure_fin"] > "13:00": + pm = True + if am and pm: + demijournee = 2 + elif am: + demijournee = 1 + else: + demijournee = 0 + pm = True + return am, pm, demijournee + + +def evaluation_check_absences(evaluation_id): + """Vérifie les absences au moment de cette évaluation. + Cas incohérents que l'on peut rencontrer pour chaque étudiant: + note et absent + ABS et pas noté absent + ABS et absent justifié + EXC et pas noté absent + EXC et pas justifie + Ramene 3 listes d'etudid + """ + E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] + if not E["jour"]: + return [], [], [], [], [] # evaluation sans date + + am, pm, demijournee = _eval_demijournee(E) + + # Liste les absences à ce moment: + A = sco_abs.list_abs_jour(ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm) + As = set([x["etudid"] for x in A]) # ensemble des etudiants absents + NJ = sco_abs.list_abs_non_just_jour(ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm) + NJs = set([x["etudid"] for x in NJ]) # ensemble des etudiants absents non justifies + Just = sco_abs.list_abs_jour( + ndb.DateDMYtoISO(E["jour"]), am=am, pm=pm, is_abs=None, is_just=True + ) + Justs = set([x["etudid"] for x in Just]) # ensemble des etudiants avec justif + + # Les notes: + notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) + ValButAbs = [] # une note mais noté absent + AbsNonSignalee = [] # note ABS mais pas noté absent + ExcNonSignalee = [] # note EXC mais pas noté absent + ExcNonJust = [] # note EXC mais absent non justifie + AbsButExc = [] # note ABS mais justifié + for etudid, _ in sco_groups.do_evaluation_listeetuds_groups( + evaluation_id, getallstudents=True + ): + if etudid in notes_db: + val = notes_db[etudid]["value"] + if ( + val != None and val != scu.NOTES_NEUTRALISE and val != scu.NOTES_ATTENTE + ) and etudid in As: + # note valide et absent + ValButAbs.append(etudid) + if val is None and not etudid in As: + # absent mais pas signale comme tel + AbsNonSignalee.append(etudid) + if val == scu.NOTES_NEUTRALISE and not etudid in As: + # Neutralisé mais pas signale absent + ExcNonSignalee.append(etudid) + if val == scu.NOTES_NEUTRALISE and etudid in NJs: + # EXC mais pas justifié + ExcNonJust.append(etudid) + if val is None and etudid in Justs: + # ABS mais justificatif + AbsButExc.append(etudid) + + return ValButAbs, AbsNonSignalee, ExcNonSignalee, ExcNonJust, AbsButExc + + +def evaluation_check_absences_html(evaluation_id, with_header=True, show_ok=True): + """Affiche état vérification absences d'une évaluation""" + + E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] + am, pm, demijournee = _eval_demijournee(E) + + ( + ValButAbs, + AbsNonSignalee, + ExcNonSignalee, + ExcNonJust, + AbsButExc, + ) = evaluation_check_absences(evaluation_id) + + if with_header: + H = [ + html_sco_header.html_sem_header("Vérification absences à l'évaluation"), + sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), + """

Vérification de la cohérence entre les notes saisies et les absences signalées.

""", + ] + else: + # pas de header, mais un titre + H = [ + """

%s du %s """ + % (E["description"], E["jour"]) + ] + if ( + not ValButAbs + and not AbsNonSignalee + and not ExcNonSignalee + and not ExcNonJust + ): + H.append(': ok') + H.append("

") + + def etudlist(etudids, linkabs=False): + H.append("") + + if ValButAbs or show_ok: + H.append( + "

Etudiants ayant une note alors qu'ils sont signalés absents:

" + ) + etudlist(ValButAbs) + + if AbsNonSignalee or show_ok: + H.append( + """

Etudiants avec note "ABS" alors qu'ils ne sont pas signalés absents:

""" + ) + etudlist(AbsNonSignalee, linkabs=True) + + if ExcNonSignalee or show_ok: + H.append( + """

Etudiants avec note "EXC" alors qu'ils ne sont pas signalés absents:

""" + ) + etudlist(ExcNonSignalee) + + if ExcNonJust or show_ok: + H.append( + """

Etudiants avec note "EXC" alors qu'ils sont absents non justifiés:

""" + ) + etudlist(ExcNonJust) + + if AbsButExc or show_ok: + H.append( + """

Etudiants avec note "ABS" alors qu'ils ont une justification:

""" + ) + etudlist(AbsButExc) + + if with_header: + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def formsemestre_check_absences_html(formsemestre_id): + """Affiche etat verification absences pour toutes les evaluations du semestre !""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + H = [ + html_sco_header.html_sem_header( + "Vérification absences aux évaluations de ce semestre", + ), + """

Vérification de la cohérence entre les notes saisies et les absences signalées. + Sont listés tous les modules avec des évaluations.
Aucune action n'est effectuée: + il vous appartient de corriger les erreurs détectées si vous le jugez nécessaire. +

""", + ] + # Modules, dans l'ordre + Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) + for M in Mlist: + evals = sco_evaluation_db.do_evaluation_list( + {"moduleimpl_id": M["moduleimpl_id"]} + ) + if evals: + H.append( + '

%s: %s

' + % ( + M["moduleimpl_id"], + M["module"]["code"] or "", + M["module"]["abbrev"] or "", + ) + ) + for E in evals: + H.append( + evaluation_check_absences_html( + E["evaluation_id"], + with_header=False, + show_ok=False, + ) + ) + if evals: + H.append("
") + H.append(html_sco_header.sco_footer()) + return "\n".join(H) diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 581e0add..163daf74 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -1,411 +1,411 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Recherche d'étudiants -""" -import flask -from flask import url_for, g, request -from flask_login import current_user - -import app -from app.models import Departement -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc.gen_tables import GenTable -from app.scodoc import html_sco_header -from app.scodoc import sco_etud -from app.scodoc import sco_groups -from app.scodoc.sco_exceptions import ScoException -from app.scodoc.sco_permissions import Permission -from app.scodoc import sco_preferences - - -def form_search_etud( - dest_url=None, - parameters=None, - parameters_keys=None, - title="Rechercher un étudiant par nom : ", - add_headers=False, # complete page -): - "form recherche par nom" - H = [] - H.append( - f"""
- {title} - - -
(entrer une partie du nom) - """ - ) - if dest_url: - H.append('' % dest_url) - if parameters: - for param in parameters.keys(): - H.append( - '' - % (param, parameters[param]) - ) - H.append( - '' - % (",".join(parameters.keys())) - ) - elif parameters_keys: - if request.method == "POST": - vals = request.form - elif request.method == "GET": - vals = request.args - else: - vals = {} - for key in parameters_keys.split(","): - v = vals.get(key, False) - if v: - H.append('' % (key, v)) - H.append( - '' % parameters_keys - ) - H.append("
") - - if add_headers: - return ( - html_sco_header.sco_header(page_title="Choix d'un étudiant") - + "\n".join(H) - + html_sco_header.sco_footer() - ) - else: - return "\n".join(H) - - -def search_etud_in_dept(expnom=""): - """Page recherche d'un etudiant. - - Affiche la fiche de l'étudiant, ou, si la recherche donne plusieurs résultats, - la liste des étudiants correspondants. - Appelée par: - - boite de recherche barre latérale gauche. - - choix d'un étudiant à inscrire (en POST avec dest_url et parameters_keys) - - Args: - expnom: string, regexp sur le nom ou un code_nip ou un etudid - """ - if isinstance(expnom, int) or len(expnom) > 1: - try: - etudid = int(expnom) - except ValueError: - etudid = None - if etudid is not None: - etuds = sco_etud.get_etud_info(filled=True, etudid=expnom) - if (etudid is None) or len(etuds) != 1: - expnom_str = str(expnom) - if scu.is_valid_code_nip(expnom_str): - etuds = search_etuds_infos(code_nip=expnom_str) - else: - etuds = search_etuds_infos(expnom=expnom_str) - else: - etuds = [] # si expnom est trop court, n'affiche rien - - if request.method == "POST": - vals = request.form - elif request.method == "GET": - vals = request.args - else: - vals = {} - - url_args = {"scodoc_dept": g.scodoc_dept} - if "dest_url" in vals: - endpoint = vals["dest_url"] - else: - endpoint = "scolar.ficheEtud" - if "parameters_keys" in vals: - for key in vals["parameters_keys"].split(","): - url_args[key] = vals[key] - - if len(etuds) == 1: - # va directement a la fiche - url_args["etudid"] = etuds[0]["etudid"] - return flask.redirect(url_for(endpoint, **url_args)) - - H = [ - html_sco_header.sco_header( - page_title="Recherche d'un étudiant", - no_side_bar=False, - init_qtip=True, - javascripts=["js/etud_info.js"], - ) - ] - if len(etuds) == 0 and len(etuds) <= 1: - H.append("""

chercher un étudiant:

""") - else: - H.append( - f"""

{len(etuds)} résultats pour "{expnom}": choisissez un étudiant:

""" - ) - H.append( - form_search_etud( - dest_url=endpoint, - parameters=vals.get("parameters"), - parameters_keys=vals.get("parameters_keys"), - title="Autre recherche", - ) - ) - if len(etuds) > 0: - # Choix dans la liste des résultats: - for e in etuds: - url_args["etudid"] = e["etudid"] - target = url_for(endpoint, **url_args) - e["_nomprenom_target"] = target - e["inscription_target"] = target - e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) - sco_groups.etud_add_group_infos( - e, e["cursem"]["formsemestre_id"] if e["cursem"] else None - ) - - tab = GenTable( - columns_ids=("nomprenom", "code_nip", "inscription", "groupes"), - titles={ - "nomprenom": "Étudiant", - "code_nip": "NIP", - "inscription": "Inscription", - "groupes": "Groupes", - }, - rows=etuds, - html_sortable=True, - html_class="table_leftalign", - preferences=sco_preferences.SemPreferences(), - ) - H.append(tab.html()) - if len(etuds) > 20: # si la page est grande - H.append( - form_search_etud( - dest_url=endpoint, - parameters=vals.get("parameters"), - parameters_keys=vals.get("parameters_keys"), - title="Autre recherche", - ) - ) - else: - H.append('

Aucun résultat pour "%s".

' % expnom) - H.append( - """

La recherche porte sur tout ou partie du NOM ou du NIP de l'étudiant. Saisir au moins deux caractères.

""" - ) - return "\n".join(H) + html_sco_header.sco_footer() - - -# Was chercheEtudsInfo() -def search_etuds_infos(expnom=None, code_nip=None): - """recherche les étudiants correspondants à expnom ou au code_nip - et ramene liste de mappings utilisables en DTML. - """ - may_be_nip = scu.is_valid_code_nip(expnom) - cnx = ndb.GetDBConnexion() - if expnom and not may_be_nip: - expnom = expnom.upper() # les noms dans la BD sont en uppercase - try: - etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~") - except ScoException: - etuds = [] - else: - code_nip = code_nip or expnom - if code_nip: - etuds = sco_etud.etudident_list(cnx, args={"code_nip": str(code_nip)}) - else: - etuds = [] - sco_etud.fill_etuds_info(etuds) - return etuds - - -def search_etud_by_name(term: str) -> list: - """Recherche noms étudiants par début du nom, pour autocomplete - Accepte aussi un début de code NIP (au moins 6 caractères) - Renvoie une liste de dicts - { "label" : " ", "value" : etudid } - """ - may_be_nip = scu.is_valid_code_nip(term) - # term = term.upper() # conserve les accents - term = term.upper() - if ( - not scu.ALPHANUM_EXP.match(term) # n'autorise pas les caractères spéciaux - and not may_be_nip - ): - data = [] - else: - if may_be_nip: - r = ndb.SimpleDictFetch( - """SELECT nom, prenom, code_nip - FROM identite - WHERE - dept_id = %(dept_id)s - AND code_nip LIKE %(beginning)s - ORDER BY nom - """, - {"beginning": term + "%", "dept_id": g.scodoc_dept_id}, - ) - data = [ - { - "label": "%s %s %s" - % (x["code_nip"], x["nom"], sco_etud.format_prenom(x["prenom"])), - "value": x["code_nip"], - } - for x in r - ] - else: - r = ndb.SimpleDictFetch( - """SELECT id AS etudid, nom, prenom - FROM identite - WHERE - dept_id = %(dept_id)s - AND nom LIKE %(beginning)s - ORDER BY nom - """, - {"beginning": term + "%", "dept_id": g.scodoc_dept_id}, - ) - - data = [ - { - "label": "%s %s" % (x["nom"], sco_etud.format_prenom(x["prenom"])), - "value": x["etudid"], - } - for x in r - ] - return data - - -# ---------- Recherche sur plusieurs département - - -def search_etud_in_accessible_depts(expnom=None, code_nip=None): - """ - result is a list of (sorted) etuds, one list per dept. - """ - result = [] - accessible_depts = [] - depts = Departement.query.filter_by(visible=True).all() - for dept in depts: - if current_user.has_permission(Permission.ScoView, dept=dept.acronym): - if expnom or code_nip: - accessible_depts.append(dept.acronym) - app.set_sco_dept(dept.acronym) - etuds = search_etuds_infos(expnom=expnom, code_nip=code_nip) - else: - etuds = [] - result.append(etuds) - return result, accessible_depts - - -def table_etud_in_accessible_depts(expnom=None): - """ - Page avec table étudiants trouvés, dans tous les departements. - Attention: nous sommes ici au niveau de ScoDoc, pas dans un département - """ - result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom) - H = [ - """
""", - """

Recherche multi-département de "%s"

""" % expnom, - ] - for etuds in result: - if etuds: - dept_id = etuds[0]["dept"] - # H.append('

Département %s

' % DeptId) - for e in etuds: - e["_nomprenom_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"] - ) - e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) - - tab = GenTable( - titles={"nomprenom": "Étudiants en " + dept_id}, - columns_ids=("nomprenom",), - rows=etuds, - html_sortable=True, - html_class="table_leftalign", - ) - - H.append('
') - H.append(tab.html()) - H.append("
") - if len(accessible_depts) > 1: - ss = "s" - else: - ss = "" - H.append( - f"""

(recherche menée dans le{ss} département{ss}: - {", ".join(accessible_depts)}) -

-

- Retour à l'accueil -

-
- """ - ) - return ( - html_sco_header.scodoc_top_html_header(page_title="Choix d'un étudiant") - + "\n".join(H) - + html_sco_header.standard_html_footer() - ) - - -def search_inscr_etud_by_nip(code_nip, format="json"): - """Recherche multi-departement d'un étudiant par son code NIP - Seuls les départements accessibles par l'utilisateur sont cherchés. - - Renvoie une liste des inscriptions de l'étudiants dans tout ScoDoc: - code_nip, nom, prenom, civilite_str, dept, formsemestre_id, date_debut_sem, date_fin_sem - """ - result, _ = search_etud_in_accessible_depts(code_nip=code_nip) - - T = [] - for etuds in result: - if etuds: - dept_id = etuds[0]["dept"] - for e in etuds: - for sem in e["sems"]: - T.append( - { - "dept": dept_id, - "etudid": e["etudid"], - "code_nip": e["code_nip"], - "civilite_str": e["civilite_str"], - "nom": e["nom"], - "prenom": e["prenom"], - "formsemestre_id": sem["formsemestre_id"], - "date_debut_iso": sem["date_debut_iso"], - "date_fin_iso": sem["date_fin_iso"], - } - ) - - columns_ids = ( - "dept", - "etudid", - "code_nip", - "civilite_str", - "nom", - "prenom", - "formsemestre_id", - "date_debut_iso", - "date_fin_iso", - ) - tab = GenTable(columns_ids=columns_ids, rows=T) - - return tab.make_page(format=format, with_html_headers=False, publish=True) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Recherche d'étudiants +""" +import flask +from flask import url_for, g, request +from flask_login import current_user + +import app +from app.models import Departement +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc.gen_tables import GenTable +from app.scodoc import html_sco_header +from app.scodoc import sco_etud +from app.scodoc import sco_groups +from app.scodoc.sco_exceptions import ScoException +from app.scodoc.sco_permissions import Permission +from app.scodoc import sco_preferences + + +def form_search_etud( + dest_url=None, + parameters=None, + parameters_keys=None, + title="Rechercher un étudiant par nom : ", + add_headers=False, # complete page +): + "form recherche par nom" + H = [] + H.append( + f"""
+ {title} + + +
(entrer une partie du nom) + """ + ) + if dest_url: + H.append('' % dest_url) + if parameters: + for param in parameters.keys(): + H.append( + '' + % (param, parameters[param]) + ) + H.append( + '' + % (",".join(parameters.keys())) + ) + elif parameters_keys: + if request.method == "POST": + vals = request.form + elif request.method == "GET": + vals = request.args + else: + vals = {} + for key in parameters_keys.split(","): + v = vals.get(key, False) + if v: + H.append('' % (key, v)) + H.append( + '' % parameters_keys + ) + H.append("
") + + if add_headers: + return ( + html_sco_header.sco_header(page_title="Choix d'un étudiant") + + "\n".join(H) + + html_sco_header.sco_footer() + ) + else: + return "\n".join(H) + + +def search_etud_in_dept(expnom=""): + """Page recherche d'un etudiant. + + Affiche la fiche de l'étudiant, ou, si la recherche donne plusieurs résultats, + la liste des étudiants correspondants. + Appelée par: + - boite de recherche barre latérale gauche. + - choix d'un étudiant à inscrire (en POST avec dest_url et parameters_keys) + + Args: + expnom: string, regexp sur le nom ou un code_nip ou un etudid + """ + if isinstance(expnom, int) or len(expnom) > 1: + try: + etudid = int(expnom) + except ValueError: + etudid = None + if etudid is not None: + etuds = sco_etud.get_etud_info(filled=True, etudid=expnom) + if (etudid is None) or len(etuds) != 1: + expnom_str = str(expnom) + if scu.is_valid_code_nip(expnom_str): + etuds = search_etuds_infos(code_nip=expnom_str) + else: + etuds = search_etuds_infos(expnom=expnom_str) + else: + etuds = [] # si expnom est trop court, n'affiche rien + + if request.method == "POST": + vals = request.form + elif request.method == "GET": + vals = request.args + else: + vals = {} + + url_args = {"scodoc_dept": g.scodoc_dept} + if "dest_url" in vals: + endpoint = vals["dest_url"] + else: + endpoint = "scolar.ficheEtud" + if "parameters_keys" in vals: + for key in vals["parameters_keys"].split(","): + url_args[key] = vals[key] + + if len(etuds) == 1: + # va directement a la fiche + url_args["etudid"] = etuds[0]["etudid"] + return flask.redirect(url_for(endpoint, **url_args)) + + H = [ + html_sco_header.sco_header( + page_title="Recherche d'un étudiant", + no_side_bar=False, + init_qtip=True, + javascripts=["js/etud_info.js"], + ) + ] + if len(etuds) == 0 and len(etuds) <= 1: + H.append("""

chercher un étudiant:

""") + else: + H.append( + f"""

{len(etuds)} résultats pour "{expnom}": choisissez un étudiant:

""" + ) + H.append( + form_search_etud( + dest_url=endpoint, + parameters=vals.get("parameters"), + parameters_keys=vals.get("parameters_keys"), + title="Autre recherche", + ) + ) + if len(etuds) > 0: + # Choix dans la liste des résultats: + for e in etuds: + url_args["etudid"] = e["etudid"] + target = url_for(endpoint, **url_args) + e["_nomprenom_target"] = target + e["inscription_target"] = target + e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) + sco_groups.etud_add_group_infos( + e, e["cursem"]["formsemestre_id"] if e["cursem"] else None + ) + + tab = GenTable( + columns_ids=("nomprenom", "code_nip", "inscription", "groupes"), + titles={ + "nomprenom": "Étudiant", + "code_nip": "NIP", + "inscription": "Inscription", + "groupes": "Groupes", + }, + rows=etuds, + html_sortable=True, + html_class="table_leftalign", + preferences=sco_preferences.SemPreferences(), + ) + H.append(tab.html()) + if len(etuds) > 20: # si la page est grande + H.append( + form_search_etud( + dest_url=endpoint, + parameters=vals.get("parameters"), + parameters_keys=vals.get("parameters_keys"), + title="Autre recherche", + ) + ) + else: + H.append('

Aucun résultat pour "%s".

' % expnom) + H.append( + """

La recherche porte sur tout ou partie du NOM ou du NIP de l'étudiant. Saisir au moins deux caractères.

""" + ) + return "\n".join(H) + html_sco_header.sco_footer() + + +# Was chercheEtudsInfo() +def search_etuds_infos(expnom=None, code_nip=None): + """recherche les étudiants correspondants à expnom ou au code_nip + et ramene liste de mappings utilisables en DTML. + """ + may_be_nip = scu.is_valid_code_nip(expnom) + cnx = ndb.GetDBConnexion() + if expnom and not may_be_nip: + expnom = expnom.upper() # les noms dans la BD sont en uppercase + try: + etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~") + except ScoException: + etuds = [] + else: + code_nip = code_nip or expnom + if code_nip: + etuds = sco_etud.etudident_list(cnx, args={"code_nip": str(code_nip)}) + else: + etuds = [] + sco_etud.fill_etuds_info(etuds) + return etuds + + +def search_etud_by_name(term: str) -> list: + """Recherche noms étudiants par début du nom, pour autocomplete + Accepte aussi un début de code NIP (au moins 6 caractères) + Renvoie une liste de dicts + { "label" : " ", "value" : etudid } + """ + may_be_nip = scu.is_valid_code_nip(term) + # term = term.upper() # conserve les accents + term = term.upper() + if ( + not scu.ALPHANUM_EXP.match(term) # n'autorise pas les caractères spéciaux + and not may_be_nip + ): + data = [] + else: + if may_be_nip: + r = ndb.SimpleDictFetch( + """SELECT nom, prenom, code_nip + FROM identite + WHERE + dept_id = %(dept_id)s + AND code_nip LIKE %(beginning)s + ORDER BY nom + """, + {"beginning": term + "%", "dept_id": g.scodoc_dept_id}, + ) + data = [ + { + "label": "%s %s %s" + % (x["code_nip"], x["nom"], sco_etud.format_prenom(x["prenom"])), + "value": x["code_nip"], + } + for x in r + ] + else: + r = ndb.SimpleDictFetch( + """SELECT id AS etudid, nom, prenom + FROM identite + WHERE + dept_id = %(dept_id)s + AND nom LIKE %(beginning)s + ORDER BY nom + """, + {"beginning": term + "%", "dept_id": g.scodoc_dept_id}, + ) + + data = [ + { + "label": "%s %s" % (x["nom"], sco_etud.format_prenom(x["prenom"])), + "value": x["etudid"], + } + for x in r + ] + return data + + +# ---------- Recherche sur plusieurs département + + +def search_etud_in_accessible_depts(expnom=None, code_nip=None): + """ + result is a list of (sorted) etuds, one list per dept. + """ + result = [] + accessible_depts = [] + depts = Departement.query.filter_by(visible=True).all() + for dept in depts: + if current_user.has_permission(Permission.ScoView, dept=dept.acronym): + if expnom or code_nip: + accessible_depts.append(dept.acronym) + app.set_sco_dept(dept.acronym) + etuds = search_etuds_infos(expnom=expnom, code_nip=code_nip) + else: + etuds = [] + result.append(etuds) + return result, accessible_depts + + +def table_etud_in_accessible_depts(expnom=None): + """ + Page avec table étudiants trouvés, dans tous les departements. + Attention: nous sommes ici au niveau de ScoDoc, pas dans un département + """ + result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom) + H = [ + """
""", + """

Recherche multi-département de "%s"

""" % expnom, + ] + for etuds in result: + if etuds: + dept_id = etuds[0]["dept"] + # H.append('

Département %s

' % DeptId) + for e in etuds: + e["_nomprenom_target"] = url_for( + "scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"] + ) + e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) + + tab = GenTable( + titles={"nomprenom": "Étudiants en " + dept_id}, + columns_ids=("nomprenom",), + rows=etuds, + html_sortable=True, + html_class="table_leftalign", + ) + + H.append('
') + H.append(tab.html()) + H.append("
") + if len(accessible_depts) > 1: + ss = "s" + else: + ss = "" + H.append( + f"""

(recherche menée dans le{ss} département{ss}: + {", ".join(accessible_depts)}) +

+

+ Retour à l'accueil +

+
+ """ + ) + return ( + html_sco_header.scodoc_top_html_header(page_title="Choix d'un étudiant") + + "\n".join(H) + + html_sco_header.standard_html_footer() + ) + + +def search_inscr_etud_by_nip(code_nip, format="json"): + """Recherche multi-departement d'un étudiant par son code NIP + Seuls les départements accessibles par l'utilisateur sont cherchés. + + Renvoie une liste des inscriptions de l'étudiants dans tout ScoDoc: + code_nip, nom, prenom, civilite_str, dept, formsemestre_id, date_debut_sem, date_fin_sem + """ + result, _ = search_etud_in_accessible_depts(code_nip=code_nip) + + T = [] + for etuds in result: + if etuds: + dept_id = etuds[0]["dept"] + for e in etuds: + for sem in e["sems"]: + T.append( + { + "dept": dept_id, + "etudid": e["etudid"], + "code_nip": e["code_nip"], + "civilite_str": e["civilite_str"], + "nom": e["nom"], + "prenom": e["prenom"], + "formsemestre_id": sem["formsemestre_id"], + "date_debut_iso": sem["date_debut_iso"], + "date_fin_iso": sem["date_fin_iso"], + } + ) + + columns_ids = ( + "dept", + "etudid", + "code_nip", + "civilite_str", + "nom", + "prenom", + "formsemestre_id", + "date_debut_iso", + "date_fin_iso", + ) + tab = GenTable(columns_ids=columns_ids, rows=T) + + return tab.make_page(format=format, with_html_headers=False, publish=True) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 1195c575..f3afb4d7 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1659,7 +1659,7 @@ def formsemestre_change_publication_bul( "

Confirmer la %s publication des bulletins ?

" % msg, helpmsg="""Il est parfois utile de désactiver la diffusion des bulletins, par exemple pendant la tenue d'un jury ou avant harmonisation des notes. -
+
Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc et un portail étudiant. """, dest_url="", diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 540282e9..2c4fd681 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -893,7 +893,7 @@ def formsemestre_inscrits_ailleurs(formsemestre_id): H.append("") H.append("

Total: %d étudiants concernés.

" % len(etudlist)) H.append( - """

Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps !
Sauf exception, cette situation est anormale:

+ """

Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps !
Sauf exception, cette situation est anormale:

  • vérifier que les dates des semestres se suivent sans se chevaucher
  • ou si besoin désinscrire le(s) étudiant(s) de l'un des semestres (via leurs fiches individuelles).
  • diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index f129a36a..635b96c5 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -349,7 +349,7 @@ def formsemestre_validation_etud_form( H.append("") H.append( - '


    ' + '


    ' ) H.append("") @@ -1299,7 +1299,7 @@ def check_formation_ues(formation_id): H = [ """
    Attention: les UE suivantes de cette formation sont utilisées dans des - semestres de rangs différents (eg S1 et S3).
    Cela peut engendrer des problèmes pour + semestres de rangs différents (eg S1 et S3).
    Cela peut engendrer des problèmes pour la capitalisation des UE. Il serait préférable d'essayer de rectifier cette situation: soit modifier le programme de la formation (définir des UE dans chaque semestre), soit veiller à saisir le bon indice de semestre dans le menu lors de la validation d'une diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py index 89338e8b..d8b5c3e0 100644 --- a/app/scodoc/sco_import_etuds.py +++ b/app/scodoc/sco_import_etuds.py @@ -302,7 +302,7 @@ def scolars_import_excel_file( else: unknown.append(f) raise ScoValueError( - """Nombre de colonnes incorrect (devrait être %d, et non %d)
    + """Nombre de colonnes incorrect (devrait être %d, et non %d)
    (colonnes manquantes: %s, colonnes invalides: %s)""" % (len(titles), len(fs), list(missing.keys()), unknown) ) diff --git a/app/scodoc/sco_import_users.py b/app/scodoc/sco_import_users.py index a0b821ea..8683c008 100644 --- a/app/scodoc/sco_import_users.py +++ b/app/scodoc/sco_import_users.py @@ -1,312 +1,312 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Import d'utilisateurs via fichier Excel -""" -import random -import time - -from email.mime.multipart import MIMEMultipart -from flask import g, url_for -from flask_login import current_user - -from app import db -from app import email -from app.auth.models import User, UserRole -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc.sco_exceptions import AccessDenied, ScoValueError -from app.scodoc import sco_excel -from app.scodoc import sco_preferences -from app.scodoc import sco_users - - -TITLES = ("user_name", "nom", "prenom", "email", "roles", "dept") -COMMENTS = ( - """user_name: - Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _ - """, - """nom: - Maximum 64 caractères""", - """prenom: - Maximum 64 caractères""", - """email: - Maximum 120 caractères""", - """roles: - un plusieurs rôles séparés par ',' - chaque role est fait de 2 composantes séparées par _: - 1. Le role (Ens, Secr ou Admin) - 2. Le département (en majuscule) - Exemple: "Ens_RT,Admin_INFO" - """, - """dept: - Le département d'appartenance du l'utillsateur. Laisser vide si l'utilisateur intervient dans plusieurs dépatements - """, -) - - -def generate_excel_sample(): - """generates an excel document suitable to import users""" - style = sco_excel.excel_make_style(bold=True) - titles = TITLES - titles_styles = [style] * len(titles) - return sco_excel.excel_simple_table( - titles=titles, - titles_styles=titles_styles, - sheet_name="Utilisateurs ScoDoc", - comments=COMMENTS, - ) - - -def import_excel_file(datafile, force=""): - """ - Import scodoc users from Excel file. - This method: - * checks that the current_user has the ability to do so (at the moment only a SuperAdmin). He may thereoff import users with any well formed role into any department (or all) - * Once the check is done ans successfull, build the list of users (does not check the data) - * call :func:`import_users` to actually do the job - history: scodoc7 with no SuperAdmin every Admin_XXX could import users. - :param datafile: the stream from to the to be imported - :return: same as import users - """ - # Check current user privilege - auth_name = str(current_user) - if not current_user.is_administrator(): - raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) - # Récupération des informations sur l'utilisateur courant - log("sco_import_users.import_excel_file by %s" % auth_name) - # Read the data from the stream - exceldata = datafile.read() - if not exceldata: - raise ScoValueError("Ficher excel vide ou invalide") - _, data = sco_excel.excel_bytes_to_list(exceldata) - if not data: - raise ScoValueError( - """Le fichier xlsx attendu semble vide ! - """ - ) - # 1- --- check title line - fs = [scu.stripquotes(s).lower() for s in data[0]] - log("excel: fs='%s'\ndata=%s" % (str(fs), str(data))) - # check cols - cols = {}.fromkeys(TITLES) - unknown = [] - for tit in fs: - if tit not in cols: - unknown.append(tit) - else: - del cols[tit] - if cols or unknown: - raise ScoValueError( - """colonnes incorrectes (on attend %d, et non %d)
    - (colonnes manquantes: %s, colonnes invalides: %s)""" - % (len(TITLES), len(fs), list(cols.keys()), unknown) - ) - # ok, same titles... : build the list of dictionaries - users = [] - for line in data[1:]: - d = {} - for i in range(len(fs)): - d[fs[i]] = line[i] - users.append(d) - - return import_users(users=users, force=force) - - -def import_users(users, force=""): - """ - Import users from a list of users_descriptors. - - descriptors are dictionaries hosting users's data. - The operation is atomic (all the users are imported or none) - - :param users: list of descriptors to be imported - - :return: a tuple that describe the result of the import: - * ok: import ok or aborted - * messages: the list of messages - * the # of users created - - Implémentation: - Pour chaque utilisateur à créer: - * vérifier données (y compris que le même nom d'utilisateur n'est pas utilisé plusieurs fois) - * générer mot de passe aléatoire - * créer utilisateur et mettre le mot de passe - * envoyer mot de passe par mail - Les utilisateurs à créer sont stockés dans un dictionnaire. - L'ajout effectif ne se fait qu'en fin de fonction si aucune erreur n'a été détectée - """ - - created = {} # uid créés - if len(users) == 0: - import_ok = False - msg_list = ["Feuille vide ou illisible"] - else: - msg_list = [] - line = 1 # start from excel line #2 - import_ok = True - - def append_msg(msg): - msg_list.append("Ligne %s : %s" % (line, msg)) - - try: - for u in users: - line = line + 1 - user_ok, msg = sco_users.check_modif_user( - 0, - enforce_optionals=not force, - user_name=u["user_name"], - nom=u["nom"], - prenom=u["prenom"], - email=u["email"], - roles=[r for r in u["roles"].split(",") if r], - dept=u["dept"], - ) - if not user_ok: - append_msg("identifiant '%s' %s" % (u["user_name"], msg)) - - u["passwd"] = generate_password() - # - # check identifiant - if u["user_name"] in created.keys(): - user_ok = False - append_msg( - "l'utilisateur '%s' a déjà été décrit ligne %s" - % (u["user_name"], created[u["user_name"]]["line"]) - ) - # check roles / ignore whitespaces around roles / build roles_string - # roles_string (expected by User) appears as column 'roles' in excel file - roles_list = [] - for role in u["roles"].split(","): - try: - role = role.strip() - if role: - _, _ = UserRole.role_dept_from_string(role) - roles_list.append(role) - except ScoValueError as value_error: - user_ok = False - append_msg("role %s : %s" % (role, value_error)) - u["roles_string"] = ",".join(roles_list) - if user_ok: - u["line"] = line - created[u["user_name"]] = u - else: - import_ok = False - except ScoValueError as value_error: - log(f"import_users: exception: abort create {str(created.keys())}") - raise ScoValueError(msg) from value_error - if import_ok: - for u in created.values(): - # Création de l'utilisateur (via SQLAlchemy) - user = User() - user.from_dict(u, new_user=True) - db.session.add(user) - db.session.commit() - mail_password(u) - else: - created = {} # reset # of created users to 0 - return import_ok, msg_list, len(created) - - -# --------- Génération du mot de passe initial ----------- -# Adapté de http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/440564 -# Alphabet tres simple pour des mots de passe simples... - - -ALPHABET = r"""ABCDEFGHIJKLMNPQRSTUVWXYZ123456789123456789AEIOU""" -PASSLEN = 8 -RNG = random.Random(time.time()) - - -def generate_password(): - """This function creates a pseudo random number generator object, seeded with - the cryptographic hash of the passString. The contents of the character set - is then shuffled and a selection of passLength words is made from this list. - This selection is returned as the generated password.""" - l = list(ALPHABET) # make this mutable so that we can shuffle the characters - RNG.shuffle(l) # shuffle the character set - # pick up only a subset from the available characters: - return "".join(RNG.sample(l, PASSLEN)) - - -def mail_password(user: dict, reset=False) -> None: - "Send password by email" - if not user["email"]: - return - - user["url"] = url_for("scodoc.index", _external=True) - txt = ( - """ -Bonjour %(prenom)s %(nom)s, - -""" - % user - ) - if reset: - txt += ( - """ -votre mot de passe ScoDoc a été ré-initialisé. - -Le nouveau mot de passe est: %(passwd)s -Votre nom d'utilisateur est %(user_name)s - -Vous devrez changer ce mot de passe lors de votre première connexion -sur %(url)s -""" - % user - ) - else: - txt += ( - """ -vous avez été déclaré comme utilisateur du logiciel de gestion de scolarité ScoDoc. - -Votre nom d'utilisateur est %(user_name)s -Votre mot de passe est: %(passwd)s - -Le logiciel est accessible sur: %(url)s - -Vous êtes invité à changer ce mot de passe au plus vite (cliquez sur votre nom en haut à gauche de la page d'accueil). -""" - % user - ) - - txt += ( - """ -_______ -ScoDoc est un logiciel libre développé par Emmanuel Viennet et l'association ScoDoc. -Pour plus d'informations sur ce logiciel, voir %s - -""" - % scu.SCO_WEBSITE - ) - msg = MIMEMultipart() - if reset: - subject = "Mot de passe ScoDoc" - else: - subject = "Votre accès ScoDoc" - sender = sco_preferences.get_preference("email_from_addr") - email.send_email(subject, sender, [user["email"]], txt) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Import d'utilisateurs via fichier Excel +""" +import random +import time + +from email.mime.multipart import MIMEMultipart +from flask import g, url_for +from flask_login import current_user + +from app import db +from app import email +from app.auth.models import User, UserRole +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +from app.scodoc import sco_excel +from app.scodoc import sco_preferences +from app.scodoc import sco_users + + +TITLES = ("user_name", "nom", "prenom", "email", "roles", "dept") +COMMENTS = ( + """user_name: + Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _ + """, + """nom: + Maximum 64 caractères""", + """prenom: + Maximum 64 caractères""", + """email: + Maximum 120 caractères""", + """roles: + un plusieurs rôles séparés par ',' + chaque role est fait de 2 composantes séparées par _: + 1. Le role (Ens, Secr ou Admin) + 2. Le département (en majuscule) + Exemple: "Ens_RT,Admin_INFO" + """, + """dept: + Le département d'appartenance du l'utillsateur. Laisser vide si l'utilisateur intervient dans plusieurs dépatements + """, +) + + +def generate_excel_sample(): + """generates an excel document suitable to import users""" + style = sco_excel.excel_make_style(bold=True) + titles = TITLES + titles_styles = [style] * len(titles) + return sco_excel.excel_simple_table( + titles=titles, + titles_styles=titles_styles, + sheet_name="Utilisateurs ScoDoc", + comments=COMMENTS, + ) + + +def import_excel_file(datafile, force=""): + """ + Import scodoc users from Excel file. + This method: + * checks that the current_user has the ability to do so (at the moment only a SuperAdmin). He may thereoff import users with any well formed role into any department (or all) + * Once the check is done ans successfull, build the list of users (does not check the data) + * call :func:`import_users` to actually do the job + history: scodoc7 with no SuperAdmin every Admin_XXX could import users. + :param datafile: the stream from to the to be imported + :return: same as import users + """ + # Check current user privilege + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + # Récupération des informations sur l'utilisateur courant + log("sco_import_users.import_excel_file by %s" % auth_name) + # Read the data from the stream + exceldata = datafile.read() + if not exceldata: + raise ScoValueError("Ficher excel vide ou invalide") + _, data = sco_excel.excel_bytes_to_list(exceldata) + if not data: + raise ScoValueError( + """Le fichier xlsx attendu semble vide ! + """ + ) + # 1- --- check title line + fs = [scu.stripquotes(s).lower() for s in data[0]] + log("excel: fs='%s'\ndata=%s" % (str(fs), str(data))) + # check cols + cols = {}.fromkeys(TITLES) + unknown = [] + for tit in fs: + if tit not in cols: + unknown.append(tit) + else: + del cols[tit] + if cols or unknown: + raise ScoValueError( + """colonnes incorrectes (on attend %d, et non %d)
    + (colonnes manquantes: %s, colonnes invalides: %s)""" + % (len(TITLES), len(fs), list(cols.keys()), unknown) + ) + # ok, same titles... : build the list of dictionaries + users = [] + for line in data[1:]: + d = {} + for i in range(len(fs)): + d[fs[i]] = line[i] + users.append(d) + + return import_users(users=users, force=force) + + +def import_users(users, force=""): + """ + Import users from a list of users_descriptors. + + descriptors are dictionaries hosting users's data. + The operation is atomic (all the users are imported or none) + + :param users: list of descriptors to be imported + + :return: a tuple that describe the result of the import: + * ok: import ok or aborted + * messages: the list of messages + * the # of users created + + Implémentation: + Pour chaque utilisateur à créer: + * vérifier données (y compris que le même nom d'utilisateur n'est pas utilisé plusieurs fois) + * générer mot de passe aléatoire + * créer utilisateur et mettre le mot de passe + * envoyer mot de passe par mail + Les utilisateurs à créer sont stockés dans un dictionnaire. + L'ajout effectif ne se fait qu'en fin de fonction si aucune erreur n'a été détectée + """ + + created = {} # uid créés + if len(users) == 0: + import_ok = False + msg_list = ["Feuille vide ou illisible"] + else: + msg_list = [] + line = 1 # start from excel line #2 + import_ok = True + + def append_msg(msg): + msg_list.append("Ligne %s : %s" % (line, msg)) + + try: + for u in users: + line = line + 1 + user_ok, msg = sco_users.check_modif_user( + 0, + enforce_optionals=not force, + user_name=u["user_name"], + nom=u["nom"], + prenom=u["prenom"], + email=u["email"], + roles=[r for r in u["roles"].split(",") if r], + dept=u["dept"], + ) + if not user_ok: + append_msg("identifiant '%s' %s" % (u["user_name"], msg)) + + u["passwd"] = generate_password() + # + # check identifiant + if u["user_name"] in created.keys(): + user_ok = False + append_msg( + "l'utilisateur '%s' a déjà été décrit ligne %s" + % (u["user_name"], created[u["user_name"]]["line"]) + ) + # check roles / ignore whitespaces around roles / build roles_string + # roles_string (expected by User) appears as column 'roles' in excel file + roles_list = [] + for role in u["roles"].split(","): + try: + role = role.strip() + if role: + _, _ = UserRole.role_dept_from_string(role) + roles_list.append(role) + except ScoValueError as value_error: + user_ok = False + append_msg("role %s : %s" % (role, value_error)) + u["roles_string"] = ",".join(roles_list) + if user_ok: + u["line"] = line + created[u["user_name"]] = u + else: + import_ok = False + except ScoValueError as value_error: + log(f"import_users: exception: abort create {str(created.keys())}") + raise ScoValueError(msg) from value_error + if import_ok: + for u in created.values(): + # Création de l'utilisateur (via SQLAlchemy) + user = User() + user.from_dict(u, new_user=True) + db.session.add(user) + db.session.commit() + mail_password(u) + else: + created = {} # reset # of created users to 0 + return import_ok, msg_list, len(created) + + +# --------- Génération du mot de passe initial ----------- +# Adapté de http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/440564 +# Alphabet tres simple pour des mots de passe simples... + + +ALPHABET = r"""ABCDEFGHIJKLMNPQRSTUVWXYZ123456789123456789AEIOU""" +PASSLEN = 8 +RNG = random.Random(time.time()) + + +def generate_password(): + """This function creates a pseudo random number generator object, seeded with + the cryptographic hash of the passString. The contents of the character set + is then shuffled and a selection of passLength words is made from this list. + This selection is returned as the generated password.""" + l = list(ALPHABET) # make this mutable so that we can shuffle the characters + RNG.shuffle(l) # shuffle the character set + # pick up only a subset from the available characters: + return "".join(RNG.sample(l, PASSLEN)) + + +def mail_password(user: dict, reset=False) -> None: + "Send password by email" + if not user["email"]: + return + + user["url"] = url_for("scodoc.index", _external=True) + txt = ( + """ +Bonjour %(prenom)s %(nom)s, + +""" + % user + ) + if reset: + txt += ( + """ +votre mot de passe ScoDoc a été ré-initialisé. + +Le nouveau mot de passe est: %(passwd)s +Votre nom d'utilisateur est %(user_name)s + +Vous devrez changer ce mot de passe lors de votre première connexion +sur %(url)s +""" + % user + ) + else: + txt += ( + """ +vous avez été déclaré comme utilisateur du logiciel de gestion de scolarité ScoDoc. + +Votre nom d'utilisateur est %(user_name)s +Votre mot de passe est: %(passwd)s + +Le logiciel est accessible sur: %(url)s + +Vous êtes invité à changer ce mot de passe au plus vite (cliquez sur votre nom en haut à gauche de la page d'accueil). +""" + % user + ) + + txt += ( + """ +_______ +ScoDoc est un logiciel libre développé par Emmanuel Viennet et l'association ScoDoc. +Pour plus d'informations sur ce logiciel, voir %s + +""" + % scu.SCO_WEBSITE + ) + msg = MIMEMultipart() + if reset: + subject = "Mot de passe ScoDoc" + else: + subject = "Votre accès ScoDoc" + sender = sco_preferences.get_preference("email_from_addr") + email.send_email(subject, sender, [user["email"]], txt) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index b3aeba7a..402073b5 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -517,18 +517,18 @@ def _make_table_notes( hh += "s" hh += ", %d en attente." % (nb_att) - pdf_title = "
    BORDEREAU DE SIGNATURES" - pdf_title += "

    %(titre)s" % sem - pdf_title += "
    (%(mois_debut)s - %(mois_fin)s)" % sem + pdf_title = "
    BORDEREAU DE SIGNATURES" + pdf_title += "

    %(titre)s" % sem + pdf_title += "
    (%(mois_debut)s - %(mois_fin)s)" % sem pdf_title += " semestre %s %s" % ( sem["semestre_id"], sem.get("modalite", ""), ) - pdf_title += f"
    Notes du module {module.code} - {module.titre}" - pdf_title += "
    Evaluation : %(description)s " % e + pdf_title += f"
    Notes du module {module.code} - {module.titre}" + pdf_title += "
    Evaluation : %(description)s " % e if len(e["jour"]) > 0: pdf_title += " (%(jour)s)" % e - pdf_title += "(noté sur %(note_max)s )

    " % e + pdf_title += "(noté sur %(note_max)s )

    " % e else: hh = " %s, %s (%d étudiants)" % ( E["description"], @@ -623,11 +623,11 @@ def _make_table_notes( commentkeys.sort(key=lambda x: int(x[1])) for (comment, key) in commentkeys: C.append( - '(%s) %s
    ' % (key, comment) + '(%s) %s
    ' % (key, comment) ) if commentkeys: C.append( - 'Gérer les opérations
    ' + 'Gérer les opérations
    ' % E["evaluation_id"] ) eval_info = "xxx" diff --git a/app/scodoc/sco_lycee.py b/app/scodoc/sco_lycee.py index 1530d0f1..1aba7b89 100644 --- a/app/scodoc/sco_lycee.py +++ b/app/scodoc/sco_lycee.py @@ -1,259 +1,259 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Rapports sur lycées d'origine des étudiants d'un semestre. - - statistiques decisions - - suivi cohortes -""" -from operator import itemgetter - -from flask import url_for, g, request - -import app -import app.scodoc.sco_utils as scu -from app.scodoc import html_sco_header -from app.scodoc import sco_formsemestre -from app.scodoc import sco_preferences -from app.scodoc import sco_report -from app.scodoc import sco_etud -import sco_version -from app.scodoc.gen_tables import GenTable - - -def formsemestre_table_etuds_lycees( - formsemestre_id, group_lycees=True, only_primo=False -): - """Récupère liste d'etudiants avec etat et decision.""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = sco_report.tsp_etud_list(formsemestre_id, only_primo=only_primo)[0] - if only_primo: - primostr = "primo-entrants du " - else: - primostr = "du " - title = "Lycées des étudiants %ssemestre " % primostr + sem["titreannee"] - return _table_etuds_lycees( - etuds, - group_lycees, - title, - sco_preferences.SemPreferences(formsemestre_id), - ) - - -def scodoc_table_etuds_lycees(format="html"): - """Table avec _tous_ les étudiants des semestres non verrouillés - de _tous_ les départements. - """ - cur_dept = g.scodoc_dept - semdepts = sco_formsemestre.scodoc_get_all_unlocked_sems() - etuds = [] - try: - for (sem, dept) in semdepts: - app.set_sco_dept(dept.acronym) - etuds += sco_report.tsp_etud_list(sem["formsemestre_id"])[0] - finally: - app.set_sco_dept(cur_dept) - - tab, etuds_by_lycee = _table_etuds_lycees( - etuds, - False, - "Lycées de TOUS les étudiants", - sco_preferences.SemPreferences(), - no_links=True, - ) - tab.base_url = request.base_url - t = tab.make_page(format=format, with_html_headers=False) - if format != "html": - return t - H = [ - html_sco_header.sco_header( - page_title=tab.page_title, - init_google_maps=True, - init_qtip=True, - javascripts=["js/etud_info.js", "js/map_lycees.js"], - ), - """

    Lycées d'origine des %d étudiants (%d semestres)

    """ - % (len(etuds), len(semdepts)), - t, - """
    - """, - js_coords_lycees(etuds_by_lycee), - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False): - etuds = [sco_etud.etud_add_lycee_infos(e) for e in etuds] - etuds_by_lycee = scu.group_by_key(etuds, "codelycee") - # - if group_lycees: - L = [etuds_by_lycee[codelycee][0] for codelycee in etuds_by_lycee] - for l in L: - l["nbetuds"] = len(etuds_by_lycee[l["codelycee"]]) - # L.sort( key=operator.itemgetter('codepostallycee', 'nomlycee') ) argh, only python 2.5+ !!! - L.sort(key=itemgetter("codepostallycee", "nomlycee")) - columns_ids = ( - "nbetuds", - "codelycee", - "codepostallycee", - "villelycee", - "nomlycee", - ) - bottom_titles = { - "nbetuds": len(etuds), - "nomlycee": "%d lycées" - % len([x for x in etuds_by_lycee if etuds_by_lycee[x][0]["codelycee"]]), - } - else: - L = etuds - columns_ids = ( - "civilite_str", - "nom", - "prenom", - "codelycee", - "codepostallycee", - "villelycee", - "nomlycee", - ) - bottom_titles = None - if not no_links: - for etud in etuds: - fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - etud["_nom_target"] = fiche_url - etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - - tab = GenTable( - columns_ids=columns_ids, - rows=L, - titles={ - "nbetuds": "Nb d'étudiants", - "civilite_str": "", - "nom": "Nom", - "prenom": "Prénom", - "etudid": "etudid", - "codelycee": "Code Lycée", - "codepostallycee": "Code postal", - "nomlycee": "Lycée", - "villelycee": "Commune", - }, - origin="Généré par %s le " % sco_version.SCONAME - + scu.timedate_human_repr() - + "", - caption=title, - page_title="Carte lycées d'origine", - html_sortable=True, - html_class="table_leftalign table_listegroupe", - bottom_titles=bottom_titles, - preferences=preferences, - ) - return tab, etuds_by_lycee - - -def formsemestre_etuds_lycees( - formsemestre_id, - format="html", - only_primo=False, - no_grouping=False, -): - """Table des lycées d'origine""" - tab, etuds_by_lycee = formsemestre_table_etuds_lycees( - formsemestre_id, only_primo=only_primo, group_lycees=not no_grouping - ) - tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) - if only_primo: - tab.base_url += "&only_primo=1" - if no_grouping: - tab.base_url += "&no_grouping=1" - t = tab.make_page(format=format, with_html_headers=False) - if format != "html": - return t - F = [ - sco_report.tsp_form_primo_group( - only_primo, no_grouping, formsemestre_id, format - ) - ] - H = [ - html_sco_header.sco_header( - page_title=tab.page_title, - init_google_maps=True, - init_qtip=True, - javascripts=["js/etud_info.js", "js/map_lycees.js"], - ), - """

    Lycées d'origine des étudiants

    """, - "\n".join(F), - t, - """
    - """, - js_coords_lycees(etuds_by_lycee), - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def qjs(txt): # quote for JS - return txt.replace("'", r"\'").replace('"', r"\"") - - -def js_coords_lycees(etuds_by_lycee): - """Formatte liste des lycees en JSON pour Google Map""" - L = [] - for codelycee in etuds_by_lycee: - if codelycee: - lyc = etuds_by_lycee[codelycee][0] - if not lyc.get("positionlycee", False): - continue - listeetuds = "
    %d étudiants: " % len( - etuds_by_lycee[codelycee] - ) + ", ".join( - [ - '%s' - % ( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=e["etudid"], - ), - qjs(e["nomprenom"]), - ) - for e in etuds_by_lycee[codelycee] - ] - ) - pos = qjs(lyc["positionlycee"]) - legend = "%s %s" % (qjs("%(nomlycee)s (%(villelycee)s)" % lyc), listeetuds) - L.append( - "{'position' : '%s', 'name' : '%s', 'number' : %d }" - % (pos, legend, len(etuds_by_lycee[codelycee])) - ) - - return """""" % ",".join( - L - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Rapports sur lycées d'origine des étudiants d'un semestre. + - statistiques decisions + - suivi cohortes +""" +from operator import itemgetter + +from flask import url_for, g, request + +import app +import app.scodoc.sco_utils as scu +from app.scodoc import html_sco_header +from app.scodoc import sco_formsemestre +from app.scodoc import sco_preferences +from app.scodoc import sco_report +from app.scodoc import sco_etud +import sco_version +from app.scodoc.gen_tables import GenTable + + +def formsemestre_table_etuds_lycees( + formsemestre_id, group_lycees=True, only_primo=False +): + """Récupère liste d'etudiants avec etat et decision.""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etuds = sco_report.tsp_etud_list(formsemestre_id, only_primo=only_primo)[0] + if only_primo: + primostr = "primo-entrants du " + else: + primostr = "du " + title = "Lycées des étudiants %ssemestre " % primostr + sem["titreannee"] + return _table_etuds_lycees( + etuds, + group_lycees, + title, + sco_preferences.SemPreferences(formsemestre_id), + ) + + +def scodoc_table_etuds_lycees(format="html"): + """Table avec _tous_ les étudiants des semestres non verrouillés + de _tous_ les départements. + """ + cur_dept = g.scodoc_dept + semdepts = sco_formsemestre.scodoc_get_all_unlocked_sems() + etuds = [] + try: + for (sem, dept) in semdepts: + app.set_sco_dept(dept.acronym) + etuds += sco_report.tsp_etud_list(sem["formsemestre_id"])[0] + finally: + app.set_sco_dept(cur_dept) + + tab, etuds_by_lycee = _table_etuds_lycees( + etuds, + False, + "Lycées de TOUS les étudiants", + sco_preferences.SemPreferences(), + no_links=True, + ) + tab.base_url = request.base_url + t = tab.make_page(format=format, with_html_headers=False) + if format != "html": + return t + H = [ + html_sco_header.sco_header( + page_title=tab.page_title, + init_google_maps=True, + init_qtip=True, + javascripts=["js/etud_info.js", "js/map_lycees.js"], + ), + """

    Lycées d'origine des %d étudiants (%d semestres)

    """ + % (len(etuds), len(semdepts)), + t, + """
    + """, + js_coords_lycees(etuds_by_lycee), + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False): + etuds = [sco_etud.etud_add_lycee_infos(e) for e in etuds] + etuds_by_lycee = scu.group_by_key(etuds, "codelycee") + # + if group_lycees: + L = [etuds_by_lycee[codelycee][0] for codelycee in etuds_by_lycee] + for l in L: + l["nbetuds"] = len(etuds_by_lycee[l["codelycee"]]) + # L.sort( key=operator.itemgetter('codepostallycee', 'nomlycee') ) argh, only python 2.5+ !!! + L.sort(key=itemgetter("codepostallycee", "nomlycee")) + columns_ids = ( + "nbetuds", + "codelycee", + "codepostallycee", + "villelycee", + "nomlycee", + ) + bottom_titles = { + "nbetuds": len(etuds), + "nomlycee": "%d lycées" + % len([x for x in etuds_by_lycee if etuds_by_lycee[x][0]["codelycee"]]), + } + else: + L = etuds + columns_ids = ( + "civilite_str", + "nom", + "prenom", + "codelycee", + "codepostallycee", + "villelycee", + "nomlycee", + ) + bottom_titles = None + if not no_links: + for etud in etuds: + fiche_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + etud["_nom_target"] = fiche_url + etud["_prenom_target"] = fiche_url + etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + + tab = GenTable( + columns_ids=columns_ids, + rows=L, + titles={ + "nbetuds": "Nb d'étudiants", + "civilite_str": "", + "nom": "Nom", + "prenom": "Prénom", + "etudid": "etudid", + "codelycee": "Code Lycée", + "codepostallycee": "Code postal", + "nomlycee": "Lycée", + "villelycee": "Commune", + }, + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + caption=title, + page_title="Carte lycées d'origine", + html_sortable=True, + html_class="table_leftalign table_listegroupe", + bottom_titles=bottom_titles, + preferences=preferences, + ) + return tab, etuds_by_lycee + + +def formsemestre_etuds_lycees( + formsemestre_id, + format="html", + only_primo=False, + no_grouping=False, +): + """Table des lycées d'origine""" + tab, etuds_by_lycee = formsemestre_table_etuds_lycees( + formsemestre_id, only_primo=only_primo, group_lycees=not no_grouping + ) + tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) + if only_primo: + tab.base_url += "&only_primo=1" + if no_grouping: + tab.base_url += "&no_grouping=1" + t = tab.make_page(format=format, with_html_headers=False) + if format != "html": + return t + F = [ + sco_report.tsp_form_primo_group( + only_primo, no_grouping, formsemestre_id, format + ) + ] + H = [ + html_sco_header.sco_header( + page_title=tab.page_title, + init_google_maps=True, + init_qtip=True, + javascripts=["js/etud_info.js", "js/map_lycees.js"], + ), + """

    Lycées d'origine des étudiants

    """, + "\n".join(F), + t, + """
    + """, + js_coords_lycees(etuds_by_lycee), + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def qjs(txt): # quote for JS + return txt.replace("'", r"\'").replace('"', r"\"") + + +def js_coords_lycees(etuds_by_lycee): + """Formatte liste des lycees en JSON pour Google Map""" + L = [] + for codelycee in etuds_by_lycee: + if codelycee: + lyc = etuds_by_lycee[codelycee][0] + if not lyc.get("positionlycee", False): + continue + listeetuds = "
    %d étudiants: " % len( + etuds_by_lycee[codelycee] + ) + ", ".join( + [ + '%s' + % ( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=e["etudid"], + ), + qjs(e["nomprenom"]), + ) + for e in etuds_by_lycee[codelycee] + ] + ) + pos = qjs(lyc["positionlycee"]) + legend = "%s %s" % (qjs("%(nomlycee)s (%(villelycee)s)" % lyc), listeetuds) + L.append( + "{'position' : '%s', 'name' : '%s', 'number' : %d }" + % (pos, legend, len(etuds_by_lycee[codelycee])) + ) + + return """""" % ",".join( + L + ) diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 6c409865..d15eb229 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -1,605 +1,605 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Opérations d'inscriptions aux modules (interface pour gérer options ou parcours) -""" -from operator import itemgetter - -import flask -from flask import url_for, g, request -from flask_login import current_user - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre - -import app.scodoc.notesdb as ndb -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc.scolog import logdb -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_cache -from app.scodoc import sco_edit_module -from app.scodoc import sco_edit_ue -from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_permissions import Permission - - -def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): - """Formulaire inscription des etudiants a ce module - * Gestion des inscriptions - Nom TD TA TP (triable) - [x] M. XXX YYY - - - - - - ajouter TD A, TD B, TP 1, TP 2 ... - supprimer TD A, TD B, TP 1, TP 2 ... - - * Si pas les droits: idem en readonly - """ - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - formsemestre_id = M["formsemestre_id"] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # -- check lock - if not sem["etat"]: - raise ScoValueError("opération impossible: semestre verrouille") - header = html_sco_header.sco_header( - page_title="Inscription au module", - init_qtip=True, - javascripts=["js/etud_info.js"], - ) - footer = html_sco_header.sco_footer() - H = [ - header, - """

    Inscriptions au module %s (%s)

    -

    Cette page permet d'éditer les étudiants inscrits à ce module - (ils doivent évidemment être inscrits au semestre). - Les étudiants cochés sont (ou seront) inscrits. Vous pouvez facilement inscrire ou - désinscrire tous les étudiants d'un groupe à l'aide des menus "Ajouter" et "Enlever". -

    -

    Aucune modification n'est prise en compte tant que l'on n'appuie pas sur le bouton - "Appliquer les modifications". -

    - """ - % ( - moduleimpl_id, - mod["titre"] or "(module sans titre)", - mod["code"] or "(module sans code)", - ), - ] - # Liste des inscrits à ce semestre - inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( - formsemestre_id - ) - for ins in inscrits: - etuds_info = sco_etud.get_etud_info(etudid=ins["etudid"], filled=1) - if not etuds_info: - log( - f"""moduleimpl_inscriptions_edit: inconsistency for etudid={ins['etudid']} !""" - ) - raise ScoValueError( - f"""Étudiant {ins['etudid']} inscrit mais inconnu dans la base !""" - ) - ins["etud"] = etuds_info[0] - inscrits.sort(key=lambda inscr: sco_etud.etud_sort_key(inscr["etud"])) - in_m = sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=M["moduleimpl_id"] - ) - in_module = set([x["etudid"] for x in in_m]) - # - partitions = sco_groups.get_partitions_list(formsemestre_id) - # - if not submitted: - H.append( - """""" - ) - H.append( - f"""
    - - -

    - - { _make_menu(partitions, "Ajouter", "true") } - { _make_menu(partitions, "Enlever", "false")} -
    -


    - - - - """ - ) - for partition in partitions: - if partition["partition_name"]: - H.append("" % partition["partition_name"]) - H.append("") - - for ins in inscrits: - etud = ins["etud"] - if etud["etudid"] in in_module: - checked = 'checked="checked"' - else: - checked = "" - H.append( - """""") - - groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id) - for partition in partitions: - if partition["partition_name"]: - gr_name = "" - for group in groups: - if group["partition_id"] == partition["partition_id"]: - gr_name = group["group_name"] - break - # gr_name == '' si etud non inscrit dans un groupe de cette partition - H.append(f"") - H.append("""
    Nom%s
    """ - % (etud["etudid"], checked) - ) - H.append( - """%s""" - % ( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etud["etudid"], - ), - etud["etudid"], - etud["nomprenom"], - ) - ) - H.append("""{gr_name}
    """) - else: # SUBMISSION - # inscrit a ce module tous les etuds selectionnes - sco_moduleimpl.do_moduleimpl_inscrit_etuds( - moduleimpl_id, formsemestre_id, etuds, reset=True - ) - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - # - H.append(footer) - return "\n".join(H) - - -def _make_menu(partitions: list[dict], title="", check="true") -> str: - """Menu with list of all groups""" - items = [{"title": "Tous", "attr": "onclick=\"group_select('', -1, %s)\"" % check}] - p_idx = 0 - for partition in partitions: - if partition["partition_name"] != None: - p_idx += 1 - for group in sco_groups.get_partition_groups(partition): - items.append( - { - "title": "%s %s" - % (partition["partition_name"], group["group_name"]), - "attr": "onclick=\"group_select('%s', %s, %s)\"" - % (group["group_name"], p_idx, check), - } - ) - return ( - '' - + htmlutils.make_menu(title, items, alone=True) - + "" - ) - - -def moduleimpl_inscriptions_stats(formsemestre_id): - """Affiche quelques informations sur les inscriptions - aux modules de ce semestre. - - Inscrits au semestre: - - Modules communs (tous inscrits): : () - ... - - - descriptions: - groupes de TD A, B et C - tous sauf groupe de TP Z (?) - tous sauf - - """ - authuser = current_user - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - is_apc = formsemestre.formation.is_apc() - inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - set_all = set([x["etudid"] for x in inscrits]) - partitions, _ = sco_groups.get_formsemestre_groups(formsemestre_id) - - can_change = ( - authuser.has_permission(Permission.ScoEtudInscrit) and formsemestre.etat - ) - - # Décrit les inscriptions aux modules: - commons = [] # modules communs a tous les etuds du semestre - options = [] # modules ou seuls quelques etudiants sont inscrits - mod_description = {} # modimplid : str - mod_nb_inscrits = {} # modimplid : int - if is_apc: - modimpls = sorted(formsemestre.modimpls, key=lambda m: m.module.sort_key_apc()) - else: - modimpls = formsemestre.modimpls_sorted - for modimpl in modimpls: - tous_inscrits, nb_inscrits, descr = descr_inscrs_module( - modimpl.id, - set_all, - partitions, - ) - if tous_inscrits: - commons.append(modimpl) - else: - mod_description[modimpl.id] = descr - mod_nb_inscrits[modimpl.id] = nb_inscrits - options.append(modimpl) - - # Page HTML: - H = [html_sco_header.html_sem_header("Inscriptions aux modules du semestre")] - - H.append(f"

    Inscrits au semestre: {len(inscrits)} étudiants

    ") - - if options: - H.append("

    Modules auxquels tous les étudiants ne sont pas inscrits:

    ") - H.append( - '' - ) - for modimpl in options: - if can_change: - c_link = f"""{mod_description[modimpl.id] or "(inscrire des étudiants)"} - """ - else: - c_link = mod_description[modimpl.id] - H.append( - f"""""" - ) - H.append("
    UECodeInscrits
    { - modimpl.module.ue.acronyme or "" - }{ - modimpl.module.code or "(module sans code)" - }{ - mod_nb_inscrits[modimpl.id]}{c_link}
    ") - else: - H.append( - """Tous les étudiants sont inscrits à tous les modules.""" - ) - - if commons: - H.append( - """

    Modules communs (auxquels tous les étudiants sont inscrits):

    - - - """ - ) - if is_apc: - H.append("") - H.append("""""") - for modimpl in commons: - if can_change: - c_link = f"""{modimpl.module.titre}""" - else: - c_link = modimpl.module.titre - H.append( - f"""""" - ) - if is_apc: - H.append( - f"""""" - ) - H.append("") - H.append("
    UECodeModuleParcours
    { - modimpl.module.ue.acronyme or "" - }{ - modimpl.module.code or "(module sans code)" - }{c_link}{', '.join(p.code for p in modimpl.module.parcours)}
    ") - - # Etudiants "dispensés" d'une UE (capitalisée) - UECaps = get_etuds_with_capitalized_ue(formsemestre_id) - if UECaps: - H.append('

    Etudiants avec UEs capitalisées:

      ') - ues = [sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in UECaps.keys()] - ues.sort(key=lambda u: u["numero"]) - for ue in ues: - H.append( - '
    • %(acronyme)s: %(titre)s' % ue - ) - H.append("
        ") - for info in UECaps[ue["ue_id"]]: - etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0] - H.append( - '
      • %s' - % ( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etud["etudid"], - ), - etud["nomprenom"], - ) - ) - if info["ue_status"]["event_date"]: - H.append( - "(cap. le %s)" - % (info["ue_status"]["event_date"]).strftime("%d/%m/%Y") - ) - - if info["is_ins"]: - dm = ", ".join( - [ - m["code"] or m["abbrev"] or "pas_de_code" - for m in info["is_ins"] - ] - ) - H.append( - 'actuellement inscrit dans %d modules' - % (dm, len(info["is_ins"])) - ) - if info["ue_status"]["is_capitalized"]: - H.append( - """
        UE actuelle moins bonne que l'UE capitalisée
        """ - ) - else: - H.append( - """
        UE actuelle meilleure que l'UE capitalisée
        """ - ) - if can_change: - H.append( - '' - % (etud["etudid"], formsemestre_id, ue["ue_id"]) - ) - else: - H.append("(non réinscrit dans cette UE)") - if can_change: - H.append( - '' - % (etud["etudid"], formsemestre_id, ue["ue_id"]) - ) - H.append("
      • ") - H.append("
    • ") - H.append("
    ") - - H.append( - """

    Cette page décrit les inscriptions actuelles. - Vous pouvez changer (si vous en avez le droit) les inscrits dans chaque module en - cliquant sur la ligne du module.

    -

    Note: la déinscription d'un module ne perd pas les notes. Ainsi, si - l'étudiant est ensuite réinscrit au même module, il retrouvera ses notes.

    - """ - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def descr_inscrs_module(moduleimpl_id, set_all, partitions): - """returns tous_inscrits, nb_inscrits, descr""" - ins = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=moduleimpl_id) - set_m = set([x["etudid"] for x in ins]) # ens. des inscrits au module - non_inscrits = set_all - set_m - if len(non_inscrits) == 0: - return True, len(ins), "" # tous inscrits - if len(non_inscrits) <= 7: # seuil arbitraire - return False, len(ins), "tous sauf " + _fmt_etud_set(non_inscrits) - # Cherche les groupes: - gr = [] # [ ( partition_name , [ group_names ] ) ] - for partition in partitions: - grp = [] # groupe de cette partition - for group in sco_groups.get_partition_groups(partition): - members = sco_groups.get_group_members(group["group_id"]) - set_g = set([m["etudid"] for m in members]) - if set_g.issubset(set_m): - grp.append(group["group_name"]) - set_m = set_m - set_g - gr.append((partition["partition_name"], grp)) - # - d = [] - for (partition_name, grp) in gr: - if grp: - d.append("groupes de %s: %s" % (partition_name, ", ".join(grp))) - r = [] - if d: - r.append(", ".join(d)) - if set_m: - r.append(_fmt_etud_set(set_m)) - # - return False, len(ins), " et ".join(r) - - -def _fmt_etud_set(ins, max_list_size=7): - # max_list_size est le nombre max de noms d'etudiants listés - # au delà, on indique juste le nombre, sans les noms. - if len(ins) > max_list_size: - return "%d étudiants" % len(ins) - etuds = [] - for etudid in ins: - etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0]) - etuds.sort(key=itemgetter("nom")) - return ", ".join( - [ - '%s' - % ( - url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ), - etud["nomprenom"], - ) - for etud in etuds - ] - ) - - -def get_etuds_with_capitalized_ue(formsemestre_id): - """For each UE, computes list of students capitalizing the UE. - returns { ue_id : [ { infos } ] } - """ - UECaps = scu.DictDefault(defaultvalue=[]) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - ues = nt.get_ues_stat_dict() - for ue in ues: - for etud in inscrits: - ue_status = nt.get_etud_ue_status(etud["etudid"], ue["ue_id"]) - if ue_status and ue_status["was_capitalized"]: - UECaps[ue["ue_id"]].append( - { - "etudid": etud["etudid"], - "ue_status": ue_status, - "is_ins": is_inscrit_ue( - etud["etudid"], formsemestre_id, ue["ue_id"] - ), - } - ) - return UECaps - - -def is_inscrit_ue(etudid, formsemestre_id, ue_id): - """Modules de cette UE dans ce semestre - auxquels l'étudiant est inscrit. - """ - r = ndb.SimpleDictFetch( - """SELECT mod.id AS module_id, mod.* - FROM notes_moduleimpl mi, notes_modules mod, - notes_formsemestre sem, notes_moduleimpl_inscription i - WHERE sem.id = %(formsemestre_id)s - AND mi.formsemestre_id = sem.id - AND mod.id = mi.module_id - AND mod.ue_id = %(ue_id)s - AND i.moduleimpl_id = mi.id - AND i.etudid = %(etudid)s - ORDER BY mod.numero - """, - {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, - ) - return r - - -def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id): - """Desincrit l'etudiant de tous les modules de cette UE dans ce semestre.""" - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """DELETE FROM notes_moduleimpl_inscription - WHERE id IN ( - SELECT i.id FROM - notes_moduleimpl mi, notes_modules mod, - notes_formsemestre sem, notes_moduleimpl_inscription i - WHERE sem.id = %(formsemestre_id)s - AND mi.formsemestre_id = sem.id - AND mod.id = mi.module_id - AND mod.ue_id = %(ue_id)s - AND i.moduleimpl_id = mi.id - AND i.etudid = %(etudid)s - ) - """, - {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, - ) - logdb( - cnx, - method="etud_desinscrit_ue", - etudid=etudid, - msg="desinscription UE %s" % ue_id, - commit=False, - ) - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > desinscription etudiant des modules - - -def do_etud_inscrit_ue(etudid, formsemestre_id, ue_id): - """Incrit l'etudiant de tous les modules de cette UE dans ce semestre.""" - # Verifie qu'il est bien inscrit au semestre - insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id, "etudid": etudid} - ) - if not insem: - raise ScoValueError("%s n'est pas inscrit au semestre !" % etudid) - - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT mi.id - FROM notes_moduleimpl mi, notes_modules mod, notes_formsemestre sem - WHERE sem.id = %(formsemestre_id)s - AND mi.formsemestre_id = sem.id - AND mod.id = mi.module_id - AND mod.ue_id = %(ue_id)s - """, - {"formsemestre_id": formsemestre_id, "ue_id": ue_id}, - ) - res = cursor.dictfetchall() - for moduleimpl_id in [x["id"] for x in res]: - sco_moduleimpl.do_moduleimpl_inscription_create( - {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, - formsemestre_id=formsemestre_id, - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Opérations d'inscriptions aux modules (interface pour gérer options ou parcours) +""" +from operator import itemgetter + +import flask +from flask import url_for, g, request +from flask_login import current_user + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc.scolog import logdb +from app.scodoc import html_sco_header +from app.scodoc import htmlutils +from app.scodoc import sco_cache +from app.scodoc import sco_edit_module +from app.scodoc import sco_edit_ue +from app.scodoc import sco_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_permissions import Permission + + +def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): + """Formulaire inscription des etudiants a ce module + * Gestion des inscriptions + Nom TD TA TP (triable) + [x] M. XXX YYY - - - + + + ajouter TD A, TD B, TP 1, TP 2 ... + supprimer TD A, TD B, TP 1, TP 2 ... + + * Si pas les droits: idem en readonly + """ + M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] + formsemestre_id = M["formsemestre_id"] + mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # -- check lock + if not sem["etat"]: + raise ScoValueError("opération impossible: semestre verrouille") + header = html_sco_header.sco_header( + page_title="Inscription au module", + init_qtip=True, + javascripts=["js/etud_info.js"], + ) + footer = html_sco_header.sco_footer() + H = [ + header, + """

    Inscriptions au module %s (%s)

    +

    Cette page permet d'éditer les étudiants inscrits à ce module + (ils doivent évidemment être inscrits au semestre). + Les étudiants cochés sont (ou seront) inscrits. Vous pouvez facilement inscrire ou + désinscrire tous les étudiants d'un groupe à l'aide des menus "Ajouter" et "Enlever". +

    +

    Aucune modification n'est prise en compte tant que l'on n'appuie pas sur le bouton + "Appliquer les modifications". +

    + """ + % ( + moduleimpl_id, + mod["titre"] or "(module sans titre)", + mod["code"] or "(module sans code)", + ), + ] + # Liste des inscrits à ce semestre + inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( + formsemestre_id + ) + for ins in inscrits: + etuds_info = sco_etud.get_etud_info(etudid=ins["etudid"], filled=1) + if not etuds_info: + log( + f"""moduleimpl_inscriptions_edit: inconsistency for etudid={ins['etudid']} !""" + ) + raise ScoValueError( + f"""Étudiant {ins['etudid']} inscrit mais inconnu dans la base !""" + ) + ins["etud"] = etuds_info[0] + inscrits.sort(key=lambda inscr: sco_etud.etud_sort_key(inscr["etud"])) + in_m = sco_moduleimpl.do_moduleimpl_inscription_list( + moduleimpl_id=M["moduleimpl_id"] + ) + in_module = set([x["etudid"] for x in in_m]) + # + partitions = sco_groups.get_partitions_list(formsemestre_id) + # + if not submitted: + H.append( + """""" + ) + H.append( + f"""
    + + +

    + + { _make_menu(partitions, "Ajouter", "true") } + { _make_menu(partitions, "Enlever", "false")} +
    +


    + + + + """ + ) + for partition in partitions: + if partition["partition_name"]: + H.append("" % partition["partition_name"]) + H.append("") + + for ins in inscrits: + etud = ins["etud"] + if etud["etudid"] in in_module: + checked = 'checked="checked"' + else: + checked = "" + H.append( + """""") + + groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre_id) + for partition in partitions: + if partition["partition_name"]: + gr_name = "" + for group in groups: + if group["partition_id"] == partition["partition_id"]: + gr_name = group["group_name"] + break + # gr_name == '' si etud non inscrit dans un groupe de cette partition + H.append(f"") + H.append("""
    Nom%s
    """ + % (etud["etudid"], checked) + ) + H.append( + """%s""" + % ( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], + ), + etud["etudid"], + etud["nomprenom"], + ) + ) + H.append("""{gr_name}
    """) + else: # SUBMISSION + # inscrit a ce module tous les etuds selectionnes + sco_moduleimpl.do_moduleimpl_inscrit_etuds( + moduleimpl_id, formsemestre_id, etuds, reset=True + ) + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + # + H.append(footer) + return "\n".join(H) + + +def _make_menu(partitions: list[dict], title="", check="true") -> str: + """Menu with list of all groups""" + items = [{"title": "Tous", "attr": "onclick=\"group_select('', -1, %s)\"" % check}] + p_idx = 0 + for partition in partitions: + if partition["partition_name"] != None: + p_idx += 1 + for group in sco_groups.get_partition_groups(partition): + items.append( + { + "title": "%s %s" + % (partition["partition_name"], group["group_name"]), + "attr": "onclick=\"group_select('%s', %s, %s)\"" + % (group["group_name"], p_idx, check), + } + ) + return ( + '' + + htmlutils.make_menu(title, items, alone=True) + + "" + ) + + +def moduleimpl_inscriptions_stats(formsemestre_id): + """Affiche quelques informations sur les inscriptions + aux modules de ce semestre. + + Inscrits au semestre: + + Modules communs (tous inscrits): : () + ... + + + descriptions: + groupes de TD A, B et C + tous sauf groupe de TP Z (?) + tous sauf + + """ + authuser = current_user + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + is_apc = formsemestre.formation.is_apc() + inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + set_all = set([x["etudid"] for x in inscrits]) + partitions, _ = sco_groups.get_formsemestre_groups(formsemestre_id) + + can_change = ( + authuser.has_permission(Permission.ScoEtudInscrit) and formsemestre.etat + ) + + # Décrit les inscriptions aux modules: + commons = [] # modules communs a tous les etuds du semestre + options = [] # modules ou seuls quelques etudiants sont inscrits + mod_description = {} # modimplid : str + mod_nb_inscrits = {} # modimplid : int + if is_apc: + modimpls = sorted(formsemestre.modimpls, key=lambda m: m.module.sort_key_apc()) + else: + modimpls = formsemestre.modimpls_sorted + for modimpl in modimpls: + tous_inscrits, nb_inscrits, descr = descr_inscrs_module( + modimpl.id, + set_all, + partitions, + ) + if tous_inscrits: + commons.append(modimpl) + else: + mod_description[modimpl.id] = descr + mod_nb_inscrits[modimpl.id] = nb_inscrits + options.append(modimpl) + + # Page HTML: + H = [html_sco_header.html_sem_header("Inscriptions aux modules du semestre")] + + H.append(f"

    Inscrits au semestre: {len(inscrits)} étudiants

    ") + + if options: + H.append("

    Modules auxquels tous les étudiants ne sont pas inscrits:

    ") + H.append( + '' + ) + for modimpl in options: + if can_change: + c_link = f"""{mod_description[modimpl.id] or "(inscrire des étudiants)"} + """ + else: + c_link = mod_description[modimpl.id] + H.append( + f"""""" + ) + H.append("
    UECodeInscrits
    { + modimpl.module.ue.acronyme or "" + }{ + modimpl.module.code or "(module sans code)" + }{ + mod_nb_inscrits[modimpl.id]}{c_link}
    ") + else: + H.append( + """Tous les étudiants sont inscrits à tous les modules.""" + ) + + if commons: + H.append( + """

    Modules communs (auxquels tous les étudiants sont inscrits):

    + + + """ + ) + if is_apc: + H.append("") + H.append("""""") + for modimpl in commons: + if can_change: + c_link = f"""{modimpl.module.titre}""" + else: + c_link = modimpl.module.titre + H.append( + f"""""" + ) + if is_apc: + H.append( + f"""""" + ) + H.append("") + H.append("
    UECodeModuleParcours
    { + modimpl.module.ue.acronyme or "" + }{ + modimpl.module.code or "(module sans code)" + }{c_link}{', '.join(p.code for p in modimpl.module.parcours)}
    ") + + # Etudiants "dispensés" d'une UE (capitalisée) + UECaps = get_etuds_with_capitalized_ue(formsemestre_id) + if UECaps: + H.append('

    Etudiants avec UEs capitalisées:

      ') + ues = [sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in UECaps.keys()] + ues.sort(key=lambda u: u["numero"]) + for ue in ues: + H.append( + '
    • %(acronyme)s: %(titre)s' % ue + ) + H.append("
        ") + for info in UECaps[ue["ue_id"]]: + etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0] + H.append( + '
      • %s' + % ( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], + ), + etud["nomprenom"], + ) + ) + if info["ue_status"]["event_date"]: + H.append( + "(cap. le %s)" + % (info["ue_status"]["event_date"]).strftime("%d/%m/%Y") + ) + + if info["is_ins"]: + dm = ", ".join( + [ + m["code"] or m["abbrev"] or "pas_de_code" + for m in info["is_ins"] + ] + ) + H.append( + 'actuellement inscrit dans %d modules' + % (dm, len(info["is_ins"])) + ) + if info["ue_status"]["is_capitalized"]: + H.append( + """
        UE actuelle moins bonne que l'UE capitalisée
        """ + ) + else: + H.append( + """
        UE actuelle meilleure que l'UE capitalisée
        """ + ) + if can_change: + H.append( + '' + % (etud["etudid"], formsemestre_id, ue["ue_id"]) + ) + else: + H.append("(non réinscrit dans cette UE)") + if can_change: + H.append( + '' + % (etud["etudid"], formsemestre_id, ue["ue_id"]) + ) + H.append("
      • ") + H.append("
    • ") + H.append("
    ") + + H.append( + """

    Cette page décrit les inscriptions actuelles. + Vous pouvez changer (si vous en avez le droit) les inscrits dans chaque module en + cliquant sur la ligne du module.

    +

    Note: la déinscription d'un module ne perd pas les notes. Ainsi, si + l'étudiant est ensuite réinscrit au même module, il retrouvera ses notes.

    + """ + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def descr_inscrs_module(moduleimpl_id, set_all, partitions): + """returns tous_inscrits, nb_inscrits, descr""" + ins = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=moduleimpl_id) + set_m = set([x["etudid"] for x in ins]) # ens. des inscrits au module + non_inscrits = set_all - set_m + if len(non_inscrits) == 0: + return True, len(ins), "" # tous inscrits + if len(non_inscrits) <= 7: # seuil arbitraire + return False, len(ins), "tous sauf " + _fmt_etud_set(non_inscrits) + # Cherche les groupes: + gr = [] # [ ( partition_name , [ group_names ] ) ] + for partition in partitions: + grp = [] # groupe de cette partition + for group in sco_groups.get_partition_groups(partition): + members = sco_groups.get_group_members(group["group_id"]) + set_g = set([m["etudid"] for m in members]) + if set_g.issubset(set_m): + grp.append(group["group_name"]) + set_m = set_m - set_g + gr.append((partition["partition_name"], grp)) + # + d = [] + for (partition_name, grp) in gr: + if grp: + d.append("groupes de %s: %s" % (partition_name, ", ".join(grp))) + r = [] + if d: + r.append(", ".join(d)) + if set_m: + r.append(_fmt_etud_set(set_m)) + # + return False, len(ins), " et ".join(r) + + +def _fmt_etud_set(ins, max_list_size=7): + # max_list_size est le nombre max de noms d'etudiants listés + # au delà, on indique juste le nombre, sans les noms. + if len(ins) > max_list_size: + return "%d étudiants" % len(ins) + etuds = [] + for etudid in ins: + etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0]) + etuds.sort(key=itemgetter("nom")) + return ", ".join( + [ + '%s' + % ( + url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ), + etud["nomprenom"], + ) + for etud in etuds + ] + ) + + +def get_etuds_with_capitalized_ue(formsemestre_id): + """For each UE, computes list of students capitalizing the UE. + returns { ue_id : [ { infos } ] } + """ + UECaps = scu.DictDefault(defaultvalue=[]) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + ues = nt.get_ues_stat_dict() + for ue in ues: + for etud in inscrits: + ue_status = nt.get_etud_ue_status(etud["etudid"], ue["ue_id"]) + if ue_status and ue_status["was_capitalized"]: + UECaps[ue["ue_id"]].append( + { + "etudid": etud["etudid"], + "ue_status": ue_status, + "is_ins": is_inscrit_ue( + etud["etudid"], formsemestre_id, ue["ue_id"] + ), + } + ) + return UECaps + + +def is_inscrit_ue(etudid, formsemestre_id, ue_id): + """Modules de cette UE dans ce semestre + auxquels l'étudiant est inscrit. + """ + r = ndb.SimpleDictFetch( + """SELECT mod.id AS module_id, mod.* + FROM notes_moduleimpl mi, notes_modules mod, + notes_formsemestre sem, notes_moduleimpl_inscription i + WHERE sem.id = %(formsemestre_id)s + AND mi.formsemestre_id = sem.id + AND mod.id = mi.module_id + AND mod.ue_id = %(ue_id)s + AND i.moduleimpl_id = mi.id + AND i.etudid = %(etudid)s + ORDER BY mod.numero + """, + {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, + ) + return r + + +def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id): + """Desincrit l'etudiant de tous les modules de cette UE dans ce semestre.""" + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """DELETE FROM notes_moduleimpl_inscription + WHERE id IN ( + SELECT i.id FROM + notes_moduleimpl mi, notes_modules mod, + notes_formsemestre sem, notes_moduleimpl_inscription i + WHERE sem.id = %(formsemestre_id)s + AND mi.formsemestre_id = sem.id + AND mod.id = mi.module_id + AND mod.ue_id = %(ue_id)s + AND i.moduleimpl_id = mi.id + AND i.etudid = %(etudid)s + ) + """, + {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, + ) + logdb( + cnx, + method="etud_desinscrit_ue", + etudid=etudid, + msg="desinscription UE %s" % ue_id, + commit=False, + ) + sco_cache.invalidate_formsemestre( + formsemestre_id=formsemestre_id + ) # > desinscription etudiant des modules + + +def do_etud_inscrit_ue(etudid, formsemestre_id, ue_id): + """Incrit l'etudiant de tous les modules de cette UE dans ce semestre.""" + # Verifie qu'il est bien inscrit au semestre + insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id, "etudid": etudid} + ) + if not insem: + raise ScoValueError("%s n'est pas inscrit au semestre !" % etudid) + + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT mi.id + FROM notes_moduleimpl mi, notes_modules mod, notes_formsemestre sem + WHERE sem.id = %(formsemestre_id)s + AND mi.formsemestre_id = sem.id + AND mod.id = mi.module_id + AND mod.ue_id = %(ue_id)s + """, + {"formsemestre_id": formsemestre_id, "ue_id": ue_id}, + ) + res = cursor.dictfetchall() + for moduleimpl_id in [x["id"] for x in res]: + sco_moduleimpl.do_moduleimpl_inscription_create( + {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, + formsemestre_id=formsemestre_id, + ) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 12828e87..220e9416 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -189,7 +189,7 @@ def ficheEtud(etudid=None): else: info["paysdomicile"] = "" if info["telephone"] or info["telephonemobile"]: - info["telephones"] = "
    %s    %s" % ( + info["telephones"] = "
    %s    %s" % ( info["telephonestr"], info["telephonemobilestr"], ) @@ -506,9 +506,9 @@ def ficheEtud(etudid=None): Ajouter une annotation sur %(nomprenom)s: diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py index e67865f8..898bd31b 100644 --- a/app/scodoc/sco_placement.py +++ b/app/scodoc/sco_placement.py @@ -1,637 +1,637 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""ScoDoc: génération feuille émargement et placement - -Contribution J.-M. Place 2021 -basée sur une idée de M. Salomon, UFC / IUT DE BELFORT-MONTBÉLIARD, 2016 - -""" -import random -import time -from copy import copy - -import wtforms.validators -from flask import request, render_template -from flask_login import current_user -from flask_wtf import FlaskForm -from openpyxl.styles import PatternFill, Alignment, Border, Side, Font -from wtforms import ( - StringField, - SubmitField, - SelectField, - RadioField, - HiddenField, - SelectMultipleField, -) -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import ScoValueError -from app.scodoc import html_sco_header, sco_preferences -from app.scodoc import sco_edit_module -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_excel -from app.scodoc.sco_excel import ScoExcelBook, COLORS -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_permissions_check -from app.scodoc.gen_tables import GenTable -from app.scodoc import sco_etud -import sco_version - -_ = lambda x: x # sans babel -_l = _ - -COORD = "Coordonnées" -SEQ = "Continue" - -TOUS = "Tous" - - -def _get_group_info(evaluation_id): - # groupes - groups = sco_groups.do_evaluation_listegroupes(evaluation_id, include_default=True) - has_groups = False - groups_tree = {} - for group in groups: - partition = group["partition_name"] or TOUS - group_id = group["group_id"] - group_name = group["group_name"] or TOUS - if partition not in groups_tree: - groups_tree[partition] = {} - groups_tree[partition][group_name] = group_id - if partition != TOUS: - has_groups = True - else: - has_groups = False - nb_groups = sum([len(groups_tree[p]) for p in groups_tree]) - return groups_tree, has_groups, nb_groups - - -class PlacementForm(FlaskForm): - """Formulaire pour placement des étudiants en Salle""" - - evaluation_id = HiddenField("evaluation_id") - file_format = RadioField( - "Format de fichier", - choices=["pdf", "xls"], - validators=[ - wtforms.validators.DataRequired("indiquez le format du fichier attendu"), - ], - ) - surveillants = StringField("Surveillants", validators=[]) - batiment = StringField("Batiment") - salle = StringField("Salle") - nb_rangs = SelectField( - "nb de places en largeur", - coerce=int, - choices=[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], - description="largeur de la salle, en nombre de places", - ) - etiquetage = RadioField( - "Numérotation", - choices=[SEQ, COORD], - validators=[ - wtforms.validators.DataRequired("indiquez le style de numérotation"), - ], - ) - groups = SelectMultipleField( - "Groupe(s)", - validators=[], - ) - submit = SubmitField("OK") - - def __init__(self, formdata=None, data=None): - super().__init__(formdata=formdata, data=data) - self.groups_tree = {} - self.has_groups = None - self.nb_groups = None - self.tous_id = None - self.set_evaluation_infos(data["evaluation_id"]) - - def set_evaluation_infos(self, evaluation_id): - """Initialise les données du formulaire avec les données de l'évaluation.""" - eval_data = sco_evaluation_db.do_evaluation_list( - {"evaluation_id": evaluation_id} - ) - if not eval_data: - raise ScoValueError("invalid evaluation_id") - self.groups_tree, self.has_groups, self.nb_groups = _get_group_info( - evaluation_id - ) - choices = [] - for partition in self.groups_tree: - for groupe in self.groups_tree[partition]: - if ( - groupe == TOUS - ): # Affichage et valeur spécifique pour le groupe TOUS - self.tous_id = str(self.groups_tree[partition][groupe]) - choices.append((TOUS, TOUS)) - else: - groupe_id = str(self.groups_tree[partition][groupe]) - choices.append((groupe_id, "%s (%s)" % (str(groupe), partition))) - self.groups.choices = choices - # self.groups.default = [TOUS] # Ne fonctionnne pas... (ni dans la déclaration de PlaceForm.groups) - # la réponse [] est de toute façon transposée en [ self.tous_id ] lors du traitement (cas du groupe unique) - - -class _DistributeurContinu: - """Distribue les places selon un ordre numérique.""" - - def __init__(self): - self.position = 1 - - def suivant(self): - """Retounre la désignation de la place suivante""" - retour = self.position - self.position += 1 - return retour - - -class _Distributeur2D: - """Distribue les places selon des coordonnées sur nb_rangs.""" - - def __init__(self, nb_rangs): - self.nb_rangs = nb_rangs - self.rang = 1 - self.index = 1 - - def suivant(self): - """Retounre la désignation de la place suivante""" - retour = (self.index, self.rang) - self.rang += 1 - if self.rang > self.nb_rangs: - self.rang = 1 - self.index += 1 - return retour - - -def placement_eval_selectetuds(evaluation_id): - """Creation de l'écran de placement""" - form = PlacementForm( - request.form, - data={"evaluation_id": int(evaluation_id), "groups": TOUS}, - ) - if form.validate_on_submit(): - runner = PlacementRunner(form) - if not runner.check_placement(): - return ( - """

    Génération du placement impossible pour %s

    -

    (vérifiez que le semestre n'est pas verrouillé et que vous - avez l'autorisation d'effectuer cette opération)

    -

    Continuer

    - """ - % runner.__dict__ - ) - return runner.exec_placement() # calcul et generation du fichier - htmls = [ - html_sco_header.sco_header(), - sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), - "

    Placement et émargement des étudiants

    ", - render_template("scodoc/forms/placement.html", form=form), - ] - footer = html_sco_header.sco_footer() - return "\n".join(htmls) + "

    " + footer - - -class PlacementRunner: - """Execution de l'action définie par le formulaire""" - - def __init__(self, form): - """Calcul et génération du fichier sur la base des données du formulaire""" - self.evaluation_id = form["evaluation_id"].data - self.etiquetage = form["etiquetage"].data - self.surveillants = form["surveillants"].data - self.batiment = form["batiment"].data - self.salle = form["salle"].data - self.nb_rangs = form["nb_rangs"].data - self.file_format = form["file_format"].data - if len(form["groups"].data) == 0: - self.groups_ids = [form.tous_id] - else: # On remplace le mot-clé TOUS le l'identiant de ce groupe - self.groups_ids = [ - gid if gid != TOUS else form.tous_id for gid in form["groups"].data - ] - self.eval_data = sco_evaluation_db.do_evaluation_list( - {"evaluation_id": self.evaluation_id} - )[0] - self.groups = sco_groups.listgroups(self.groups_ids) - self.gr_title_filename = sco_groups.listgroups_filename(self.groups) - # gr_title = sco_groups.listgroups_abbrev(d['groups']) - self.current_user = current_user - self.moduleimpl_id = self.eval_data["moduleimpl_id"] - self.moduleimpl_data = sco_moduleimpl.moduleimpl_list( - moduleimpl_id=self.moduleimpl_id - )[0] - self.module_data = sco_edit_module.module_list( - args={"module_id": self.moduleimpl_data["module_id"]} - )[0] - self.sem = sco_formsemestre.get_formsemestre( - self.moduleimpl_data["formsemestre_id"] - ) - self.evalname = "%s-%s" % ( - self.module_data["code"] or "?", - ndb.DateDMYtoISO(self.eval_data["jour"]), - ) - if self.eval_data["description"]: - self.evaltitre = self.eval_data["description"] - else: - self.evaltitre = "évaluation du %s" % self.eval_data["jour"] - self.desceval = [ # une liste de chaines: description de l'evaluation - "%s" % self.sem["titreannee"], - "Module : %s - %s" - % (self.module_data["code"] or "?", self.module_data["abbrev"] or ""), - "Surveillants : %s" % self.surveillants, - "Batiment : %(batiment)s - Salle : %(salle)s" % self.__dict__, - "Controle : %s (coef. %g)" - % (self.evaltitre, self.eval_data["coefficient"]), - ] - self.styles = None - self.plan = None - self.listetud = None - - def check_placement(self): - """Vérifie que l'utilisateur courant a le droit d'édition sur les notes""" - # Check access (admin, respformation, and responsable_id) - return sco_permissions_check.can_edit_notes( - self.current_user, self.moduleimpl_id - ) - - def exec_placement(self): - """Excéute l'action liée au formulaire""" - self._repartition() - if self.file_format == "xls": - return self._production_xls() - return self._production_pdf() - - def _repartition(self): - """ - Calcule le placement. retourne une liste de couples ((nom, prenom), position) - """ - # Construit liste des etudiants et les réparti - self.groups = sco_groups.listgroups(self.groups_ids) - self.listetud = self._build_listetud() - self.plan = self._affectation_places() - - def _build_listetud(self): - get_all_students = None in [ - g["group_name"] for g in self.groups - ] # tous les etudiants - etudid_etats = sco_groups.do_evaluation_listeetuds_groups( - self.evaluation_id, - self.groups, - getallstudents=get_all_students, - include_demdef=True, - ) - listetud = [] # liste de couples (nom,prenom) - for etudid, etat in etudid_etats: - # infos identite etudiant (xxx sous-optimal: 1/select par etudiant) - ident = sco_etud.etudident_list(ndb.GetDBConnexion(), {"etudid": etudid})[0] - if etat != "D": - nom = ident["nom"].upper() - prenom = ident["prenom"].lower().capitalize() - etudid = ident["etudid"] - listetud.append((nom, prenom, etudid)) - random.shuffle(listetud) - return listetud - - def _affectation_places(self): - plan = [] - if self.etiquetage == SEQ: - distributeur = _DistributeurContinu() - else: - distributeur = _Distributeur2D(self.nb_rangs) - for etud in self.listetud: - plan.append((etud, distributeur.suivant())) - return plan - - def _production_xls(self): - filename = "placement_%s_%s" % (self.evalname, self.gr_title_filename) - xls = self._excel_feuille_placement() - return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) - - def _production_pdf(self): - pdf_title = "
    ".join(self.desceval) - pdf_title += ( - "\nDate : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" - % self.eval_data - ) - filename = "placement_%(evalname)s_%(gr_title_filename)s" % self.__dict__ - titles = { - "nom": "Nom", - "prenom": "Prenom", - "colonne": "Colonne", - "ligne": "Ligne", - "place": "Place", - } - if self.etiquetage == COORD: - columns_ids = ["nom", "prenom", "colonne", "ligne"] - else: - columns_ids = ["nom", "prenom", "place"] - - rows = [] - for etud in sorted(self.plan, key=lambda item: item[0][0]): # sort by name - if self.etiquetage == COORD: - rows.append( - { - "nom": etud[0][0], - "prenom": etud[0][1], - "colonne": etud[1][0], - "ligne": etud[1][1], - } - ) - else: - rows.append({"nom": etud[0][0], "prenom": etud[0][1], "place": etud[1]}) - tab = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=rows, - filename=filename, - origin="Généré par %s le " % sco_version.SCONAME - + scu.timedate_human_repr() - + "", - pdf_title=pdf_title, - # pdf_shorttitle = '', - preferences=sco_preferences.SemPreferences( - self.moduleimpl_data["formsemestre_id"] - ), - ) - return tab.make_page(format="pdf", with_html_headers=False) - - def _one_header(self, worksheet): - cells = [ - worksheet.make_cell("Nom", self.styles["2bi"]), - worksheet.make_cell("Prénom", self.styles["2bi"]), - ] - if self.etiquetage == COORD: - cells.append(worksheet.make_cell("Colonne", self.styles["2bi"])) - cells.append(worksheet.make_cell("Ligne", self.styles["2bi"])) - else: - cells.append(worksheet.make_cell("Place", self.styles["2bi"])) - return cells - - def _headers(self, worksheet, nb_listes): - cells = [] - for _ in range(nb_listes): - cells += self._one_header(worksheet) - cells.append(worksheet.make_cell("")) - worksheet.append_row(cells) - - def _make_styles(self, ws0, ws1): - # polices - font0 = Font(name="Calibri", bold=True, size=12) - font1b = copy(font0) - font1b.size = 9 - font1i = Font(name="Arial", italic=True, size=10) - font1o = Font(name="Arial", outline=True, size=10) - font2bi = Font(name="Arial", bold=True, italic=True, size=8) - font2 = Font(name="Arial", size=10) - - # bordures - side_double = Side(border_style="double", color=COLORS.BLACK.value) - side_thin = Side(border_style="thin", color=COLORS.BLACK.value) - - # bordures - border1t = Border(left=side_double, top=side_double, right=side_double) - border1bb = Border(left=side_double, bottom=side_double, right=side_double) - border1bm = Border(left=side_double, right=side_double) - border1m = Border(left=side_double, bottom=side_thin, right=side_double) - border2m = Border(top=side_thin, bottom=side_thin) - border2r = Border(top=side_thin, bottom=side_thin, right=side_thin) - border2l = Border(left=side_thin, top=side_thin, bottom=side_thin) - border2b = Border( - left=side_thin, top=side_thin, bottom=side_thin, right=side_thin - ) - - # alignements - align_center_center = Alignment(horizontal="center", vertical="center") - align_right_bottom = Alignment(horizontal="right", vertical="bottom") - align_left_center = Alignment(horizontal="left", vertical="center") - align_right_center = Alignment(horizontal="right", vertical="center") - - # patterns - pattern = PatternFill( - fill_type="solid", fgColor=sco_excel.COLORS.LIGHT_YELLOW.value - ) - - # styles - self.styles = { - "titres": sco_excel.excel_make_style(font_name="Arial", bold=True, size=12), - "1t": ws0.excel_make_composite_style( - font=font0, alignment=align_center_center, border=border1t - ), - "1m": ws0.excel_make_composite_style( - font=font1b, alignment=align_center_center, border=border1m - ), - "1bm": ws0.excel_make_composite_style( - font=font1b, alignment=align_center_center, border=border1bm - ), - "1bb": ws0.excel_make_composite_style( - font=font1o, alignment=align_right_bottom, border=border1bb - ), - "2b": ws1.excel_make_composite_style( - font=font1i, alignment=align_center_center, border=border2b - ), - "2bi": ws1.excel_make_composite_style( - font=font2bi, - alignment=align_center_center, - border=border2b, - fill=pattern, - ), - "2l": ws1.excel_make_composite_style( - font=font2, alignment=align_left_center, border=border2l - ), - "2m1": ws1.excel_make_composite_style( - font=font2, alignment=align_left_center, border=border2m - ), - "2m2": ws1.excel_make_composite_style( - font=font2, alignment=align_right_center, border=border2m - ), - "2r": ws1.excel_make_composite_style( - font=font2, alignment=align_right_center, border=border2r - ), - } - - def _titres(self, worksheet): - datetime = time.strftime("%d/%m/%Y a %Hh%M") - worksheet.append_single_cell_row( - "Feuille placement etudiants éditée le %s" % datetime, self.styles["titres"] - ) - for line, desceval in enumerate(self.desceval): - if line in [1, 4, 7]: - worksheet.append_blank_row() - worksheet.append_single_cell_row(desceval, self.styles["titres"]) - worksheet.append_single_cell_row( - "Date : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" - % self.eval_data, - self.styles["titres"], - ) - - def _feuille0(self, ws0, space): - self._titres(ws0) - # entetes colonnes - feuille0 - cells = [ws0.make_cell()] - for col in range(self.nb_rangs): - cells.append(ws0.make_cell("colonne %s" % (col + 1), self.styles["2b"])) - ws0.append_row(cells) - - # etudiants - feuille0 - place = 1 - col = 0 - rang = 1 - # Chaque rang est affiché sur 3 lignes xlsx (notées A, B, C) - # ligne A: le nom, ligne B: le prénom, ligne C: un espace ou la place - cells_a = [ws0.make_cell(rang, self.styles["2b"])] - cells_b = [ws0.make_cell("", self.styles["2b"])] - cells_c = [ws0.make_cell("", self.styles["2b"])] - row = 13 # première ligne de signature - rang += 1 - for linetud in self.plan: - cells_a.append(ws0.make_cell(linetud[0][0], self.styles["1t"])) # nom - cells_b.append(ws0.make_cell(linetud[0][1], self.styles["1m"])) # prenom - if self.etiquetage == COORD: - cell_c = ws0.make_cell("", self.styles["1bb"]) - else: - cell_c = ws0.make_cell("place %s" % place, self.styles["1bb"]) - place = place + 1 - cells_c.append(cell_c) - ws0.set_row_dimension_height(row, space / 25) - row += 3 - col += 1 - if col == self.nb_rangs: # On a fini la rangée courante - ws0.append_row(cells_a) # on affiche les 3 lignes construites - ws0.append_row(cells_b) - ws0.append_row(cells_c) - cells_a = [ - ws0.make_cell(rang, self.styles["2b"]) - ] # on réinitialise les 3 lignes - cells_b = [ws0.make_cell("", self.styles["2b"])] - cells_c = [ws0.make_cell("", self.styles["2b"])] - col = 0 - rang += 1 - # publication du rang final incomplet - ws0.append_row(cells_a) # Affiche des 3 lignes (dernières lignes incomplètes) - ws0.append_row(cells_b) - ws0.append_row(cells_c) - ws0.set_row_dimension_height(row, space / 25) - - def _feuille1(self, worksheet, maxlines): - # etudiants - feuille1 - # structuration: - # 1 page = maxlistes listes - # 1 liste = 3 ou 4 colonnes(excel) (selon numbering) et (maximum maxlines) lignes - maxlistes = 2 # nombre de listes par page - # computes excel columns widths - if self.etiquetage == COORD: - gabarit = [16, 18, 6, 6, 2] - else: - gabarit = [16, 18, 12, 2] - widths = [] - for _ in range(maxlistes): - widths += gabarit - worksheet.set_column_dimension_width(value=widths) - nb_etu_restant = len(self.listetud) - self._titres(worksheet) - nb_listes = min( - maxlistes, nb_etu_restant // maxlines + 1 - ) # nombre de colonnes dans la page - self._headers(worksheet, nb_listes) - # construction liste alphabétique - # Affichage - lines = [[] for _ in range(maxlines)] - lineno = 0 - col = 0 - for etud in sorted(self.plan, key=lambda e: e[0][0]): # tri alphabétique - # check for skip of list or page - if col > 0: # add a empty cell between lists - lines[lineno].append(worksheet.make_cell()) - lines[lineno].append(worksheet.make_cell(etud[0][0], self.styles["2l"])) - lines[lineno].append(worksheet.make_cell(etud[0][1], self.styles["2m1"])) - if self.etiquetage == COORD: - lines[lineno].append( - worksheet.make_cell(etud[1][1], self.styles["2m2"]) - ) - lines[lineno].append(worksheet.make_cell(etud[1][0], self.styles["2r"])) - else: - lines[lineno].append(worksheet.make_cell(etud[1], self.styles["2r"])) - lineno = lineno + 1 - if lineno >= maxlines: # fin de liste - col = col + 1 - lineno = 0 - if col >= maxlistes: # fin de page - for line_cells in lines: - worksheet.append_row(line_cells) - lines = [[] for _ in range(maxlines)] - col = 0 - worksheet.append_blank_row() - nb_etu_restant -= maxlistes * maxlines - nb_listes = min( - maxlistes, nb_etu_restant // maxlines + 1 - ) # nombre de colonnes dans la page - self._headers(worksheet, nb_listes) - for line_cells in lines: - worksheet.append_row(line_cells) - - def _excel_feuille_placement(self): - """Genere feuille excel pour placement des etudiants. - E: evaluation (dict) - lines: liste de tuples - (etudid, nom, prenom, etat, groupe, val, explanation) - """ - sem_preferences = sco_preferences.SemPreferences() - space = sem_preferences.get("feuille_placement_emargement") - maxlines = sem_preferences.get("feuille_placement_positions") - nb_rangs = int(self.nb_rangs) - column_width_ratio = ( - 1 / 250 - ) # changement d unités entre pyExcelerator et openpyxl - - workbook = ScoExcelBook() - - sheet_name_0 = "Emargement" - ws0 = workbook.create_sheet(sheet_name_0) - # ajuste largeurs colonnes (unite inconnue, empirique) - width = 4500 * column_width_ratio - if nb_rangs > 5: - width = 22500 * column_width_ratio // nb_rangs - - ws0.set_column_dimension_width("A", 750 * column_width_ratio) - for col in range(nb_rangs): - ws0.set_column_dimension_width( - "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[col + 1 : col + 2], width - ) - - sheet_name_1 = "Positions" - ws1 = workbook.create_sheet(sheet_name_1) - - self._make_styles(ws0, ws1) - self._feuille0(ws0, space) - self._feuille1(ws1, maxlines) - return workbook.generate() +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""ScoDoc: génération feuille émargement et placement + +Contribution J.-M. Place 2021 +basée sur une idée de M. Salomon, UFC / IUT DE BELFORT-MONTBÉLIARD, 2016 + +""" +import random +import time +from copy import copy + +import wtforms.validators +from flask import request, render_template +from flask_login import current_user +from flask_wtf import FlaskForm +from openpyxl.styles import PatternFill, Alignment, Border, Side, Font +from wtforms import ( + StringField, + SubmitField, + SelectField, + RadioField, + HiddenField, + SelectMultipleField, +) +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import ScoValueError +from app.scodoc import html_sco_header, sco_preferences +from app.scodoc import sco_edit_module +from app.scodoc import sco_evaluations +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_excel +from app.scodoc.sco_excel import ScoExcelBook, COLORS +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_permissions_check +from app.scodoc.gen_tables import GenTable +from app.scodoc import sco_etud +import sco_version + +_ = lambda x: x # sans babel +_l = _ + +COORD = "Coordonnées" +SEQ = "Continue" + +TOUS = "Tous" + + +def _get_group_info(evaluation_id): + # groupes + groups = sco_groups.do_evaluation_listegroupes(evaluation_id, include_default=True) + has_groups = False + groups_tree = {} + for group in groups: + partition = group["partition_name"] or TOUS + group_id = group["group_id"] + group_name = group["group_name"] or TOUS + if partition not in groups_tree: + groups_tree[partition] = {} + groups_tree[partition][group_name] = group_id + if partition != TOUS: + has_groups = True + else: + has_groups = False + nb_groups = sum([len(groups_tree[p]) for p in groups_tree]) + return groups_tree, has_groups, nb_groups + + +class PlacementForm(FlaskForm): + """Formulaire pour placement des étudiants en Salle""" + + evaluation_id = HiddenField("evaluation_id") + file_format = RadioField( + "Format de fichier", + choices=["pdf", "xls"], + validators=[ + wtforms.validators.DataRequired("indiquez le format du fichier attendu"), + ], + ) + surveillants = StringField("Surveillants", validators=[]) + batiment = StringField("Batiment") + salle = StringField("Salle") + nb_rangs = SelectField( + "nb de places en largeur", + coerce=int, + choices=[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + description="largeur de la salle, en nombre de places", + ) + etiquetage = RadioField( + "Numérotation", + choices=[SEQ, COORD], + validators=[ + wtforms.validators.DataRequired("indiquez le style de numérotation"), + ], + ) + groups = SelectMultipleField( + "Groupe(s)", + validators=[], + ) + submit = SubmitField("OK") + + def __init__(self, formdata=None, data=None): + super().__init__(formdata=formdata, data=data) + self.groups_tree = {} + self.has_groups = None + self.nb_groups = None + self.tous_id = None + self.set_evaluation_infos(data["evaluation_id"]) + + def set_evaluation_infos(self, evaluation_id): + """Initialise les données du formulaire avec les données de l'évaluation.""" + eval_data = sco_evaluation_db.do_evaluation_list( + {"evaluation_id": evaluation_id} + ) + if not eval_data: + raise ScoValueError("invalid evaluation_id") + self.groups_tree, self.has_groups, self.nb_groups = _get_group_info( + evaluation_id + ) + choices = [] + for partition in self.groups_tree: + for groupe in self.groups_tree[partition]: + if ( + groupe == TOUS + ): # Affichage et valeur spécifique pour le groupe TOUS + self.tous_id = str(self.groups_tree[partition][groupe]) + choices.append((TOUS, TOUS)) + else: + groupe_id = str(self.groups_tree[partition][groupe]) + choices.append((groupe_id, "%s (%s)" % (str(groupe), partition))) + self.groups.choices = choices + # self.groups.default = [TOUS] # Ne fonctionnne pas... (ni dans la déclaration de PlaceForm.groups) + # la réponse [] est de toute façon transposée en [ self.tous_id ] lors du traitement (cas du groupe unique) + + +class _DistributeurContinu: + """Distribue les places selon un ordre numérique.""" + + def __init__(self): + self.position = 1 + + def suivant(self): + """Retounre la désignation de la place suivante""" + retour = self.position + self.position += 1 + return retour + + +class _Distributeur2D: + """Distribue les places selon des coordonnées sur nb_rangs.""" + + def __init__(self, nb_rangs): + self.nb_rangs = nb_rangs + self.rang = 1 + self.index = 1 + + def suivant(self): + """Retounre la désignation de la place suivante""" + retour = (self.index, self.rang) + self.rang += 1 + if self.rang > self.nb_rangs: + self.rang = 1 + self.index += 1 + return retour + + +def placement_eval_selectetuds(evaluation_id): + """Creation de l'écran de placement""" + form = PlacementForm( + request.form, + data={"evaluation_id": int(evaluation_id), "groups": TOUS}, + ) + if form.validate_on_submit(): + runner = PlacementRunner(form) + if not runner.check_placement(): + return ( + """

    Génération du placement impossible pour %s

    +

    (vérifiez que le semestre n'est pas verrouillé et que vous + avez l'autorisation d'effectuer cette opération)

    +

    Continuer

    + """ + % runner.__dict__ + ) + return runner.exec_placement() # calcul et generation du fichier + htmls = [ + html_sco_header.sco_header(), + sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), + "

    Placement et émargement des étudiants

    ", + render_template("scodoc/forms/placement.html", form=form), + ] + footer = html_sco_header.sco_footer() + return "\n".join(htmls) + "

    " + footer + + +class PlacementRunner: + """Execution de l'action définie par le formulaire""" + + def __init__(self, form): + """Calcul et génération du fichier sur la base des données du formulaire""" + self.evaluation_id = form["evaluation_id"].data + self.etiquetage = form["etiquetage"].data + self.surveillants = form["surveillants"].data + self.batiment = form["batiment"].data + self.salle = form["salle"].data + self.nb_rangs = form["nb_rangs"].data + self.file_format = form["file_format"].data + if len(form["groups"].data) == 0: + self.groups_ids = [form.tous_id] + else: # On remplace le mot-clé TOUS le l'identiant de ce groupe + self.groups_ids = [ + gid if gid != TOUS else form.tous_id for gid in form["groups"].data + ] + self.eval_data = sco_evaluation_db.do_evaluation_list( + {"evaluation_id": self.evaluation_id} + )[0] + self.groups = sco_groups.listgroups(self.groups_ids) + self.gr_title_filename = sco_groups.listgroups_filename(self.groups) + # gr_title = sco_groups.listgroups_abbrev(d['groups']) + self.current_user = current_user + self.moduleimpl_id = self.eval_data["moduleimpl_id"] + self.moduleimpl_data = sco_moduleimpl.moduleimpl_list( + moduleimpl_id=self.moduleimpl_id + )[0] + self.module_data = sco_edit_module.module_list( + args={"module_id": self.moduleimpl_data["module_id"]} + )[0] + self.sem = sco_formsemestre.get_formsemestre( + self.moduleimpl_data["formsemestre_id"] + ) + self.evalname = "%s-%s" % ( + self.module_data["code"] or "?", + ndb.DateDMYtoISO(self.eval_data["jour"]), + ) + if self.eval_data["description"]: + self.evaltitre = self.eval_data["description"] + else: + self.evaltitre = "évaluation du %s" % self.eval_data["jour"] + self.desceval = [ # une liste de chaines: description de l'evaluation + "%s" % self.sem["titreannee"], + "Module : %s - %s" + % (self.module_data["code"] or "?", self.module_data["abbrev"] or ""), + "Surveillants : %s" % self.surveillants, + "Batiment : %(batiment)s - Salle : %(salle)s" % self.__dict__, + "Controle : %s (coef. %g)" + % (self.evaltitre, self.eval_data["coefficient"]), + ] + self.styles = None + self.plan = None + self.listetud = None + + def check_placement(self): + """Vérifie que l'utilisateur courant a le droit d'édition sur les notes""" + # Check access (admin, respformation, and responsable_id) + return sco_permissions_check.can_edit_notes( + self.current_user, self.moduleimpl_id + ) + + def exec_placement(self): + """Excéute l'action liée au formulaire""" + self._repartition() + if self.file_format == "xls": + return self._production_xls() + return self._production_pdf() + + def _repartition(self): + """ + Calcule le placement. retourne une liste de couples ((nom, prenom), position) + """ + # Construit liste des etudiants et les réparti + self.groups = sco_groups.listgroups(self.groups_ids) + self.listetud = self._build_listetud() + self.plan = self._affectation_places() + + def _build_listetud(self): + get_all_students = None in [ + g["group_name"] for g in self.groups + ] # tous les etudiants + etudid_etats = sco_groups.do_evaluation_listeetuds_groups( + self.evaluation_id, + self.groups, + getallstudents=get_all_students, + include_demdef=True, + ) + listetud = [] # liste de couples (nom,prenom) + for etudid, etat in etudid_etats: + # infos identite etudiant (xxx sous-optimal: 1/select par etudiant) + ident = sco_etud.etudident_list(ndb.GetDBConnexion(), {"etudid": etudid})[0] + if etat != "D": + nom = ident["nom"].upper() + prenom = ident["prenom"].lower().capitalize() + etudid = ident["etudid"] + listetud.append((nom, prenom, etudid)) + random.shuffle(listetud) + return listetud + + def _affectation_places(self): + plan = [] + if self.etiquetage == SEQ: + distributeur = _DistributeurContinu() + else: + distributeur = _Distributeur2D(self.nb_rangs) + for etud in self.listetud: + plan.append((etud, distributeur.suivant())) + return plan + + def _production_xls(self): + filename = "placement_%s_%s" % (self.evalname, self.gr_title_filename) + xls = self._excel_feuille_placement() + return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) + + def _production_pdf(self): + pdf_title = "
    ".join(self.desceval) + pdf_title += ( + "\nDate : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" + % self.eval_data + ) + filename = "placement_%(evalname)s_%(gr_title_filename)s" % self.__dict__ + titles = { + "nom": "Nom", + "prenom": "Prenom", + "colonne": "Colonne", + "ligne": "Ligne", + "place": "Place", + } + if self.etiquetage == COORD: + columns_ids = ["nom", "prenom", "colonne", "ligne"] + else: + columns_ids = ["nom", "prenom", "place"] + + rows = [] + for etud in sorted(self.plan, key=lambda item: item[0][0]): # sort by name + if self.etiquetage == COORD: + rows.append( + { + "nom": etud[0][0], + "prenom": etud[0][1], + "colonne": etud[1][0], + "ligne": etud[1][1], + } + ) + else: + rows.append({"nom": etud[0][0], "prenom": etud[0][1], "place": etud[1]}) + tab = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=rows, + filename=filename, + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + pdf_title=pdf_title, + # pdf_shorttitle = '', + preferences=sco_preferences.SemPreferences( + self.moduleimpl_data["formsemestre_id"] + ), + ) + return tab.make_page(format="pdf", with_html_headers=False) + + def _one_header(self, worksheet): + cells = [ + worksheet.make_cell("Nom", self.styles["2bi"]), + worksheet.make_cell("Prénom", self.styles["2bi"]), + ] + if self.etiquetage == COORD: + cells.append(worksheet.make_cell("Colonne", self.styles["2bi"])) + cells.append(worksheet.make_cell("Ligne", self.styles["2bi"])) + else: + cells.append(worksheet.make_cell("Place", self.styles["2bi"])) + return cells + + def _headers(self, worksheet, nb_listes): + cells = [] + for _ in range(nb_listes): + cells += self._one_header(worksheet) + cells.append(worksheet.make_cell("")) + worksheet.append_row(cells) + + def _make_styles(self, ws0, ws1): + # polices + font0 = Font(name="Calibri", bold=True, size=12) + font1b = copy(font0) + font1b.size = 9 + font1i = Font(name="Arial", italic=True, size=10) + font1o = Font(name="Arial", outline=True, size=10) + font2bi = Font(name="Arial", bold=True, italic=True, size=8) + font2 = Font(name="Arial", size=10) + + # bordures + side_double = Side(border_style="double", color=COLORS.BLACK.value) + side_thin = Side(border_style="thin", color=COLORS.BLACK.value) + + # bordures + border1t = Border(left=side_double, top=side_double, right=side_double) + border1bb = Border(left=side_double, bottom=side_double, right=side_double) + border1bm = Border(left=side_double, right=side_double) + border1m = Border(left=side_double, bottom=side_thin, right=side_double) + border2m = Border(top=side_thin, bottom=side_thin) + border2r = Border(top=side_thin, bottom=side_thin, right=side_thin) + border2l = Border(left=side_thin, top=side_thin, bottom=side_thin) + border2b = Border( + left=side_thin, top=side_thin, bottom=side_thin, right=side_thin + ) + + # alignements + align_center_center = Alignment(horizontal="center", vertical="center") + align_right_bottom = Alignment(horizontal="right", vertical="bottom") + align_left_center = Alignment(horizontal="left", vertical="center") + align_right_center = Alignment(horizontal="right", vertical="center") + + # patterns + pattern = PatternFill( + fill_type="solid", fgColor=sco_excel.COLORS.LIGHT_YELLOW.value + ) + + # styles + self.styles = { + "titres": sco_excel.excel_make_style(font_name="Arial", bold=True, size=12), + "1t": ws0.excel_make_composite_style( + font=font0, alignment=align_center_center, border=border1t + ), + "1m": ws0.excel_make_composite_style( + font=font1b, alignment=align_center_center, border=border1m + ), + "1bm": ws0.excel_make_composite_style( + font=font1b, alignment=align_center_center, border=border1bm + ), + "1bb": ws0.excel_make_composite_style( + font=font1o, alignment=align_right_bottom, border=border1bb + ), + "2b": ws1.excel_make_composite_style( + font=font1i, alignment=align_center_center, border=border2b + ), + "2bi": ws1.excel_make_composite_style( + font=font2bi, + alignment=align_center_center, + border=border2b, + fill=pattern, + ), + "2l": ws1.excel_make_composite_style( + font=font2, alignment=align_left_center, border=border2l + ), + "2m1": ws1.excel_make_composite_style( + font=font2, alignment=align_left_center, border=border2m + ), + "2m2": ws1.excel_make_composite_style( + font=font2, alignment=align_right_center, border=border2m + ), + "2r": ws1.excel_make_composite_style( + font=font2, alignment=align_right_center, border=border2r + ), + } + + def _titres(self, worksheet): + datetime = time.strftime("%d/%m/%Y a %Hh%M") + worksheet.append_single_cell_row( + "Feuille placement etudiants éditée le %s" % datetime, self.styles["titres"] + ) + for line, desceval in enumerate(self.desceval): + if line in [1, 4, 7]: + worksheet.append_blank_row() + worksheet.append_single_cell_row(desceval, self.styles["titres"]) + worksheet.append_single_cell_row( + "Date : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" + % self.eval_data, + self.styles["titres"], + ) + + def _feuille0(self, ws0, space): + self._titres(ws0) + # entetes colonnes - feuille0 + cells = [ws0.make_cell()] + for col in range(self.nb_rangs): + cells.append(ws0.make_cell("colonne %s" % (col + 1), self.styles["2b"])) + ws0.append_row(cells) + + # etudiants - feuille0 + place = 1 + col = 0 + rang = 1 + # Chaque rang est affiché sur 3 lignes xlsx (notées A, B, C) + # ligne A: le nom, ligne B: le prénom, ligne C: un espace ou la place + cells_a = [ws0.make_cell(rang, self.styles["2b"])] + cells_b = [ws0.make_cell("", self.styles["2b"])] + cells_c = [ws0.make_cell("", self.styles["2b"])] + row = 13 # première ligne de signature + rang += 1 + for linetud in self.plan: + cells_a.append(ws0.make_cell(linetud[0][0], self.styles["1t"])) # nom + cells_b.append(ws0.make_cell(linetud[0][1], self.styles["1m"])) # prenom + if self.etiquetage == COORD: + cell_c = ws0.make_cell("", self.styles["1bb"]) + else: + cell_c = ws0.make_cell("place %s" % place, self.styles["1bb"]) + place = place + 1 + cells_c.append(cell_c) + ws0.set_row_dimension_height(row, space / 25) + row += 3 + col += 1 + if col == self.nb_rangs: # On a fini la rangée courante + ws0.append_row(cells_a) # on affiche les 3 lignes construites + ws0.append_row(cells_b) + ws0.append_row(cells_c) + cells_a = [ + ws0.make_cell(rang, self.styles["2b"]) + ] # on réinitialise les 3 lignes + cells_b = [ws0.make_cell("", self.styles["2b"])] + cells_c = [ws0.make_cell("", self.styles["2b"])] + col = 0 + rang += 1 + # publication du rang final incomplet + ws0.append_row(cells_a) # Affiche des 3 lignes (dernières lignes incomplètes) + ws0.append_row(cells_b) + ws0.append_row(cells_c) + ws0.set_row_dimension_height(row, space / 25) + + def _feuille1(self, worksheet, maxlines): + # etudiants - feuille1 + # structuration: + # 1 page = maxlistes listes + # 1 liste = 3 ou 4 colonnes(excel) (selon numbering) et (maximum maxlines) lignes + maxlistes = 2 # nombre de listes par page + # computes excel columns widths + if self.etiquetage == COORD: + gabarit = [16, 18, 6, 6, 2] + else: + gabarit = [16, 18, 12, 2] + widths = [] + for _ in range(maxlistes): + widths += gabarit + worksheet.set_column_dimension_width(value=widths) + nb_etu_restant = len(self.listetud) + self._titres(worksheet) + nb_listes = min( + maxlistes, nb_etu_restant // maxlines + 1 + ) # nombre de colonnes dans la page + self._headers(worksheet, nb_listes) + # construction liste alphabétique + # Affichage + lines = [[] for _ in range(maxlines)] + lineno = 0 + col = 0 + for etud in sorted(self.plan, key=lambda e: e[0][0]): # tri alphabétique + # check for skip of list or page + if col > 0: # add a empty cell between lists + lines[lineno].append(worksheet.make_cell()) + lines[lineno].append(worksheet.make_cell(etud[0][0], self.styles["2l"])) + lines[lineno].append(worksheet.make_cell(etud[0][1], self.styles["2m1"])) + if self.etiquetage == COORD: + lines[lineno].append( + worksheet.make_cell(etud[1][1], self.styles["2m2"]) + ) + lines[lineno].append(worksheet.make_cell(etud[1][0], self.styles["2r"])) + else: + lines[lineno].append(worksheet.make_cell(etud[1], self.styles["2r"])) + lineno = lineno + 1 + if lineno >= maxlines: # fin de liste + col = col + 1 + lineno = 0 + if col >= maxlistes: # fin de page + for line_cells in lines: + worksheet.append_row(line_cells) + lines = [[] for _ in range(maxlines)] + col = 0 + worksheet.append_blank_row() + nb_etu_restant -= maxlistes * maxlines + nb_listes = min( + maxlistes, nb_etu_restant // maxlines + 1 + ) # nombre de colonnes dans la page + self._headers(worksheet, nb_listes) + for line_cells in lines: + worksheet.append_row(line_cells) + + def _excel_feuille_placement(self): + """Genere feuille excel pour placement des etudiants. + E: evaluation (dict) + lines: liste de tuples + (etudid, nom, prenom, etat, groupe, val, explanation) + """ + sem_preferences = sco_preferences.SemPreferences() + space = sem_preferences.get("feuille_placement_emargement") + maxlines = sem_preferences.get("feuille_placement_positions") + nb_rangs = int(self.nb_rangs) + column_width_ratio = ( + 1 / 250 + ) # changement d unités entre pyExcelerator et openpyxl + + workbook = ScoExcelBook() + + sheet_name_0 = "Emargement" + ws0 = workbook.create_sheet(sheet_name_0) + # ajuste largeurs colonnes (unite inconnue, empirique) + width = 4500 * column_width_ratio + if nb_rangs > 5: + width = 22500 * column_width_ratio // nb_rangs + + ws0.set_column_dimension_width("A", 750 * column_width_ratio) + for col in range(nb_rangs): + ws0.set_column_dimension_width( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[col + 1 : col + 2], width + ) + + sheet_name_1 = "Positions" + ws1 = workbook.create_sheet(sheet_name_1) + + self._make_styles(ws0, ws1) + self._feuille0(ws0, space) + self._feuille1(ws1, maxlines) + return workbook.generate() diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index f6315583..3ceba608 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -43,7 +43,7 @@ Au niveau du code interface, on défini pour chaque préférence: - initvalue : valeur initiale - explanation: explication en français - size: longueur du chap texte - - input_type: textarea,separator,... type de widget TrivialFormulator a utiliser + - input_type: textarea, separator, ... type de widget TrivialFormulator a utiliser - rows, rols: geometrie des textareas - category: misc ou bul ou page_bulletins ou abs ou general ou portal ou pdf ou pvpdf ou ... @@ -202,7 +202,7 @@ _INSTALLED_FONTS = ", ".join(sco_pdf.get_available_font_names()) PREF_CATEGORIES = ( # sur page "Paramètres" - ("general", {}), + ("general", {"title": ""}), # voir paramètre titlr de TrivialFormulator ("misc", {"title": "Divers"}), ("apc", {"title": "BUT et Approches par Compétences"}), ("abs", {"title": "Suivi des absences", "related": ("bul",)}), @@ -1008,7 +1008,7 @@ class BasePreferences(object): ( "PV_LETTER_DIPLOMA_SIGNATURE", { - "initvalue": """Le %(DirectorTitle)s,
    %(DirectorName)s""", + "initvalue": """Le %(DirectorTitle)s,
    %(DirectorName)s""", "title": """Signature des lettres individuelles de diplôme""", "explanation": """%(DirectorName)s et %(DirectorTitle)s remplacés""", "input_type": "textarea", @@ -1020,8 +1020,8 @@ class BasePreferences(object): ( "PV_LETTER_PASSAGE_SIGNATURE", { - "initvalue": """Pour le Directeur de l'IUT
    - et par délégation
    + "initvalue": """Pour le Directeur de l'IUT
    + et par délégation
    Le Chef du département""", "title": """Signature des lettres individuelles de passage d'un semestre à l'autre""", "explanation": """%(DirectorName)s et %(DirectorTitle)s remplacés""", @@ -1056,7 +1056,7 @@ class BasePreferences(object): %(codepostaldomicile)s %(villedomicile)s - Jury de %(type_jury)s
    %(titre_formation)s
    + Jury de %(type_jury)s
    %(titre_formation)s
    @@ -1499,7 +1499,7 @@ class BasePreferences(object): "bul_pdf_sig_left", { "initvalue": """La direction des études -
    +
    %(responsable)s
    """, @@ -1515,7 +1515,7 @@ class BasePreferences(object): "bul_pdf_sig_right", { "initvalue": """Le chef de département -
    +
    %(ChiefDeptName)s
    """, @@ -1891,7 +1891,7 @@ class BasePreferences(object): "explanation": """si cette adresse est indiquée, TOUS les mails envoyés par ScoDoc de ce département vont aller vers elle AU LIEU DE LEUR DESTINATION NORMALE !""", - "size": 30, + "size": 60, "category": "debug", "only_global": True, }, @@ -1935,7 +1935,7 @@ class BasePreferences(object): value = _get_pref_default_value_from_config(name, pref[1]) self.default[name] = value self.prefs[None][name] = value - log("creating missing preference for %s=%s" % (name, value)) + log(f"creating missing preference for {name}={value}") # add to db table self._editor.create( cnx, {"dept_id": self.dept_id, "name": name, "value": value} @@ -1999,7 +1999,7 @@ class BasePreferences(object): if not pdb: # crée préférence - log("create pref sem=%s %s=%s" % (formsemestre_id, name, value)) + log(f"create pref sem={formsemestre_id} {name}={value}") self._editor.create( cnx, { @@ -2036,7 +2036,7 @@ class BasePreferences(object): def set(self, formsemestre_id, name, value): if not name or name[0] == "_" or name not in self.prefs_name: - raise ValueError("invalid preference name: %s" % name) + raise ValueError(f"invalid preference name: {name}") if formsemestre_id and name in self.prefs_only_global: raise ValueError("pref %s is always defined globaly") if not formsemestre_id in self.prefs: @@ -2055,7 +2055,7 @@ class BasePreferences(object): cnx, args={"formsemestre_id": formsemestre_id, "name": name} ) if pdb: - log("deleting pref sem=%s %s" % (formsemestre_id, name)) + log(f"deleting pref sem={formsemestre_id} {name}") assert pdb[0]["dept_id"] == self.dept_id self._editor.delete(cnx, pdb[0]["pref_id"]) sco_cache.invalidate_formsemestre() # > modif preferences @@ -2067,14 +2067,18 @@ class BasePreferences(object): self.load() H = [ html_sco_header.sco_header(page_title="Préférences"), - "

    Préférences globales pour %s

    " % scu.ScoURL(), + f"

    Préférences globales pour {scu.ScoURL()}

    ", # f"""

    modification des logos du département (pour documents pdf)

    """ # if current_user.is_administrator() # else "", - """

    Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.

    -

    Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !

    - """, + """

    Ces paramètres s'appliquent par défaut à tous les semestres, + sauf si ceux-ci définissent des valeurs spécifiques. +

    +

    Attention: cliquez sur "Enregistrer les modifications" + en bas de page pour appliquer vos changements ! +

    + """, ] form = self.build_tf_form() tf = TrivialFormulator( @@ -2083,6 +2087,9 @@ class BasePreferences(object): form, initvalues=self.prefs[None], submitlabel="Enregistrer les modifications", + title="Département et institution", + before_table="
    {title}", + after_table="
    ", ) if tf[0] == 0: return "\n".join(H) + tf[1] + html_sco_header.sco_footer() @@ -2094,7 +2101,7 @@ class BasePreferences(object): self.save() return flask.redirect(scu.ScoURL() + "?head_message=Préférences modifiées") - def build_tf_form(self, categories=[], formsemestre_id=None): + def build_tf_form(self, categories: list[str] = None, formsemestre_id: int = None): """Build list of elements for TrivialFormulator. If formsemestre_id is not specified, edit global prefs. """ @@ -2119,7 +2126,7 @@ class BasePreferences(object): onclick="set_global_pref(this, '{pref_name}');" >utiliser paramètre global""" if formsemestre_id and self.is_global(formsemestre_id, pref_name): - # valeur actuelle globale (ou vient d'etre supprimee localement): + # valeur actuelle globale (ou vient d'etre supprimée localement): # montre la valeur et menus pour la rendre locale descr["readonly"] = True menu_global = f"""Restreindre aux primo-entrants' + '
    Restreindre aux primo-entrants' % checked ) F.append( @@ -928,7 +928,7 @@ def _gen_form_selectetuds( else: checked = "" F.append( - '
    Restreindre aux primo-entrants' + '
    Restreindre aux primo-entrants' % checked ) F.append( diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 166b6c96..d110f3ba 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -1,1345 +1,1345 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Saisie des notes - - Formulaire revu en juillet 2016 -""" -import time -import psycopg2 - -import flask -from flask import g, url_for, request -from flask_login import current_user - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import Evaluation, FormSemestre -from app.models import ScolarNews -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -import app.scodoc.notesdb as ndb -from app import log -from app.scodoc.sco_exceptions import ( - AccessDenied, - InvalidNoteValue, - NoteProcessError, - ScoGenError, - ScoInvalidParamError, - ScoValueError, -) -from app.scodoc.TrivialFormulator import TrivialFormulator, TF -from app.scodoc import html_sco_header, sco_users -from app.scodoc import htmlutils -from app.scodoc import sco_abs -from app.scodoc import sco_cache -from app.scodoc import sco_edit_module -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_excel -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_groups -from app.scodoc import sco_groups_view -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_permissions_check -from app.scodoc import sco_undo_notes -from app.scodoc import sco_etud - - -def convert_note_from_string( - note, - note_max, - note_min=scu.NOTES_MIN, - etudid=None, - absents=None, - tosuppress=None, - invalids=None, -): - """converti une valeur (chaine saisie) vers une note numérique (float) - Les listes absents, tosuppress et invalids sont modifiées - """ - invalid = False - note_value = None - note = note.replace(",", ".") - if note[:3] == "ABS": - note_value = None - absents.append(etudid) - elif note[:3] == "NEU" or note[:3] == "EXC": - note_value = scu.NOTES_NEUTRALISE - elif note[:3] == "ATT": - note_value = scu.NOTES_ATTENTE - elif note[:3] == "SUP": - note_value = scu.NOTES_SUPPRESS - tosuppress.append(etudid) - else: - try: - note_value = float(note) - if (note_value < note_min) or (note_value > note_max): - raise ValueError - except ValueError: - invalids.append(etudid) - invalid = True - - return note_value, invalid - - -def _displayNote(val): - """Convert note from DB to viewable string. - Utilisé seulement pour I/O vers formulaires (sans perte de precision) - (Utiliser fmt_note pour les affichages) - """ - if val is None: - val = "ABS" - elif val == scu.NOTES_NEUTRALISE: - val = "EXC" # excuse, note neutralise - elif val == scu.NOTES_ATTENTE: - val = "ATT" # attente, note neutralise - elif val == scu.NOTES_SUPPRESS: - val = "SUPR" - else: - val = "%g" % val - return val - - -def _check_notes(notes, evaluation, mod): - """notes is a list of tuples (etudid, value) - mod is the module (used to ckeck type, for malus) - returns list of valid notes (etudid, float value) - and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury - """ - note_max = evaluation["note_max"] - if mod["module_type"] in ( - scu.ModuleType.STANDARD, - scu.ModuleType.RESSOURCE, - scu.ModuleType.SAE, - ): - note_min = scu.NOTES_MIN - elif mod["module_type"] == ModuleType.MALUS: - note_min = -20.0 - else: - raise ValueError("Invalid module type") # bug - L = [] # liste (etudid, note) des notes ok (ou absent) - invalids = [] # etudid avec notes invalides - withoutnotes = [] # etudid sans notes (champs vides) - absents = [] # etudid absents - tosuppress = [] # etudids avec ancienne note à supprimer - - for (etudid, note) in notes: - note = str(note).strip().upper() - try: - etudid = int(etudid) # - except ValueError as exc: - raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc - if note[:3] == "DEM": - continue # skip ! - if note: - value, invalid = convert_note_from_string( - note, - note_max, - note_min=note_min, - etudid=etudid, - absents=absents, - tosuppress=tosuppress, - invalids=invalids, - ) - if not invalid: - L.append((etudid, value)) - else: - withoutnotes.append(etudid) - return L, invalids, withoutnotes, absents, tosuppress - - -def do_evaluation_upload_xls(): - """ - Soumission d'un fichier XLS (evaluation_id, notefile) - """ - authuser = current_user - vals = scu.get_request_args() - evaluation_id = int(vals["evaluation_id"]) - comment = vals["comment"] - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] - # Check access - # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): - raise AccessDenied("Modification des notes impossible pour %s" % authuser) - # - diag, lines = sco_excel.excel_file_to_list(vals["notefile"]) - try: - if not lines: - raise InvalidNoteValue() - # -- search eval code - n = len(lines) - i = 0 - while i < n: - if not lines[i]: - diag.append("Erreur: format invalide (ligne vide ?)") - raise InvalidNoteValue() - f0 = lines[i][0].strip() - if f0 and f0[0] == "!": - break - i = i + 1 - if i == n: - diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)") - raise InvalidNoteValue() - - eval_id_str = lines[i][0].strip()[1:] - try: - eval_id = int(eval_id_str) - except ValueError: - eval_id = None - if eval_id != evaluation_id: - diag.append( - f"Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{eval_id_str}' != '{evaluation_id}')" - ) - raise InvalidNoteValue() - # --- get notes -> list (etudid, value) - # ignore toutes les lignes ne commençant pas par ! - notes = [] - ni = i + 1 - try: - for line in lines[i + 1 :]: - if line: - cell0 = line[0].strip() - if cell0 and cell0[0] == "!": - etudid = cell0[1:] - if len(line) > 4: - val = line[4].strip() - else: - val = "" # ligne courte: cellule vide - if etudid: - notes.append((etudid, val)) - ni += 1 - except: - diag.append( - 'Erreur: Ligne invalide ! (erreur ligne %d)
    "%s"' - % (ni, str(lines[ni])) - ) - raise InvalidNoteValue() - # -- check values - L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"]) - if len(invalids): - diag.append( - "Erreur: la feuille contient %d notes invalides

    " % len(invalids) - ) - if len(invalids) < 25: - etudsnames = [ - sco_etud.get_etud_info(etudid=etudid, filled=True)[0]["nomprenom"] - for etudid in invalids - ] - diag.append("Notes invalides pour: " + ", ".join(etudsnames)) - raise InvalidNoteValue() - else: - nb_changed, nb_suppress, existing_decisions = notes_add( - authuser, evaluation_id, L, comment - ) - # news - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[ - 0 - ] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - mod["moduleimpl_id"] = M["moduleimpl_id"] - mod["url"] = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=mod["moduleimpl_id"], - _external=True, - ) - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Chargement notes dans %(titre)s' % mod, - url=mod["url"], - max_frequency=30 * 60, # 30 minutes - ) - - msg = ( - "

    %d notes changées (%d sans notes, %d absents, %d note supprimées)

    " - % (nb_changed, len(withoutnotes), len(absents), nb_suppress) - ) - if existing_decisions: - msg += """

    Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !

    """ - # msg += '

    ' + str(notes) # debug - return 1, msg - - except InvalidNoteValue: - if diag: - msg = ( - '

    • ' - + '
    • '.join(diag) - + "
    " - ) - else: - msg = '
    • Une erreur est survenue
    ' - return 0, msg + "

    (pas de notes modifiées)

    " - - -def do_evaluation_set_missing(evaluation_id, value, dialog_confirmed=False): - """Initialisation des notes manquantes""" - evaluation = Evaluation.query.get_or_404(evaluation_id) - modimpl = evaluation.moduleimpl - - # Check access - # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(current_user, modimpl.id): - raise AccessDenied(f"Modification des notes impossible pour {current_user}") - # - notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) - etudid_etats = sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, getallstudents=True, include_demdef=False - ) - notes = [] - for etudid, _ in etudid_etats: # pour tous les inscrits - if etudid not in notes_db: # pas de note - notes.append((etudid, value)) - # Check value - L, invalids, _, _, _ = _check_notes( - notes, evaluation.to_dict(), modimpl.module.to_dict() - ) - dest_url = url_for( - "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id - ) - diag = "" - if len(invalids) > 0: - diag = f"Valeur {value} invalide ou hors barème" - if diag: - return f""" - {html_sco_header.sco_header()} -

    {diag}

    -

    - Recommencer -

    - {html_sco_header.sco_footer()} - """ - # Confirm action - if not dialog_confirmed: - return scu.confirm_dialog( - f"""

    Mettre toutes les notes manquantes de l'évaluation - à la valeur {value} ?

    -

    Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) - n'a été rentrée seront affectés.

    -

    {len(L)} étudiants concernés par ce changement de note.

    -

    Attention, les étudiants sans notes de tous les groupes de ce semestre seront affectés.

    - """, - dest_url="", - cancel_url=dest_url, - parameters={"evaluation_id": evaluation_id, "value": value}, - ) - # ok - comment = "Initialisation notes manquantes" - nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) - # news - url = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=evaluation.moduleimpl_id, - ) - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=evaluation.moduleimpl_id, - text=f"""Initialisation notes dans {modimpl.module.titre or ""}""", - url=url, - max_frequency=30 * 60, - ) - return f""" - { html_sco_header.sco_header() } -

    {nb_changed} notes changées

    - - { html_sco_header.sco_footer() } - """ - - -def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): - "suppress all notes in this eval" - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - - if sco_permissions_check.can_edit_notes( - current_user, E["moduleimpl_id"], allow_ens=False - ): - # On a le droit de modifier toutes les notes - # recupere les etuds ayant une note - notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) - elif sco_permissions_check.can_edit_notes( - current_user, E["moduleimpl_id"], allow_ens=True - ): - # Enseignant associé au module: ne peut supprimer que les notes qu'il a saisi - notes_db = sco_evaluation_db.do_evaluation_get_all_notes( - evaluation_id, by_uid=current_user.id - ) - else: - raise AccessDenied("Modification des notes impossible pour %s" % current_user) - - notes = [(etudid, scu.NOTES_SUPPRESS) for etudid in notes_db.keys()] - - if not dialog_confirmed: - nb_changed, nb_suppress, existing_decisions = notes_add( - current_user, evaluation_id, notes, do_it=False, check_inscription=False - ) - msg = ( - "

    Confirmer la suppression des %d notes ? (peut affecter plusieurs groupes)

    " - % nb_suppress - ) - if existing_decisions: - msg += """

    Important: il y a déjà des décisions de jury enregistrées, qui seront potentiellement à revoir suite à cette modification !

    """ - return scu.confirm_dialog( - msg, - dest_url="", - OK="Supprimer les notes", - cancel_url="moduleimpl_status?moduleimpl_id=%s" % E["moduleimpl_id"], - parameters={"evaluation_id": evaluation_id}, - ) - - # modif - nb_changed, nb_suppress, existing_decisions = notes_add( - current_user, - evaluation_id, - notes, - comment="effacer tout", - check_inscription=False, - ) - assert nb_changed == nb_suppress - H = ["

    %s notes supprimées

    " % nb_suppress] - if existing_decisions: - H.append( - """

    Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !

    """ - ) - H += [ - '

    continuer' - % E["moduleimpl_id"] - ] - # news - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - mod["moduleimpl_id"] = M["moduleimpl_id"] - mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Suppression des notes d\'une évaluation dans %(titre)s' - % mod, - url=mod["url"], - ) - - return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer() - - -def notes_add( - user, - evaluation_id: int, - notes: list, - comment=None, - do_it=True, - check_inscription=True, -) -> tuple: - """ - Insert or update notes - notes is a list of tuples (etudid,value) - If do_it is False, simulate the process and returns the number of values that - WOULD be changed or suppressed. - Nota: - - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - Return tuple (nb_changed, nb_suppress, existing_decisions) - """ - now = psycopg2.Timestamp( - *time.localtime()[:6] - ) # datetime.datetime.now().isoformat() - # Verifie inscription et valeur note - inscrits = { - x[0] - for x in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, getallstudents=True, include_demdef=True - ) - } - for (etudid, value) in notes: - if check_inscription and (etudid not in inscrits): - raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module") - if (value is not None) and not isinstance(value, float): - raise NoteProcessError( - f"etudiant {etudid}: valeur de note invalide ({value})" - ) - # Recherche notes existantes - notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) - # Met a jour la base - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - nb_changed = 0 - nb_suppress = 0 - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - existing_decisions = ( - [] - ) # etudids pour lesquels il y a une decision de jury et que la note change - try: - for (etudid, value) in notes: - changed = False - if etudid not in notes_db: - # nouvelle note - if value != scu.NOTES_SUPPRESS: - if do_it: - aa = { - "etudid": etudid, - "evaluation_id": evaluation_id, - "value": value, - "comment": comment, - "uid": user.id, - "date": now, - } - ndb.quote_dict(aa) - cursor.execute( - """INSERT INTO notes_notes - (etudid, evaluation_id, value, comment, date, uid) - VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s) - """, - aa, - ) - changed = True - else: - # il y a deja une note - oldval = notes_db[etudid]["value"] - if type(value) != type(oldval): - changed = True - elif type(value) == type(1.0) and ( - abs(value - oldval) > scu.NOTES_PRECISION - ): - changed = True - elif value != oldval: - changed = True - if changed: - # recopie l'ancienne note dans notes_notes_log, puis update - if do_it: - cursor.execute( - """INSERT INTO notes_notes_log - (etudid,evaluation_id,value,comment,date,uid) - SELECT etudid, evaluation_id, value, comment, date, uid - FROM notes_notes - WHERE etudid=%(etudid)s - and evaluation_id=%(evaluation_id)s - """, - {"etudid": etudid, "evaluation_id": evaluation_id}, - ) - aa = { - "etudid": etudid, - "evaluation_id": evaluation_id, - "value": value, - "date": now, - "comment": comment, - "uid": user.id, - } - ndb.quote_dict(aa) - if value != scu.NOTES_SUPPRESS: - if do_it: - cursor.execute( - """UPDATE notes_notes - SET value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s - WHERE etudid = %(etudid)s - and evaluation_id = %(evaluation_id)s - """, - aa, - ) - else: # suppression ancienne note - if do_it: - log( - "notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" - % (evaluation_id, etudid, oldval) - ) - cursor.execute( - """DELETE FROM notes_notes - WHERE etudid = %(etudid)s - AND evaluation_id = %(evaluation_id)s - """, - aa, - ) - # garde trace de la suppression dans l'historique: - aa["value"] = scu.NOTES_SUPPRESS - cursor.execute( - """INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid) - VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s) - """, - aa, - ) - nb_suppress += 1 - if changed: - nb_changed += 1 - if has_existing_decision(M, E, etudid): - existing_decisions.append(etudid) - except Exception as exc: - log("*** exception in notes_add") - if do_it: - cnx.rollback() # abort - # inval cache - sco_cache.invalidate_formsemestre( - formsemestre_id=M["formsemestre_id"] - ) # > modif notes (exception) - sco_cache.EvaluationCache.delete(evaluation_id) - raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc - if do_it: - cnx.commit() - sco_cache.invalidate_formsemestre( - formsemestre_id=M["formsemestre_id"] - ) # > modif notes - sco_cache.EvaluationCache.delete(evaluation_id) - return nb_changed, nb_suppress, existing_decisions - - -def saisie_notes_tableur(evaluation_id, group_ids=()): - """Saisie des notes via un fichier Excel""" - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - if not evals: - raise ScoValueError("invalid evaluation_id") - E = evals[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = M["formsemestre_id"] - if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): - return ( - html_sco_header.sco_header() - + "

    Modification des notes impossible pour %s

    " - % current_user.user_name - + """

    (vérifiez que le semestre n'est pas verrouillé et que vous - avez l'autorisation d'effectuer cette opération)

    -

    Continuer

    - """ - % E["moduleimpl_id"] - + html_sco_header.sco_footer() - ) - - if E["description"]: - page_title = 'Saisie des notes de "%s"' % E["description"] - else: - page_title = "Saisie des notes" - - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids=group_ids, - formsemestre_id=formsemestre_id, - select_all_when_unspecified=True, - etat=None, - ) - - H = [ - html_sco_header.sco_header( - page_title=page_title, - javascripts=sco_groups_view.JAVASCRIPTS, - cssstyles=sco_groups_view.CSSSTYLES, - init_qtip=True, - ), - sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), - """Saisie des notes par fichier""", - ] - - # Menu choix groupe: - H.append("""
    -
    +
    Ces annotations sont lisibles par tous les enseignants et le secrétariat. -
    +
    L'annotation commençant par "PE:" est un avis de poursuite d'études.
    """) - H.append(sco_groups_view.form_groups_choice(groups_infos)) - H.append("
    ") - - H.append( - """ -
    - """ - % (evaluation_id, groups_infos.groups_query_args, evaluation_id, evaluation_id) - ) - - H.append( - """
    - Etape 2 : chargement d'un fichier de notes""" # ' - ) - - nf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), - ( - "notefile", - {"input_type": "file", "title": "Fichier de note (.xls)", "size": 44}, - ), - ( - "comment", - { - "size": 44, - "title": "Commentaire", - "explanation": "(la colonne remarque du fichier excel est ignorée)", - }, - ), - ), - formid="notesfile", - submitlabel="Télécharger", - ) - if nf[0] == 0: - H.append( - """

    Le fichier doit être un fichier tableur obtenu via - l'étape 1 ci-dessus, puis complété et enregistré au format Excel. -

    """ - ) - H.append(nf[1]) - elif nf[0] == -1: - H.append("

    Annulation

    ") - elif nf[0] == 1: - updiag = do_evaluation_upload_xls() - if updiag[0]: - H.append(updiag[1]) - H.append( - """

    Notes chargées.    - - Revenir au tableau de bord du module -     - Charger d'autres notes dans cette évaluation -

    """ - % E - ) - else: - H.append("""

    Notes non chargées !

    """ + updiag[1]) - H.append( - """ -

    - Reprendre -

    """ - % E - ) - # - H.append("""

    Autres opérations

      """) - if sco_permissions_check.can_edit_notes( - current_user, E["moduleimpl_id"], allow_ens=False - ): - H.append( - """ -
    • -
      - Mettre toutes les notes manquantes à - - - ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente" -
      -
    • -
    • Effacer toutes les notes de cette évaluation (ceci permet ensuite de supprimer l'évaluation si besoin) -
    • """ - % (evaluation_id, evaluation_id) - ) # ' - H.append( - """
    • Revenir au module
    • -
    • Revenir au formulaire de saisie
    • -
    """ - % E - ) - - H.append( - """

    Explications

    -
      -
    1. Etape 1: -
      1. choisir le ou les groupes d'étudiants;
      2. -
      3. télécharger le fichier Excel à remplir.
      4. -
      -
    2. -
    3. Etape 2 (cadre vert): Indiquer le fichier Excel téléchargé à l'étape 1 et dans lequel on a saisi des notes. Remarques: -
        -
      • le fichier Excel peut être incomplet: on peut ne saisir que quelques notes et répéter l'opération (en téléchargeant un nouveau fichier) plus tard;
      • -
      • seules les valeurs des notes modifiées sont prises en compte;
      • -
      • seules les notes sont extraites du fichier Excel;
      • -
      • on peut optionnellement ajouter un commentaire (type "copies corrigées par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire". -
      • -
      • le fichier Excel doit impérativement être celui chargé à l'étape 1 pour cette évaluation. Il n'est pas possible d'utiliser une liste d'appel ou autre document Excel téléchargé d'une autre page.
      • -
      -
    4. -
    -""" - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def feuille_saisie_notes(evaluation_id, group_ids=[]): - """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - if not evals: - raise ScoValueError("invalid evaluation_id") - eval_dict = evals[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0] - formsemestre_id = M["formsemestre_id"] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) - mod_responsable = sco_users.user_info(M["responsable_id"]) - if eval_dict["jour"]: - indication_date = ndb.DateDMYtoISO(eval_dict["jour"]) - else: - indication_date = scu.sanitize_filename(eval_dict["description"])[:12] - eval_name = "%s-%s" % (Mod["code"], indication_date) - - if eval_dict["description"]: - evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"]) - else: - evaltitre = "évaluation du %s" % eval_dict["jour"] - description = "%s en %s (%s) resp. %s" % ( - evaltitre, - Mod["abbrev"] or "", - Mod["code"] or "", - mod_responsable["prenomnom"], - ) - - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids=group_ids, - formsemestre_id=formsemestre_id, - select_all_when_unspecified=True, - etat=None, - ) - groups = sco_groups.listgroups(groups_infos.group_ids) - gr_title_filename = sco_groups.listgroups_filename(groups) - # gr_title = sco_groups.listgroups_abbrev(groups) - if None in [g["group_name"] for g in groups]: # tous les etudiants - getallstudents = True - # gr_title = "tous" - gr_title_filename = "tous" - else: - getallstudents = False - etudids = [ - x[0] - for x in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, groups, getallstudents=getallstudents, include_demdef=True - ) - ] - - # une liste de liste de chaines: lignes de la feuille de calcul - L = [] - - etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id) - for e in etuds: - etudid = e["etudid"] - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) - grc = sco_groups.listgroups_abbrev(groups) - - L.append( - [ - "%s" % etudid, - e["nom"].upper(), - e["prenom"].lower().capitalize(), - e["inscr"]["etat"], - grc, - e["val"], - e["explanation"], - ] - ) - - filename = "notes_%s_%s" % (eval_name, gr_title_filename) - xls = sco_excel.excel_feuille_saisie( - eval_dict, sem["titreannee"], description, lines=L - ) - return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) - # return sco_excel.send_excel_file(xls, filename) - - -def has_existing_decision(M, E, etudid): - """Verifie s'il y a une validation pour cet etudiant dans ce semestre ou UE - Si oui, return True - """ - formsemestre_id = M["formsemestre_id"] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - if nt.get_etud_decision_sem(etudid): - return True - dec_ues = nt.get_etud_decision_ues(etudid) - if dec_ues: - mod = sco_edit_module.module_list({"module_id": M["module_id"]})[0] - ue_id = mod["ue_id"] - if ue_id in dec_ues: - return True # decision pour l'UE a laquelle appartient cette evaluation - - return False # pas de decision de jury affectee par cette note - - -# ----------------------------- -# Nouveau formulaire saisie notes (2016) - - -def saisie_notes(evaluation_id, group_ids=[]): - """Formulaire saisie notes d'une évaluation pour un groupe""" - if not isinstance(evaluation_id, int): - raise ScoInvalidParamError() - group_ids = [int(group_id) for group_id in group_ids] - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - if not evals: - raise ScoValueError("évaluation inexistante") - E = evals[0] - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = M["formsemestre_id"] - # Check access - # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): - return ( - html_sco_header.sco_header() - + "

    Modification des notes impossible pour %s

    " - % current_user.user_name - + """

    (vérifiez que le semestre n'est pas verrouillé et que vous - avez l'autorisation d'effectuer cette opération)

    -

    Continuer

    - """ - % E["moduleimpl_id"] - + html_sco_header.sco_footer() - ) - - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids=group_ids, - formsemestre_id=formsemestre_id, - select_all_when_unspecified=True, - etat=None, - ) - - if E["description"]: - page_title = 'Saisie "%s"' % E["description"] - else: - page_title = "Saisie des notes" - - # HTML page: - H = [ - html_sco_header.sco_header( - page_title=page_title, - javascripts=sco_groups_view.JAVASCRIPTS + ["js/saisie_notes.js"], - cssstyles=sco_groups_view.CSSSTYLES, - init_qtip=True, - ), - sco_evaluations.evaluation_describe( - evaluation_id=evaluation_id, link_saisie=False - ), - '
    Saisie des notes', - ] - H.append("""
    """) - H.append(sco_groups_view.form_groups_choice(groups_infos)) - H.append('') - H.append( - htmlutils.make_menu( - "Autres opérations", - [ - { - "title": "Saisie par fichier tableur", - "id": "menu_saisie_tableur", - "endpoint": "notes.saisie_notes_tableur", - "args": { - "evaluation_id": E["evaluation_id"], - "group_ids": groups_infos.group_ids, - }, - }, - { - "title": "Voir toutes les notes du module", - "endpoint": "notes.evaluation_listenotes", - "args": {"moduleimpl_id": E["moduleimpl_id"]}, - }, - { - "title": "Effacer toutes les notes de cette évaluation", - "endpoint": "notes.evaluation_suppress_alln", - "args": {"evaluation_id": E["evaluation_id"]}, - }, - ], - alone=True, - ) - ) - H.append("""
    """) - - # Le formulaire de saisie des notes: - destination = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=E["moduleimpl_id"], - ) - - form = _form_saisie_notes(E, M, groups_infos.group_ids, destination=destination) - if form is None: - log(f"redirecting to {destination}") - return flask.redirect(destination) - H.append(form) - # - H.append("
    ") # /saisie_notes - - H.append( - """
    -

    Les modifications sont enregistrées au fur et à mesure.

    -

    Codes spéciaux:

    -
      -
    • ABS: absent (compte comme un zéro)
    • -
    • EXC: excusé (note neutralisée)
    • -
    • SUPR: pour supprimer une note existante
    • -
    • ATT: note en attente (permet de publier une évaluation avec des notes manquantes)
    • -
    -
    """ - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): - notes_db = sco_evaluation_db.do_evaluation_get_all_notes( - eval_dict["evaluation_id"] - ) # Notes existantes - cnx = ndb.GetDBConnexion() - etuds = [] - for etudid in etudids: - # infos identite etudiant - e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] - sco_etud.format_etud_ident(e) - etuds.append(e) - # infos inscription dans ce semestre - e["inscr"] = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - {"etudid": etudid, "formsemestre_id": formsemestre_id} - )[0] - # Groupes auxquels appartient cet étudiant: - e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id) - - # Information sur absence (tenant compte de la demi-journée) - jour_iso = ndb.DateDMYtoISO(eval_dict["jour"]) - warn_abs_lst = [] - if eval_dict["matin"]: - nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1) - nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1) - if nbabs: - if nbabsjust: - warn_abs_lst.append("absent justifié le matin !") - else: - warn_abs_lst.append("absent le matin !") - if eval_dict["apresmidi"]: - nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0) - nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0) - if nbabs: - if nbabsjust: - warn_abs_lst.append("absent justifié l'après-midi !") - else: - warn_abs_lst.append("absent l'après-midi !") - - e["absinfo"] = '' + " ".join(warn_abs_lst) + " " - - # Note actuelle de l'étudiant: - if etudid in notes_db: - e["val"] = _displayNote(notes_db[etudid]["value"]) - comment = notes_db[etudid]["comment"] - if comment is None: - comment = "" - e["explanation"] = "%s (%s) %s" % ( - notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"), - notes_db[etudid]["uid"], - comment, - ) - else: - e["val"] = "" - e["explanation"] = "" - # Démission ? - if e["inscr"]["etat"] == "D": - # if not e['val']: - e["val"] = "DEM" - e["explanation"] = "Démission" - - etuds.sort(key=lambda x: (x["nom"], x["prenom"])) - - return etuds - - -def _form_saisie_notes(E, M, group_ids, destination=""): - """Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M - pour les groupes indiqués. - - On charge tous les étudiants, ne seront montrés que ceux - des groupes sélectionnés grace a un filtre en javascript. - """ - evaluation_id = E["evaluation_id"] - formsemestre_id = M["formsemestre_id"] - - etudids = [ - x[0] - for x in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, getallstudents=True, include_demdef=True - ) - ] - if not etudids: - return '
    Aucun étudiant sélectionné !
    ' - - # Decisions de jury existantes ? - decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids} - # Nb de decisions de jury (pour les inscrits à l'évaluation): - nb_decisions = sum(decisions_jury.values()) - - etuds = _get_sorted_etuds(E, etudids, formsemestre_id) - - # Build form: - descr = [ - ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), - ("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}), - ("group_ids", {"default": group_ids, "input_type": "hidden", "type": "list"}), - # ('note_method', { 'default' : note_method, 'input_type' : 'hidden'}), - ("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}), - ("changed", {"default": "0", "input_type": "hidden"}), # changed in JS - ] - if M["module"]["module_type"] in ( - ModuleType.STANDARD, - ModuleType.RESSOURCE, - ModuleType.SAE, - ): - descr.append( - ( - "s3", - { - "input_type": "text", # affiche le barème - "title": "Notes ", - "cssclass": "formnote_bareme", - "readonly": True, - "default": " / %g" % E["note_max"], - }, - ) - ) - elif M["module"]["module_type"] == ModuleType.MALUS: - descr.append( - ( - "s3", - { - "input_type": "text", # affiche le barème - "title": "", - "cssclass": "formnote_bareme", - "readonly": True, - "default": "Points de malus (soustraits à la moyenne de l'UE, entre -20 et 20)", - }, - ) - ) - else: - raise ValueError("invalid module type (%s)" % M["module"]["module_type"]) # bug - - initvalues = {} - for e in etuds: - etudid = e["etudid"] - disabled = e["val"] == "DEM" - etud_classes = [] - if disabled: - classdem = " etud_dem" - etud_classes.append("etud_dem") - disabled_attr = 'disabled="%d"' % disabled - else: - classdem = "" - disabled_attr = "" - # attribue a chaque element une classe css par groupe: - for group_info in e["groups"]: - etud_classes.append("group-" + str(group_info["group_id"])) - - label = ( - '' % classdem - + e["civilite_str"] - + " " - + sco_etud.format_nomprenom(e, reverse=True) - + "" - ) - - # Historique des saisies de notes: - if not disabled: - explanation = ( - '' % etudid - + get_note_history_menu(evaluation_id, etudid) - + "" - ) - else: - explanation = "" - explanation = e["absinfo"] + explanation - - # Lien modif decision de jury: - explanation += '' % etudid - - # Valeur actuelle du champ: - initvalues["note_" + str(etudid)] = e["val"] - label_link = '%s' % (etudid, label) - - # Element de formulaire: - descr.append( - ( - "note_" + str(etudid), - { - "size": 5, - "title": label_link, - "explanation": explanation, - "return_focus_next": True, - "attributes": [ - 'class="note%s"' % classdem, - disabled_attr, - 'data-last-saved-value="%s"' % e["val"], - 'data-orig-value="%s"' % e["val"], - 'data-etudid="%s"' % etudid, - ], - "template": """%(label)s - %(elem)s - """, - }, - ) - ) - # - H = [] - if nb_decisions > 0: - H.append( - """
    -
      -
    • Attention: il y a déjà des décisions de jury enregistrées pour %d étudiants. Après changement des notes, vérifiez la situation !
    • -
    -
    """ - % nb_decisions - ) - # H.append('''
    ''') - - tf = TF( - destination, - scu.get_request_args(), - descr, - initvalues=initvalues, - submitbutton=False, - formid="formnotes", - method="GET", - ) - H.append(tf.getform()) # check and init - H.append( - f"""Terminer - """ - ) - if tf.canceled(): - return None - elif (not tf.submitted()) or not tf.result: - # ajout formulaire saisie notes manquantes - H.append( - """ -
    -
    - Mettre toutes les notes manquantes à - - - affecte tous les groupes. ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente" -
    -
    - """ - % evaluation_id - ) - # affiche formulaire - return "\n".join(H) - else: - # form submission - # rien à faire - return None - - -def save_note(etudid=None, evaluation_id=None, value=None, comment=""): - """Enregistre une note (ajax)""" - authuser = current_user - log( - "save_note: evaluation_id=%s etudid=%s uid=%s value=%s" - % (evaluation_id, etudid, authuser, value) - ) - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - Mod["url"] = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=M["moduleimpl_id"], - _external=True, - ) - result = {"nbchanged": 0} # JSON - # Check access: admin, respformation, or responsable_id - if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): - result["status"] = "unauthorized" - else: - L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) - if L: - nbchanged, _, existing_decisions = notes_add( - authuser, evaluation_id, L, comment=comment, do_it=True - ) - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Chargement notes dans %(titre)s' % Mod, - url=Mod["url"], - max_frequency=30 * 60, # 30 minutes - ) - result["nbchanged"] = nbchanged - result["existing_decisions"] = existing_decisions - if nbchanged > 0: - result["history_menu"] = get_note_history_menu(evaluation_id, etudid) - else: - result["history_menu"] = "" # no update needed - result["status"] = "ok" - return scu.sendJSON(result) - - -def get_note_history_menu(evaluation_id, etudid): - """Menu HTML historique de la note""" - history = sco_undo_notes.get_note_history(evaluation_id, etudid) - if not history: - return "" - - H = [] - if len(history) > 1: - H.append( - ' + """ + % (evaluation_id, groups_infos.groups_query_args, evaluation_id, evaluation_id) + ) + + H.append( + """
    + Etape 2 : chargement d'un fichier de notes""" # ' + ) + + nf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), + ( + "notefile", + {"input_type": "file", "title": "Fichier de note (.xls)", "size": 44}, + ), + ( + "comment", + { + "size": 44, + "title": "Commentaire", + "explanation": "(la colonne remarque du fichier excel est ignorée)", + }, + ), + ), + formid="notesfile", + submitlabel="Télécharger", + ) + if nf[0] == 0: + H.append( + """

    Le fichier doit être un fichier tableur obtenu via + l'étape 1 ci-dessus, puis complété et enregistré au format Excel. +

    """ + ) + H.append(nf[1]) + elif nf[0] == -1: + H.append("

    Annulation

    ") + elif nf[0] == 1: + updiag = do_evaluation_upload_xls() + if updiag[0]: + H.append(updiag[1]) + H.append( + """

    Notes chargées.    + + Revenir au tableau de bord du module +     + Charger d'autres notes dans cette évaluation +

    """ + % E + ) + else: + H.append("""

    Notes non chargées !

    """ + updiag[1]) + H.append( + """ +

    + Reprendre +

    """ + % E + ) + # + H.append("""

    Autres opérations

      """) + if sco_permissions_check.can_edit_notes( + current_user, E["moduleimpl_id"], allow_ens=False + ): + H.append( + """ +
    • +
      + Mettre toutes les notes manquantes à + + + ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente" +
      +
    • +
    • Effacer toutes les notes de cette évaluation (ceci permet ensuite de supprimer l'évaluation si besoin) +
    • """ + % (evaluation_id, evaluation_id) + ) # ' + H.append( + """
    • Revenir au module
    • +
    • Revenir au formulaire de saisie
    • +
    """ + % E + ) + + H.append( + """

    Explications

    +
      +
    1. Etape 1: +
      1. choisir le ou les groupes d'étudiants;
      2. +
      3. télécharger le fichier Excel à remplir.
      4. +
      +
    2. +
    3. Etape 2 (cadre vert): Indiquer le fichier Excel téléchargé à l'étape 1 et dans lequel on a saisi des notes. Remarques: +
        +
      • le fichier Excel peut être incomplet: on peut ne saisir que quelques notes et répéter l'opération (en téléchargeant un nouveau fichier) plus tard;
      • +
      • seules les valeurs des notes modifiées sont prises en compte;
      • +
      • seules les notes sont extraites du fichier Excel;
      • +
      • on peut optionnellement ajouter un commentaire (type "copies corrigées par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire". +
      • +
      • le fichier Excel doit impérativement être celui chargé à l'étape 1 pour cette évaluation. Il n'est pas possible d'utiliser une liste d'appel ou autre document Excel téléchargé d'une autre page.
      • +
      +
    4. +
    +""" + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def feuille_saisie_notes(evaluation_id, group_ids=[]): + """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" + evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) + if not evals: + raise ScoValueError("invalid evaluation_id") + eval_dict = evals[0] + M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0] + formsemestre_id = M["formsemestre_id"] + Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] + sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) + mod_responsable = sco_users.user_info(M["responsable_id"]) + if eval_dict["jour"]: + indication_date = ndb.DateDMYtoISO(eval_dict["jour"]) + else: + indication_date = scu.sanitize_filename(eval_dict["description"])[:12] + eval_name = "%s-%s" % (Mod["code"], indication_date) + + if eval_dict["description"]: + evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"]) + else: + evaltitre = "évaluation du %s" % eval_dict["jour"] + description = "%s en %s (%s) resp. %s" % ( + evaltitre, + Mod["abbrev"] or "", + Mod["code"] or "", + mod_responsable["prenomnom"], + ) + + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids=group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + etat=None, + ) + groups = sco_groups.listgroups(groups_infos.group_ids) + gr_title_filename = sco_groups.listgroups_filename(groups) + # gr_title = sco_groups.listgroups_abbrev(groups) + if None in [g["group_name"] for g in groups]: # tous les etudiants + getallstudents = True + # gr_title = "tous" + gr_title_filename = "tous" + else: + getallstudents = False + etudids = [ + x[0] + for x in sco_groups.do_evaluation_listeetuds_groups( + evaluation_id, groups, getallstudents=getallstudents, include_demdef=True + ) + ] + + # une liste de liste de chaines: lignes de la feuille de calcul + L = [] + + etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id) + for e in etuds: + etudid = e["etudid"] + groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + grc = sco_groups.listgroups_abbrev(groups) + + L.append( + [ + "%s" % etudid, + e["nom"].upper(), + e["prenom"].lower().capitalize(), + e["inscr"]["etat"], + grc, + e["val"], + e["explanation"], + ] + ) + + filename = "notes_%s_%s" % (eval_name, gr_title_filename) + xls = sco_excel.excel_feuille_saisie( + eval_dict, sem["titreannee"], description, lines=L + ) + return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) + # return sco_excel.send_excel_file(xls, filename) + + +def has_existing_decision(M, E, etudid): + """Verifie s'il y a une validation pour cet etudiant dans ce semestre ou UE + Si oui, return True + """ + formsemestre_id = M["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + if nt.get_etud_decision_sem(etudid): + return True + dec_ues = nt.get_etud_decision_ues(etudid) + if dec_ues: + mod = sco_edit_module.module_list({"module_id": M["module_id"]})[0] + ue_id = mod["ue_id"] + if ue_id in dec_ues: + return True # decision pour l'UE a laquelle appartient cette evaluation + + return False # pas de decision de jury affectee par cette note + + +# ----------------------------- +# Nouveau formulaire saisie notes (2016) + + +def saisie_notes(evaluation_id, group_ids=[]): + """Formulaire saisie notes d'une évaluation pour un groupe""" + if not isinstance(evaluation_id, int): + raise ScoInvalidParamError() + group_ids = [int(group_id) for group_id in group_ids] + evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) + if not evals: + raise ScoValueError("évaluation inexistante") + E = evals[0] + M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] + formsemestre_id = M["formsemestre_id"] + # Check access + # (admin, respformation, and responsable_id) + if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): + return ( + html_sco_header.sco_header() + + "

    Modification des notes impossible pour %s

    " + % current_user.user_name + + """

    (vérifiez que le semestre n'est pas verrouillé et que vous + avez l'autorisation d'effectuer cette opération)

    +

    Continuer

    + """ + % E["moduleimpl_id"] + + html_sco_header.sco_footer() + ) + + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids=group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + etat=None, + ) + + if E["description"]: + page_title = 'Saisie "%s"' % E["description"] + else: + page_title = "Saisie des notes" + + # HTML page: + H = [ + html_sco_header.sco_header( + page_title=page_title, + javascripts=sco_groups_view.JAVASCRIPTS + ["js/saisie_notes.js"], + cssstyles=sco_groups_view.CSSSTYLES, + init_qtip=True, + ), + sco_evaluations.evaluation_describe( + evaluation_id=evaluation_id, link_saisie=False + ), + '
    Saisie des notes', + ] + H.append("""
    """) + H.append(sco_groups_view.form_groups_choice(groups_infos)) + H.append('') + H.append( + htmlutils.make_menu( + "Autres opérations", + [ + { + "title": "Saisie par fichier tableur", + "id": "menu_saisie_tableur", + "endpoint": "notes.saisie_notes_tableur", + "args": { + "evaluation_id": E["evaluation_id"], + "group_ids": groups_infos.group_ids, + }, + }, + { + "title": "Voir toutes les notes du module", + "endpoint": "notes.evaluation_listenotes", + "args": {"moduleimpl_id": E["moduleimpl_id"]}, + }, + { + "title": "Effacer toutes les notes de cette évaluation", + "endpoint": "notes.evaluation_suppress_alln", + "args": {"evaluation_id": E["evaluation_id"]}, + }, + ], + alone=True, + ) + ) + H.append("""
    """) + + # Le formulaire de saisie des notes: + destination = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=E["moduleimpl_id"], + ) + + form = _form_saisie_notes(E, M, groups_infos.group_ids, destination=destination) + if form is None: + log(f"redirecting to {destination}") + return flask.redirect(destination) + H.append(form) + # + H.append("
    ") # /saisie_notes + + H.append( + """
    +

    Les modifications sont enregistrées au fur et à mesure.

    +

    Codes spéciaux:

    +
      +
    • ABS: absent (compte comme un zéro)
    • +
    • EXC: excusé (note neutralisée)
    • +
    • SUPR: pour supprimer une note existante
    • +
    • ATT: note en attente (permet de publier une évaluation avec des notes manquantes)
    • +
    +
    """ + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): + notes_db = sco_evaluation_db.do_evaluation_get_all_notes( + eval_dict["evaluation_id"] + ) # Notes existantes + cnx = ndb.GetDBConnexion() + etuds = [] + for etudid in etudids: + # infos identite etudiant + e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] + sco_etud.format_etud_ident(e) + etuds.append(e) + # infos inscription dans ce semestre + e["inscr"] = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + {"etudid": etudid, "formsemestre_id": formsemestre_id} + )[0] + # Groupes auxquels appartient cet étudiant: + e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id) + + # Information sur absence (tenant compte de la demi-journée) + jour_iso = ndb.DateDMYtoISO(eval_dict["jour"]) + warn_abs_lst = [] + if eval_dict["matin"]: + nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1) + nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1) + if nbabs: + if nbabsjust: + warn_abs_lst.append("absent justifié le matin !") + else: + warn_abs_lst.append("absent le matin !") + if eval_dict["apresmidi"]: + nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0) + nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0) + if nbabs: + if nbabsjust: + warn_abs_lst.append("absent justifié l'après-midi !") + else: + warn_abs_lst.append("absent l'après-midi !") + + e["absinfo"] = '' + " ".join(warn_abs_lst) + " " + + # Note actuelle de l'étudiant: + if etudid in notes_db: + e["val"] = _displayNote(notes_db[etudid]["value"]) + comment = notes_db[etudid]["comment"] + if comment is None: + comment = "" + e["explanation"] = "%s (%s) %s" % ( + notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"), + notes_db[etudid]["uid"], + comment, + ) + else: + e["val"] = "" + e["explanation"] = "" + # Démission ? + if e["inscr"]["etat"] == "D": + # if not e['val']: + e["val"] = "DEM" + e["explanation"] = "Démission" + + etuds.sort(key=lambda x: (x["nom"], x["prenom"])) + + return etuds + + +def _form_saisie_notes(E, M, group_ids, destination=""): + """Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M + pour les groupes indiqués. + + On charge tous les étudiants, ne seront montrés que ceux + des groupes sélectionnés grace a un filtre en javascript. + """ + evaluation_id = E["evaluation_id"] + formsemestre_id = M["formsemestre_id"] + + etudids = [ + x[0] + for x in sco_groups.do_evaluation_listeetuds_groups( + evaluation_id, getallstudents=True, include_demdef=True + ) + ] + if not etudids: + return '
    Aucun étudiant sélectionné !
    ' + + # Decisions de jury existantes ? + decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids} + # Nb de decisions de jury (pour les inscrits à l'évaluation): + nb_decisions = sum(decisions_jury.values()) + + etuds = _get_sorted_etuds(E, etudids, formsemestre_id) + + # Build form: + descr = [ + ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), + ("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}), + ("group_ids", {"default": group_ids, "input_type": "hidden", "type": "list"}), + # ('note_method', { 'default' : note_method, 'input_type' : 'hidden'}), + ("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}), + ("changed", {"default": "0", "input_type": "hidden"}), # changed in JS + ] + if M["module"]["module_type"] in ( + ModuleType.STANDARD, + ModuleType.RESSOURCE, + ModuleType.SAE, + ): + descr.append( + ( + "s3", + { + "input_type": "text", # affiche le barème + "title": "Notes ", + "cssclass": "formnote_bareme", + "readonly": True, + "default": " / %g" % E["note_max"], + }, + ) + ) + elif M["module"]["module_type"] == ModuleType.MALUS: + descr.append( + ( + "s3", + { + "input_type": "text", # affiche le barème + "title": "", + "cssclass": "formnote_bareme", + "readonly": True, + "default": "Points de malus (soustraits à la moyenne de l'UE, entre -20 et 20)", + }, + ) + ) + else: + raise ValueError("invalid module type (%s)" % M["module"]["module_type"]) # bug + + initvalues = {} + for e in etuds: + etudid = e["etudid"] + disabled = e["val"] == "DEM" + etud_classes = [] + if disabled: + classdem = " etud_dem" + etud_classes.append("etud_dem") + disabled_attr = 'disabled="%d"' % disabled + else: + classdem = "" + disabled_attr = "" + # attribue a chaque element une classe css par groupe: + for group_info in e["groups"]: + etud_classes.append("group-" + str(group_info["group_id"])) + + label = ( + '' % classdem + + e["civilite_str"] + + " " + + sco_etud.format_nomprenom(e, reverse=True) + + "" + ) + + # Historique des saisies de notes: + if not disabled: + explanation = ( + '' % etudid + + get_note_history_menu(evaluation_id, etudid) + + "" + ) + else: + explanation = "" + explanation = e["absinfo"] + explanation + + # Lien modif decision de jury: + explanation += '' % etudid + + # Valeur actuelle du champ: + initvalues["note_" + str(etudid)] = e["val"] + label_link = '%s' % (etudid, label) + + # Element de formulaire: + descr.append( + ( + "note_" + str(etudid), + { + "size": 5, + "title": label_link, + "explanation": explanation, + "return_focus_next": True, + "attributes": [ + 'class="note%s"' % classdem, + disabled_attr, + 'data-last-saved-value="%s"' % e["val"], + 'data-orig-value="%s"' % e["val"], + 'data-etudid="%s"' % etudid, + ], + "template": """%(label)s + %(elem)s + """, + }, + ) + ) + # + H = [] + if nb_decisions > 0: + H.append( + """
    +
      +
    • Attention: il y a déjà des décisions de jury enregistrées pour %d étudiants. Après changement des notes, vérifiez la situation !
    • +
    +
    """ + % nb_decisions + ) + # H.append('''
    ''') + + tf = TF( + destination, + scu.get_request_args(), + descr, + initvalues=initvalues, + submitbutton=False, + formid="formnotes", + method="GET", + ) + H.append(tf.getform()) # check and init + H.append( + f"""Terminer + """ + ) + if tf.canceled(): + return None + elif (not tf.submitted()) or not tf.result: + # ajout formulaire saisie notes manquantes + H.append( + """ +
    +
    + Mettre toutes les notes manquantes à + + + affecte tous les groupes. ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente" +
    +
    + """ + % evaluation_id + ) + # affiche formulaire + return "\n".join(H) + else: + # form submission + # rien à faire + return None + + +def save_note(etudid=None, evaluation_id=None, value=None, comment=""): + """Enregistre une note (ajax)""" + authuser = current_user + log( + "save_note: evaluation_id=%s etudid=%s uid=%s value=%s" + % (evaluation_id, etudid, authuser, value) + ) + E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] + M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] + Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] + Mod["url"] = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=M["moduleimpl_id"], + _external=True, + ) + result = {"nbchanged": 0} # JSON + # Check access: admin, respformation, or responsable_id + if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): + result["status"] = "unauthorized" + else: + L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) + if L: + nbchanged, _, existing_decisions = notes_add( + authuser, evaluation_id, L, comment=comment, do_it=True + ) + ScolarNews.add( + typ=ScolarNews.NEWS_NOTE, + obj=M["moduleimpl_id"], + text='Chargement notes dans %(titre)s' % Mod, + url=Mod["url"], + max_frequency=30 * 60, # 30 minutes + ) + result["nbchanged"] = nbchanged + result["existing_decisions"] = existing_decisions + if nbchanged > 0: + result["history_menu"] = get_note_history_menu(evaluation_id, etudid) + else: + result["history_menu"] = "" # no update needed + result["status"] = "ok" + return scu.sendJSON(result) + + +def get_note_history_menu(evaluation_id, etudid): + """Menu HTML historique de la note""" + history = sco_undo_notes.get_note_history(evaluation_id, etudid) + if not history: + return "" + + H = [] + if len(history) > 1: + H.append( + ' - """ - for sem in possible_sems: - menu_sem += ( - """\n""" - % sem - ) - menu_sem += """""" - H.append( - '
    Ajouter un semestre:' - ) - H.append(menu_sem) - H.append( - '' % self.semset_id - ) - H.append('') - H.append("
    ") - else: - H.append("pas de semestres à ajouter") - return "\n".join(H) - - def html_diagnostic(self): - """Affichage de la partie Effectifs et Liste des étudiants - (actif seulement si un portail est configuré) - """ - if sco_portal_apogee.has_portal(): - return self.bilan.html_diagnostic() - else: - return "" - - -def get_semsets_list(): - """Liste de tous les semsets - Trié par date_debut, le plus récent d'abord - """ - cnx = ndb.GetDBConnexion() - L = [] - for s in semset_list(cnx): - L.append(SemSet(semset_id=s["semset_id"])) - L.sort(key=lambda s: s["date_debut"], reverse=True) - return L - - -def do_semset_create(title="", annee_scolaire=None, sem_id=None): - """Create new setset""" - log( - "do_semset_create(title=%s, annee_scolaire=%s, sem_id=%s)" - % (title, annee_scolaire, sem_id) - ) - SemSet(title=title, annee_scolaire=annee_scolaire, sem_id=sem_id) - return flask.redirect("semset_page") - - -def do_semset_delete(semset_id, dialog_confirmed=False): - """Delete a semset""" - if not semset_id: - raise ScoValueError("empty semset_id") - s = SemSet(semset_id=semset_id) - if not dialog_confirmed: - return scu.confirm_dialog( - "

    Suppression de l'ensemble %(title)s ?

    " % s, - dest_url="", - parameters={"semset_id": semset_id}, - cancel_url="semset_page", - ) - s.delete() - return flask.redirect("semset_page") - - -def edit_semset_set_title(id=None, value=None): - """Change title of semset""" - title = value.strip() - if not id: - raise ScoValueError("empty semset_id") - SemSet(semset_id=id) - cnx = ndb.GetDBConnexion() - semset_edit(cnx, {"semset_id": id, "title": title}) - return title - - -def do_semset_add_sem(semset_id, formsemestre_id): - """Add a sem to a semset""" - if not semset_id: - raise ScoValueError("empty semset_id") - if formsemestre_id == "": - raise ScoValueError("pas de semestre choisi !") - s = SemSet(semset_id=semset_id) - # check for valid formsemestre_id - _ = sco_formsemestre.get_formsemestre(formsemestre_id) # raise exc - - s.add(formsemestre_id) - - return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id) - - -def do_semset_remove_sem(semset_id, formsemestre_id): - """Add a sem to a semset""" - if not semset_id: - raise ScoValueError("empty semset_id") - s = SemSet(semset_id) - - s.remove(formsemestre_id) - - return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id) - - -# ---------------------------------------- - - -def semset_page(format="html"): - """Page avec liste semsets: - Table avec : date_debut date_fin titre liste des semestres - """ - semsets = get_semsets_list() - for s in semsets: - s["suppress"] = scu.icontag( - "delete_small_img", border="0", alt="supprimer", title="Supprimer" - ) - s["_suppress_target"] = "do_semset_delete?semset_id=%s" % (s["semset_id"]) - s["export_link"] = "Export Apogée" - s["_export_link_target"] = "apo_semset_maq_status?semset_id=%s" % s.semset_id - s["_export_link_link_class"] = "stdlink" - # Le lien associé au nom de semestre redirigeait vers le semset - # (remplacé par n liens vers chacun des semestres) - # s['_semtitles_str_target'] = s['_export_link_target'] - # Experimental: - s[ - "_title_td_attrs" - ] = 'class="inplace_edit" data-url="edit_semset_set_title" id="%s"' % ( - s["semset_id"] - ) - - tab = GenTable( - rows=semsets, - titles={ - "annee_scolaire": "Année scolaire", - "sem_id": "P", - "date_debut": "Début", - "date_fin": "Fin", - "title": "Titre", - "export_link": "", - "semtitles_str": "semestres", - }, - columns_ids=[ - "suppress", - "annee_scolaire", - "sem_id", - "date_debut", - "date_fin", - "title", - "export_link", - "semtitles_str", - ], - html_sortable=True, - html_class="table_leftalign", - filename="semsets", - preferences=sco_preferences.SemPreferences(), - ) - if format != "html": - return tab.make_page(format=format) - - page_title = "Ensembles de semestres" - H = [ - html_sco_header.sco_header( - page_title=page_title, - init_qtip=True, - javascripts=["libjs/jinplace-1.2.1.min.js"], - ), - """""", - "

    %s

    " % page_title, - ] - H.append(tab.html()) - - annee_courante = int(scu.AnneeScolaire()) - menu_annee = "\n".join( - [ - '' % (i, i) - for i in range(2014, annee_courante + 1) - ] - ) - - H.append( - """ -
    -

    Création nouvel ensemble

    -
    - - - - -
    - """ - ) - - H.append( - """ - - """ - ) - - return "\n".join(H) + html_sco_header.sco_footer() +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Gestion des ensembles de semestres: + +class SemSet: un ensemble de semestres d'un département, à exporter ves Apogée. En principe de la meme annee scolaire. + + SemSet.annees_scolaires() : les annees scolaires. e.g. [ 2015, 2016 ], ou le plus souvent, une seule: [2016] + SemSet.list_etapes(): listes des étapes apogee et vdi des semestres (instances de ApoEtapeVDI) + + SemSet.add(sem): ajoute un semestre à l'ensemble + + +sem_set_list() + +""" + +import flask +from flask import g + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre +from app.scodoc import html_sco_header +from app.scodoc import sco_cache +from app.scodoc import sco_etape_apogee +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_status +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_preferences +from app.scodoc.gen_tables import GenTable +from app import log +from app.scodoc.sco_etape_bilan import EtapeBilan +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_vdi import ApoEtapeVDI +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu + + +_semset_editor = ndb.EditableTable( + "notes_semset", + "semset_id", + ("semset_id", "title", "annee_scolaire", "sem_id"), + filter_dept=True, +) + +semset_create = _semset_editor.create +semset_edit = _semset_editor.edit +semset_list = _semset_editor.list +semset_delete = _semset_editor.delete + + +class SemSet(dict): + def __init__(self, semset_id=None, title="", annee_scolaire="", sem_id=""): + """Load and init, or, if semset_id is not specified, create""" + if not annee_scolaire and not semset_id: + # on autorise annee_scolaire null si sem_id pour pouvoir lire les anciens semsets + # mal construits... + raise ScoValueError("Année scolaire invalide !") + self.semset_id = semset_id + self["semset_id"] = semset_id + self.sems = [] + self.formsemestre_ids = [] + cnx = ndb.GetDBConnexion() + if semset_id: # read existing set + L = semset_list(cnx, args={"semset_id": semset_id}) + if not L: + raise ScoValueError(f"Ensemble inexistant ! (semset {semset_id})") + self["title"] = L[0]["title"] + self["annee_scolaire"] = L[0]["annee_scolaire"] + self["sem_id"] = L[0]["sem_id"] + r = ndb.SimpleDictFetch( + "SELECT formsemestre_id FROM notes_semset_formsemestre WHERE semset_id = %(semset_id)s", + {"semset_id": semset_id}, + ) + if r: + self.formsemestre_ids = {x["formsemestre_id"] for x in r} # a set + else: # create a new empty set + self.semset_id = semset_create( + cnx, + {"title": title, "annee_scolaire": annee_scolaire, "sem_id": sem_id}, + ) + log("created new semset_id=%s" % self.semset_id) + self.load_sems() + # analyse des semestres pour construire le bilan par semestre et par étape + self.bilan = EtapeBilan() + for sem in self.sems: + self.bilan.add_sem(sem) + + def delete(self): + """delete""" + cnx = ndb.GetDBConnexion() + semset_delete(cnx, self.semset_id) + + def edit(self, args): + cnx = ndb.GetDBConnexion() + semset_edit(cnx, args) + + def load_sems(self): + """Load formsemestres""" + self.sems = [] + for formsemestre_id in self.formsemestre_ids: + self.sems.append(sco_formsemestre.get_formsemestre(formsemestre_id)) + + if self.sems: + self["date_debut"] = min([sem["date_debut_iso"] for sem in self.sems]) + self["date_fin"] = max([sem["date_fin_iso"] for sem in self.sems]) + else: + self["date_debut"] = "" + self["date_fin"] = "" + + self["etapes"] = self.list_etapes() + self["semtitles"] = [sem["titre_num"] for sem in self.sems] + + # Construction du ou des lien(s) vers le semestre + pattern = '%(titreannee)s' + self["semlinks"] = [(pattern % sem) for sem in self.sems] + self["semtitles_str"] = "
    ".join(self["semlinks"]) + + def fill_formsemestres(self): + for sem in self.sems: + sco_formsemestre_status.fill_formsemestre(sem) + ets = sco_etape_apogee.apo_get_sem_etapes(sem) + sem["etapes_apo_str"] = sco_formsemestre.etapes_apo_str(sorted(list(ets))) + + def add(self, formsemestre_id): + # check + if formsemestre_id in self.formsemestre_ids: + return # already there + if formsemestre_id not in [ + sem["formsemestre_id"] for sem in self.list_possible_sems() + ]: + raise ValueError( + "can't add %s to set %s: incompatible sem_id" + % (formsemestre_id, self.semset_id) + ) + + ndb.SimpleQuery( + """INSERT INTO notes_semset_formsemestre + (formsemestre_id, semset_id) + VALUES (%(formsemestre_id)s, %(semset_id)s) + """, + { + "formsemestre_id": formsemestre_id, + "semset_id": self.semset_id, + }, + ) + self.load_sems() # update our list + + def remove(self, formsemestre_id): + ndb.SimpleQuery( + """DELETE FROM notes_semset_formsemestre + WHERE semset_id=%(semset_id)s + AND formsemestre_id=%(formsemestre_id)s + """, + {"formsemestre_id": formsemestre_id, "semset_id": self.semset_id}, + ) + self.load_sems() # update our list + + def annees_scolaires(self): + """Les annees scolaires. e.g. [ 2015, 2016 ], ou le plus souvent, une seule: [2016] + L'année scolaire est l'année de début du semestre (2015 pour 2015-2016) + """ + annees = list(set([int(s["annee_debut"]) for s in self.sems])) + annees.sort() + return annees + + def list_etapes(self): + """Listes triée des étapes Apogée des semestres (instances de ApoEtapeVDI). + Chaque étape apparait une seule fois, dans sa forme la plus générale. + Si on a [ 'V1RT', 'V1RT!111' ], le résultat sera [ 'V1RT' ] + Si on a [ 'V1RT!111', 'V1RT!112' ], le résultat sera [ 'V1RT!111', 'V1RT!112' ] + """ + D = {} # { etape : { versions vdi } } + for s in self.sems: + for et in s["etapes"]: + if et: + if et.etape in D: + D[et.etape].add(et.vdi) + else: + D[et.etape] = {et.vdi} + # enlève les versions excédentaires: + for etape in D: + if "" in D[etape]: + D[etape] = [""] + # forme liste triée d'instances: + etapes = [] + for etape in D: + for vdi in D[etape]: + etapes.append(ApoEtapeVDI(etape=etape, vdi=vdi)) + etapes.sort() + return etapes + + def list_possible_sems(self): + """List sems that can be added to this set""" + sems = sco_formsemestre.do_formsemestre_list() + # remove sems already here: + sems = [ + sem for sem in sems if sem["formsemestre_id"] not in self.formsemestre_ids + ] + # filter annee, sem_id: + # Remplacement du filtre de proposition des semestres potentiels + # au lieu de la parité (sem 1 et 3 / sem 2 et 4) on filtre sur la date de + # debut du semestre: ceci permet d'ajouter les semestres décalés + if self["annee_scolaire"]: + sems = [ + sem + for sem in sems + if sco_formsemestre.sem_in_semestre_scolaire( + sem, + year=self["annee_scolaire"], + saison=self["sem_id"], + ) + ] + return sems + + def load_etuds(self): + self["etuds_without_nip"] = set() # etudids + self["jury_ok"] = True + for sem in self.sems: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + sem["etuds"] = list(nt.identdict.values()) + sem["nips"] = {e["code_nip"] for e in sem["etuds"] if e["code_nip"]} + sem["etuds_without_nip"] = { + e["etudid"] for e in sem["etuds"] if not e["code_nip"] + } + self["etuds_without_nip"] |= sem["etuds_without_nip"] + sem["jury_ok"] = nt.all_etuds_have_sem_decisions() + self["jury_ok"] &= sem["jury_ok"] + + def html_descr(self): + """Short HTML description""" + H = [ + """Ensemble de semestres %(title)s""" % self + ] + if self["annee_scolaire"]: + H.append("

    Année scolaire: %(annee_scolaire)s

    " % self) + else: + H.append( + "

    Année(s) scolaire(s) présentes: %s" + % ", ".join([str(x) for x in self.annees_scolaires()]) + ) + if len(self.annees_scolaires()) > 1: + H.append( + ' (attention, plusieurs années !)' + ) + H.append("

    ") + if self["sem_id"]: + H.append( + "

    Période: %(sem_id)s (1: septembre, 2: janvier)

    " % self + ) + H.append( + "

    Etapes: %s

    " + % sco_formsemestre.etapes_apo_str(self.list_etapes()) + ) + H.append("""

    Semestres de l'ensemble:

      """) + + for sem in self.sems: + H.append( + '
    • %(titre_num)s %(mois_debut)s - %(mois_fin)s' + % sem + ) + H.append( + ' (retirer)' + % (self["semset_id"], sem["formsemestre_id"]) + ) + H.append( + "
      Etapes: %(etapes_apo_str)s, %(nbinscrits)s inscrits" % sem + ) + H.append("
      Elément Apogée année: ") + if sem["elt_annee_apo"]: + H.append("%(elt_annee_apo)s" % sem) + else: + H.append('manquant') + + H.append("
      Elément Apogée semestre: ") + if sem["elt_sem_apo"]: + H.append("%(elt_sem_apo)s" % sem) + else: + H.append('manquant') + + H.append("
      vérifier les semestres antécédents !") + H.append("
    • ") + + return "\n".join(H) + + def html_form_sems(self): + """HTML form to manage sems""" + H = [] + possible_sems = self.list_possible_sems() + if possible_sems: + menu_sem = """""" + H.append( + '
      Ajouter un semestre:' + ) + H.append(menu_sem) + H.append( + '' % self.semset_id + ) + H.append('') + H.append("
      ") + else: + H.append("pas de semestres à ajouter") + return "\n".join(H) + + def html_diagnostic(self): + """Affichage de la partie Effectifs et Liste des étudiants + (actif seulement si un portail est configuré) + """ + if sco_portal_apogee.has_portal(): + return self.bilan.html_diagnostic() + else: + return "" + + +def get_semsets_list(): + """Liste de tous les semsets + Trié par date_debut, le plus récent d'abord + """ + cnx = ndb.GetDBConnexion() + L = [] + for s in semset_list(cnx): + L.append(SemSet(semset_id=s["semset_id"])) + L.sort(key=lambda s: s["date_debut"], reverse=True) + return L + + +def do_semset_create(title="", annee_scolaire=None, sem_id=None): + """Create new setset""" + log( + "do_semset_create(title=%s, annee_scolaire=%s, sem_id=%s)" + % (title, annee_scolaire, sem_id) + ) + SemSet(title=title, annee_scolaire=annee_scolaire, sem_id=sem_id) + return flask.redirect("semset_page") + + +def do_semset_delete(semset_id, dialog_confirmed=False): + """Delete a semset""" + if not semset_id: + raise ScoValueError("empty semset_id") + s = SemSet(semset_id=semset_id) + if not dialog_confirmed: + return scu.confirm_dialog( + "

      Suppression de l'ensemble %(title)s ?

      " % s, + dest_url="", + parameters={"semset_id": semset_id}, + cancel_url="semset_page", + ) + s.delete() + return flask.redirect("semset_page") + + +def edit_semset_set_title(id=None, value=None): + """Change title of semset""" + title = value.strip() + if not id: + raise ScoValueError("empty semset_id") + SemSet(semset_id=id) + cnx = ndb.GetDBConnexion() + semset_edit(cnx, {"semset_id": id, "title": title}) + return title + + +def do_semset_add_sem(semset_id, formsemestre_id): + """Add a sem to a semset""" + if not semset_id: + raise ScoValueError("empty semset_id") + if formsemestre_id == "": + raise ScoValueError("pas de semestre choisi !") + s = SemSet(semset_id=semset_id) + # check for valid formsemestre_id + _ = sco_formsemestre.get_formsemestre(formsemestre_id) # raise exc + + s.add(formsemestre_id) + + return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id) + + +def do_semset_remove_sem(semset_id, formsemestre_id): + """Add a sem to a semset""" + if not semset_id: + raise ScoValueError("empty semset_id") + s = SemSet(semset_id) + + s.remove(formsemestre_id) + + return flask.redirect("apo_semset_maq_status?semset_id=%s" % semset_id) + + +# ---------------------------------------- + + +def semset_page(format="html"): + """Page avec liste semsets: + Table avec : date_debut date_fin titre liste des semestres + """ + semsets = get_semsets_list() + for s in semsets: + s["suppress"] = scu.icontag( + "delete_small_img", border="0", alt="supprimer", title="Supprimer" + ) + s["_suppress_target"] = "do_semset_delete?semset_id=%s" % (s["semset_id"]) + s["export_link"] = "Export Apogée" + s["_export_link_target"] = "apo_semset_maq_status?semset_id=%s" % s.semset_id + s["_export_link_link_class"] = "stdlink" + # Le lien associé au nom de semestre redirigeait vers le semset + # (remplacé par n liens vers chacun des semestres) + # s['_semtitles_str_target'] = s['_export_link_target'] + # Experimental: + s[ + "_title_td_attrs" + ] = 'class="inplace_edit" data-url="edit_semset_set_title" id="%s"' % ( + s["semset_id"] + ) + + tab = GenTable( + rows=semsets, + titles={ + "annee_scolaire": "Année scolaire", + "sem_id": "P", + "date_debut": "Début", + "date_fin": "Fin", + "title": "Titre", + "export_link": "", + "semtitles_str": "semestres", + }, + columns_ids=[ + "suppress", + "annee_scolaire", + "sem_id", + "date_debut", + "date_fin", + "title", + "export_link", + "semtitles_str", + ], + html_sortable=True, + html_class="table_leftalign", + filename="semsets", + preferences=sco_preferences.SemPreferences(), + ) + if format != "html": + return tab.make_page(format=format) + + page_title = "Ensembles de semestres" + H = [ + html_sco_header.sco_header( + page_title=page_title, + init_qtip=True, + javascripts=["libjs/jinplace-1.2.1.min.js"], + ), + """""", + "

      %s

      " % page_title, + ] + H.append(tab.html()) + + annee_courante = int(scu.AnneeScolaire()) + menu_annee = "\n".join( + [ + '' % (i, i) + for i in range(2014, annee_courante + 1) + ] + ) + + H.append( + """ +
      +

      Création nouvel ensemble

      +
      + + + + +
      + """ + ) + + H.append( + """ + + """ + ) + + return "\n".join(H) + html_sco_header.sco_footer() diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index bfcd0f81..a4b99b58 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -463,7 +463,7 @@ def list_synch(sem, anneeapogee=None): "id": "etuds_noninscrits", "title": "Étudiants non inscrits dans ce semestre", "help": """Ces étudiants sont déjà connus par ScoDoc, sont inscrits dans cette étape Apogée mais ne sont pas inscrits à ce semestre ScoDoc. Cochez les étudiants à inscrire.""", - "comment": """ dans ScoDoc et Apogée,
      mais pas inscrits + "comment": """ dans ScoDoc et Apogée,
      mais pas inscrits dans ce semestre""", "title_target": "", "with_checkbox": True, diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index 425b343d..a5239c78 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -1,671 +1,671 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Photos: trombinoscopes -""" - -import io -from zipfile import ZipFile, BadZipfile -from flask.templating import render_template -import reportlab -from reportlab.lib.units import cm, mm -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 import colors -from PIL import Image as PILImage - -import flask -from flask import url_for, g, send_file, request - -from app import log -import app.scodoc.sco_utils as scu -from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_pdf import SU -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_import_etuds -from app.scodoc import sco_etud -from app.scodoc import sco_excel -from app.scodoc import sco_groups_view -from app.scodoc import sco_pdf -from app.scodoc import sco_photos -from app.scodoc import sco_portal_apogee -from app.scodoc import sco_preferences -from app.scodoc import sco_trombino_doc - - -def trombino( - group_ids=(), # liste des groupes à afficher - formsemestre_id=None, # utilisé si pas de groupes selectionné - etat=None, - format="html", - dialog_confirmed=False, -): - """Trombinoscope""" - if not etat: - etat = None # may be passed as '' - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, formsemestre_id=formsemestre_id, etat=etat - ) - - # - if format != "html" and not dialog_confirmed: - ok, dialog = check_local_photos_availability(groups_infos, fmt=format) - if not ok: - return dialog - - if format == "zip": - return _trombino_zip(groups_infos) - elif format == "pdf": - return _trombino_pdf(groups_infos) - elif format == "pdflist": - return _listeappel_photos_pdf(groups_infos) - elif format == "doc": - return sco_trombino_doc.trombino_doc(groups_infos) - else: - raise Exception("invalid format") - - -def _trombino_html_header(): - return html_sco_header.sco_header(javascripts=["js/trombino.js"]) - - -def trombino_html(groups_infos): - "HTML snippet for trombino (with title and menu)" - menu_trombi = [ - { - "title": "Charger des photos...", - "endpoint": "scolar.photos_import_files_form", - "args": {"group_ids": groups_infos.group_ids}, - }, - { - "title": "Obtenir archive Zip des photos", - "endpoint": "scolar.trombino", - "args": {"group_ids": groups_infos.group_ids, "format": "zip"}, - }, - { - "title": "Recopier les photos depuis le portail", - "endpoint": "scolar.trombino_copy_photos", - "args": {"group_ids": groups_infos.group_ids}, - }, - ] - - if groups_infos.members: - if groups_infos.tous_les_etuds_du_sem: - group_txt = "Tous les étudiants" - else: - group_txt = f"Groupe {groups_infos.groups_titles}" - else: - group_txt = "Aucun étudiant inscrit dans ce groupe !" - H = [ - f""" - - """ - ] - if groups_infos.members: - H.append( - "" - ) - H.append("
      {group_txt}" - + htmlutils.make_menu("Gérer les photos", menu_trombi, alone=True) - + "
      ") - H.append("
      ") - i = 0 - for t in groups_infos.members: - H.append( - '' - % t["etudid"] - ) - if sco_photos.etud_photo_is_local(t, size="small"): - foto = sco_photos.etud_photo_html(t, title="") - else: # la photo n'est pas immédiatement dispo - foto = f"""en cours""" - H.append( - '%s' - % ( - url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] - ), - foto, - ) - ) - H.append("") - H.append( - '' - + sco_etud.format_prenom(t["prenom"]) - + '' - + sco_etud.format_nom(t["nom"]) - + (" (dem.)" if t["etat"] == "D" else "") - ) - H.append("") - i += 1 - - H.append("
      ") - H.append( - f"""
      - Version PDF -    - Version doc -
      """ - ) - return "\n".join(H) - - -def check_local_photos_availability(groups_infos, fmt=""): - """Vérifie que toutes les photos (des groupes indiqués) sont copiées - localement dans ScoDoc (seules les photos dont nous disposons localement - peuvent être exportées en pdf ou en zip). - Si toutes ne sont pas dispo, retourne un dialogue d'avertissement - pour l'utilisateur. - """ - nb_missing = 0 - for t in groups_infos.members: - _ = sco_photos.etud_photo_url(t) # -> copy distant files if needed - if not sco_photos.etud_photo_is_local(t): - nb_missing += 1 - if nb_missing > 0: - parameters = {"group_ids": groups_infos.group_ids, "format": fmt} - return ( - False, - scu.confirm_dialog( - 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&" - + groups_infos.groups_query_args, - parameters=parameters, - ), - ) - return True, "" - - -def _trombino_zip(groups_infos): - "Send photos as zip archive" - data = io.BytesIO() - 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) - return send_file( - data, - mimetype="application/zip", - download_name="trombi.zip", - as_attachment=True, - ) - - -# Copy photos from portal to ScoDoc -def trombino_copy_photos(group_ids=[], dialog_confirmed=False): - "Copy photos from portal to ScoDoc (overwriting local copy)" - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - back_url = "groups_view?%s&curtab=tab-photos" % groups_infos.groups_query_args - - portal_url = sco_portal_apogee.get_portal_url() - header = html_sco_header.sco_header(page_title="Chargement des photos") - footer = html_sco_header.sco_footer() - if not portal_url: - return f"""{ header } -

      portail non configuré

      -

      Retour au trombinoscope

      - { footer } - """ - if not dialog_confirmed: - return scu.confirm_dialog( - f"""

      Copier les photos du portail vers ScoDoc ?

      -

      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}, - ) - - msg = [] - nok = 0 - for etud in groups_infos.members: - path, diag = sco_photos.copy_portal_photo_to_fs(etud) - msg.append(diag) - if path: - nok += 1 - - msg.append(f"{nok} photos correctement chargées") - - return f"""{ header } -

      Chargement des photos depuis le portail

      -
      • - { '
      • '.join(msg) } -
      -

      retour au trombinoscope - { footer } - """ - - -def _get_etud_platypus_image(t, image_width=2 * cm): - """Returns a platypus object for the photo of student t""" - try: - path = sco_photos.photo_pathname(t["photo_filename"], size="small") - if not path: - # log('> unknown') - path = sco_photos.UNKNOWN_IMAGE_PATH - im = PILImage.open(path) - w0, h0 = im.size[0], im.size[1] - if w0 > h0: - W = image_width - H = h0 * W / w0 - else: - H = image_width - W = w0 * H / h0 - return reportlab.platypus.Image(path, width=W, height=H) - except: - log( - "*** exception while processing photo of %s (%s) (path=%s)" - % (t["nom"], t["etudid"], path) - ) - raise - - -def _trombino_pdf(groups_infos): - "Send photos as pdf page" - # Generate PDF page - filename = f"trombino_{groups_infos.groups_filename}.pdf" - sem = groups_infos.formsemestre # suppose 1 seul semestre - - PHOTO_WIDTH = 3 * cm - COL_WIDTH = 3.6 * cm - N_PER_ROW = 5 # XXX should be in ScoDoc preferences - - style_sheet = styles.getSampleStyleSheet() - report = io.BytesIO() # in-memory document, no disk file - objects = [ - Paragraph( - SU("Trombinoscope " + sem["titreannee"] + " " + groups_infos.groups_titles), - style_sheet["Heading3"], - ) - ] - L = [] - n = 0 - currow = [] - log(f"_trombino_pdf {len(groups_infos.members)} elements") - for t in groups_infos.members: - img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH) - elem = Table( - [ - [img], - [ - Paragraph( - SU(sco_etud.format_nomprenom(t)), - style_sheet["Normal"], - ) - ], - ], - colWidths=[PHOTO_WIDTH], - ) - currow.append(elem) - if n == (N_PER_ROW - 1): - L.append(currow) - currow = [] - n = (n + 1) % N_PER_ROW - if currow: - currow += [" "] * (N_PER_ROW - len(currow)) - L.append(currow) - if not L: - table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) - else: - table = Table( - L, - colWidths=[COL_WIDTH] * N_PER_ROW, - style=TableStyle( - [ - # ('RIGHTPADDING', (0,0), (-1,-1), -5*mm), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), - ] - ), - ) - objects.append(table) - # Build document - document = BaseDocTemplate(report) - document.addPageTemplates( - sco_pdf.ScoDocPageTemplate( - document, - preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), - ) - ) - document.build(objects) - report.seek(0) - return send_file( - report, - mimetype=scu.PDF_MIMETYPE, - download_name=scu.sanitize_filename(filename), - as_attachment=True, - ) - - -# --------------------- 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 = f"trombino_{groups_infos.groups_filename}.pdf" - sem = groups_infos.formsemestre # suppose 1 seul semestre - - PHOTO_WIDTH = 2 * cm - # COLWIDTH = 3.6 * cm - # ROWS_PER_PAGE = 26 # XXX should be in ScoDoc preferences - - style_sheet = styles.getSampleStyleSheet() - report = io.BytesIO() # in-memory document, no disk file - objects = [ - Paragraph( - SU( - f"""{sem["titreannee"]} {groups_infos.groups_titles} ({len(groups_infos.members)})""" - ), - style_sheet["Heading3"], - ) - ] - L = [] - n = 0 - currow = [] - 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=PHOTO_WIDTH) - txt = Paragraph( - SU(sco_etud.format_nomprenom(t)), - style_sheet["Normal"], - ) - if currow: - currow += [""] - currow += [img, txt, ""] - if i % 2: - L.append(currow) - currow = [] - if currow: - currow += [" "] * 3 - L.append(currow) - if not L: - table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) - else: - table = Table( - L, - colWidths=[2 * cm, 4 * cm, 27 * mm, 5 * mm, 2 * cm, 4 * cm, 27 * mm], - style=TableStyle( - [ - # ('RIGHTPADDING', (0,0), (-1,-1), -5*mm), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("GRID", (0, 0), (2, -1), 0.25, colors.grey), - ("GRID", (4, 0), (-1, -1), 0.25, colors.grey), - ] - ), - ) - objects.append(table) - # Build document - document = BaseDocTemplate(report) - document.addPageTemplates( - sco_pdf.ScoDocPageTemplate( - document, - preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), - ) - ) - document.build(objects) - data = report.getvalue() - - return scu.sendPDFFile(data, filename) - - -# --------------------- Upload des photos de tout un groupe -def photos_generate_excel_sample(group_ids=()): - """Feuille excel pour import fichiers photos""" - fmt = sco_import_etuds.sco_import_format() - data = sco_import_etuds.sco_import_generate_excel_sample( - fmt, - group_ids=group_ids, - only_tables=["identite"], - exclude_cols=[ - "date_naissance", - "lieu_naissance", - "nationalite", - "statut", - "photo_filename", - ], - extra_cols=["fichier_photo"], - ) - return scu.send_file( - data, "ImportPhotos", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE, attached=True - ) - # return sco_excel.send_excel_file(data, "ImportPhotos" + scu.XLSX_SUFFIX) - - -def photos_import_files_form(group_ids=()): - """Formulaire pour importation photos""" - if not group_ids: - raise ScoValueError("paramètre manquant !") - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - back_url = f"groups_view?{groups_infos.groups_query_args}&curtab=tab-photos" - - H = [ - html_sco_header.sco_header(page_title="Import des photos des étudiants"), - f"""

      Téléchargement des photos des étudiants

      -

      Vous pouvez aussi charger les photos individuellement via la fiche - de chaque étudiant (menu "Étudiant" / "Changer la photo"). -

      -

      Cette page permet de charger en une seule fois les photos - de plusieurs étudiants.
      - Il faut d'abord remplir une feuille excel donnant les noms - des fichiers images (une image par étudiant). -

      -

      Ensuite, réunir vos images dans un fichier zip, puis télécharger - simultanément le fichier excel et le fichier zip. -

      -
        -
      1. - Obtenir la feuille excel à remplir -
      2. -
      3. - """, - ] - F = html_sco_header.sco_footer() - vals = scu.get_request_args() - vals["group_ids"] = groups_infos.group_ids - tf = TrivialFormulator( - request.base_url, - vals, - ( - ("xlsfile", {"title": "Fichier Excel:", "input_type": "file", "size": 40}), - ("zipfile", {"title": "Fichier zip:", "input_type": "file", "size": 40}), - ("group_ids", {"input_type": "hidden", "type": "list"}), - ), - ) - - if tf[0] == 0: - return "\n".join(H) + tf[1] + "
      " + F - elif tf[0] == -1: - return flask.redirect(back_url) - else: - - def callback(etud, data, filename): - return sco_photos.store_photo(etud, data, filename) - - ( - ignored_zipfiles, - unmatched_files, - stored_etud_filename, - ) = zip_excel_import_files( - xlsfile=tf[2]["xlsfile"], - zipfile=tf[2]["zipfile"], - callback=callback, - filename_title="fichier_photo", - back_url=back_url, - ) - return render_template( - "scolar/photos_import_files.html", - page_title="Téléchargement des photos des étudiants", - ignored_zipfiles=ignored_zipfiles, - unmatched_files=unmatched_files, - stored_etud_filename=stored_etud_filename, - next_page=url_for( - "scolar.groups_view", - scodoc_dept=g.scodoc_dept, - formsemestre_id=groups_infos.formsemestre_id, - curtab="tab-photos", - ), - ) - - -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 - callback() - est appelée pour chaque fichier trouvé. - Fonction utilisée pour les photos et les fichiers étudiants (archives). - """ - # 1- build mapping etudid -> filename - exceldata = xlsfile.read() - if not exceldata: - raise ScoValueError("Fichier excel vide ou invalide") - _, data = sco_excel.excel_bytes_to_list(exceldata) - if not data: - raise ScoValueError("Fichier excel vide !") - # on doit avoir une colonne etudid et une colonne filename_title ('fichier_photo') - titles = data[0] - try: - etudid_idx = titles.index("etudid") - filename_idx = titles.index(filename_title) - except Exception as exc: - raise ScoValueError( - f"Fichier excel incorrect (il faut une colonne etudid et une colonne {filename_title}) !" - ) from exc - - filename_to_etudid = {} # filename : etudid - for line_num, l in enumerate(data[1:]): - filename = l[filename_idx].strip() - if filename: - 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: - z = ZipFile(zipfile) - except BadZipfile: - raise ScoValueError("Fichier ZIP incorrect !") from BadZipfile - ignored_zipfiles = [] - stored_etud_filename = [] # [ (etud, filename) ] - for name in z.namelist(): - if len(name) > 4 and name[-1] != "/" and "." in 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 = _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_etudid[normname] - except Exception as exc: - raise ScoValueError( - f"ID étudiant invalide: {etudid}", dest_url=back_url - ) from exc - - status, err_msg = callback( - etud, - data, - _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(f"zip: zip name {name} not in excel !") - ignored_zipfiles.append(name) - else: - if name[-1] != "/": - ignored_zipfiles.append(name) - log(f"zip: ignoring {name}") - if filename_to_etudid: - # lignes excel non traitées - unmatched_files = list(filename_to_etudid.keys()) - else: - unmatched_files = [] - return ignored_zipfiles, unmatched_files, stored_etud_filename +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Photos: trombinoscopes +""" + +import io +from zipfile import ZipFile, BadZipfile +from flask.templating import render_template +import reportlab +from reportlab.lib.units import cm, mm +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 import colors +from PIL import Image as PILImage + +import flask +from flask import url_for, g, send_file, request + +from app import log +import app.scodoc.sco_utils as scu +from app.scodoc.TrivialFormulator import TrivialFormulator +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_pdf import SU +from app.scodoc import html_sco_header +from app.scodoc import htmlutils +from app.scodoc import sco_import_etuds +from app.scodoc import sco_etud +from app.scodoc import sco_excel +from app.scodoc import sco_groups_view +from app.scodoc import sco_pdf +from app.scodoc import sco_photos +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_preferences +from app.scodoc import sco_trombino_doc + + +def trombino( + group_ids=(), # liste des groupes à afficher + formsemestre_id=None, # utilisé si pas de groupes selectionné + etat=None, + format="html", + dialog_confirmed=False, +): + """Trombinoscope""" + if not etat: + etat = None # may be passed as '' + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id, etat=etat + ) + + # + if format != "html" and not dialog_confirmed: + ok, dialog = check_local_photos_availability(groups_infos, fmt=format) + if not ok: + return dialog + + if format == "zip": + return _trombino_zip(groups_infos) + elif format == "pdf": + return _trombino_pdf(groups_infos) + elif format == "pdflist": + return _listeappel_photos_pdf(groups_infos) + elif format == "doc": + return sco_trombino_doc.trombino_doc(groups_infos) + else: + raise Exception("invalid format") + + +def _trombino_html_header(): + return html_sco_header.sco_header(javascripts=["js/trombino.js"]) + + +def trombino_html(groups_infos): + "HTML snippet for trombino (with title and menu)" + menu_trombi = [ + { + "title": "Charger des photos...", + "endpoint": "scolar.photos_import_files_form", + "args": {"group_ids": groups_infos.group_ids}, + }, + { + "title": "Obtenir archive Zip des photos", + "endpoint": "scolar.trombino", + "args": {"group_ids": groups_infos.group_ids, "format": "zip"}, + }, + { + "title": "Recopier les photos depuis le portail", + "endpoint": "scolar.trombino_copy_photos", + "args": {"group_ids": groups_infos.group_ids}, + }, + ] + + if groups_infos.members: + if groups_infos.tous_les_etuds_du_sem: + group_txt = "Tous les étudiants" + else: + group_txt = f"Groupe {groups_infos.groups_titles}" + else: + group_txt = "Aucun étudiant inscrit dans ce groupe !" + H = [ + f""" + + """ + ] + if groups_infos.members: + H.append( + "" + ) + H.append("
      {group_txt}" + + htmlutils.make_menu("Gérer les photos", menu_trombi, alone=True) + + "
      ") + H.append("
      ") + i = 0 + for t in groups_infos.members: + H.append( + '' + % t["etudid"] + ) + if sco_photos.etud_photo_is_local(t, size="small"): + foto = sco_photos.etud_photo_html(t, title="") + else: # la photo n'est pas immédiatement dispo + foto = f"""en cours""" + H.append( + '%s' + % ( + url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] + ), + foto, + ) + ) + H.append("") + H.append( + '' + + sco_etud.format_prenom(t["prenom"]) + + '' + + sco_etud.format_nom(t["nom"]) + + (" (dem.)" if t["etat"] == "D" else "") + ) + H.append("") + i += 1 + + H.append("
      ") + H.append( + f"""
      + Version PDF +    + Version doc +
      """ + ) + return "\n".join(H) + + +def check_local_photos_availability(groups_infos, fmt=""): + """Vérifie que toutes les photos (des groupes indiqués) sont copiées + localement dans ScoDoc (seules les photos dont nous disposons localement + peuvent être exportées en pdf ou en zip). + Si toutes ne sont pas dispo, retourne un dialogue d'avertissement + pour l'utilisateur. + """ + nb_missing = 0 + for t in groups_infos.members: + _ = sco_photos.etud_photo_url(t) # -> copy distant files if needed + if not sco_photos.etud_photo_is_local(t): + nb_missing += 1 + if nb_missing > 0: + parameters = {"group_ids": groups_infos.group_ids, "format": fmt} + return ( + False, + scu.confirm_dialog( + 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&" + + groups_infos.groups_query_args, + parameters=parameters, + ), + ) + return True, "" + + +def _trombino_zip(groups_infos): + "Send photos as zip archive" + data = io.BytesIO() + 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) + return send_file( + data, + mimetype="application/zip", + download_name="trombi.zip", + as_attachment=True, + ) + + +# Copy photos from portal to ScoDoc +def trombino_copy_photos(group_ids=[], dialog_confirmed=False): + "Copy photos from portal to ScoDoc (overwriting local copy)" + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + back_url = "groups_view?%s&curtab=tab-photos" % groups_infos.groups_query_args + + portal_url = sco_portal_apogee.get_portal_url() + header = html_sco_header.sco_header(page_title="Chargement des photos") + footer = html_sco_header.sco_footer() + if not portal_url: + return f"""{ header } +

      portail non configuré

      +

      Retour au trombinoscope

      + { footer } + """ + if not dialog_confirmed: + return scu.confirm_dialog( + f"""

      Copier les photos du portail vers ScoDoc ?

      +

      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}, + ) + + msg = [] + nok = 0 + for etud in groups_infos.members: + path, diag = sco_photos.copy_portal_photo_to_fs(etud) + msg.append(diag) + if path: + nok += 1 + + msg.append(f"{nok} photos correctement chargées") + + return f"""{ header } +

      Chargement des photos depuis le portail

      +
      • + { '
      • '.join(msg) } +
      +

      retour au trombinoscope + { footer } + """ + + +def _get_etud_platypus_image(t, image_width=2 * cm): + """Returns a platypus object for the photo of student t""" + try: + path = sco_photos.photo_pathname(t["photo_filename"], size="small") + if not path: + # log('> unknown') + path = sco_photos.UNKNOWN_IMAGE_PATH + im = PILImage.open(path) + w0, h0 = im.size[0], im.size[1] + if w0 > h0: + W = image_width + H = h0 * W / w0 + else: + H = image_width + W = w0 * H / h0 + return reportlab.platypus.Image(path, width=W, height=H) + except: + log( + "*** exception while processing photo of %s (%s) (path=%s)" + % (t["nom"], t["etudid"], path) + ) + raise + + +def _trombino_pdf(groups_infos): + "Send photos as pdf page" + # Generate PDF page + filename = f"trombino_{groups_infos.groups_filename}.pdf" + sem = groups_infos.formsemestre # suppose 1 seul semestre + + PHOTO_WIDTH = 3 * cm + COL_WIDTH = 3.6 * cm + N_PER_ROW = 5 # XXX should be in ScoDoc preferences + + style_sheet = styles.getSampleStyleSheet() + report = io.BytesIO() # in-memory document, no disk file + objects = [ + Paragraph( + SU("Trombinoscope " + sem["titreannee"] + " " + groups_infos.groups_titles), + style_sheet["Heading3"], + ) + ] + L = [] + n = 0 + currow = [] + log(f"_trombino_pdf {len(groups_infos.members)} elements") + for t in groups_infos.members: + img = _get_etud_platypus_image(t, image_width=PHOTO_WIDTH) + elem = Table( + [ + [img], + [ + Paragraph( + SU(sco_etud.format_nomprenom(t)), + style_sheet["Normal"], + ) + ], + ], + colWidths=[PHOTO_WIDTH], + ) + currow.append(elem) + if n == (N_PER_ROW - 1): + L.append(currow) + currow = [] + n = (n + 1) % N_PER_ROW + if currow: + currow += [" "] * (N_PER_ROW - len(currow)) + L.append(currow) + if not L: + table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) + else: + table = Table( + L, + colWidths=[COL_WIDTH] * N_PER_ROW, + style=TableStyle( + [ + # ('RIGHTPADDING', (0,0), (-1,-1), -5*mm), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 0.25, colors.grey), + ] + ), + ) + objects.append(table) + # Build document + document = BaseDocTemplate(report) + document.addPageTemplates( + sco_pdf.ScoDocPageTemplate( + document, + preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), + ) + ) + document.build(objects) + report.seek(0) + return send_file( + report, + mimetype=scu.PDF_MIMETYPE, + download_name=scu.sanitize_filename(filename), + as_attachment=True, + ) + + +# --------------------- 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 = f"trombino_{groups_infos.groups_filename}.pdf" + sem = groups_infos.formsemestre # suppose 1 seul semestre + + PHOTO_WIDTH = 2 * cm + # COLWIDTH = 3.6 * cm + # ROWS_PER_PAGE = 26 # XXX should be in ScoDoc preferences + + style_sheet = styles.getSampleStyleSheet() + report = io.BytesIO() # in-memory document, no disk file + objects = [ + Paragraph( + SU( + f"""{sem["titreannee"]} {groups_infos.groups_titles} ({len(groups_infos.members)})""" + ), + style_sheet["Heading3"], + ) + ] + L = [] + n = 0 + currow = [] + 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=PHOTO_WIDTH) + txt = Paragraph( + SU(sco_etud.format_nomprenom(t)), + style_sheet["Normal"], + ) + if currow: + currow += [""] + currow += [img, txt, ""] + if i % 2: + L.append(currow) + currow = [] + if currow: + currow += [" "] * 3 + L.append(currow) + if not L: + table = Paragraph(SU("Aucune photo à exporter !"), style_sheet["Normal"]) + else: + table = Table( + L, + colWidths=[2 * cm, 4 * cm, 27 * mm, 5 * mm, 2 * cm, 4 * cm, 27 * mm], + style=TableStyle( + [ + # ('RIGHTPADDING', (0,0), (-1,-1), -5*mm), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (2, -1), 0.25, colors.grey), + ("GRID", (4, 0), (-1, -1), 0.25, colors.grey), + ] + ), + ) + objects.append(table) + # Build document + document = BaseDocTemplate(report) + document.addPageTemplates( + sco_pdf.ScoDocPageTemplate( + document, + preferences=sco_preferences.SemPreferences(sem["formsemestre_id"]), + ) + ) + document.build(objects) + data = report.getvalue() + + return scu.sendPDFFile(data, filename) + + +# --------------------- Upload des photos de tout un groupe +def photos_generate_excel_sample(group_ids=()): + """Feuille excel pour import fichiers photos""" + fmt = sco_import_etuds.sco_import_format() + data = sco_import_etuds.sco_import_generate_excel_sample( + fmt, + group_ids=group_ids, + only_tables=["identite"], + exclude_cols=[ + "date_naissance", + "lieu_naissance", + "nationalite", + "statut", + "photo_filename", + ], + extra_cols=["fichier_photo"], + ) + return scu.send_file( + data, "ImportPhotos", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE, attached=True + ) + # return sco_excel.send_excel_file(data, "ImportPhotos" + scu.XLSX_SUFFIX) + + +def photos_import_files_form(group_ids=()): + """Formulaire pour importation photos""" + if not group_ids: + raise ScoValueError("paramètre manquant !") + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + back_url = f"groups_view?{groups_infos.groups_query_args}&curtab=tab-photos" + + H = [ + html_sco_header.sco_header(page_title="Import des photos des étudiants"), + f"""

      Téléchargement des photos des étudiants

      +

      Vous pouvez aussi charger les photos individuellement via la fiche + de chaque étudiant (menu "Étudiant" / "Changer la photo"). +

      +

      Cette page permet de charger en une seule fois les photos + de plusieurs étudiants.
      + Il faut d'abord remplir une feuille excel donnant les noms + des fichiers images (une image par étudiant). +

      +

      Ensuite, réunir vos images dans un fichier zip, puis télécharger + simultanément le fichier excel et le fichier zip. +

      +
        +
      1. + Obtenir la feuille excel à remplir +
      2. +
      3. + """, + ] + F = html_sco_header.sco_footer() + vals = scu.get_request_args() + vals["group_ids"] = groups_infos.group_ids + tf = TrivialFormulator( + request.base_url, + vals, + ( + ("xlsfile", {"title": "Fichier Excel:", "input_type": "file", "size": 40}), + ("zipfile", {"title": "Fichier zip:", "input_type": "file", "size": 40}), + ("group_ids", {"input_type": "hidden", "type": "list"}), + ), + ) + + if tf[0] == 0: + return "\n".join(H) + tf[1] + "
      " + F + elif tf[0] == -1: + return flask.redirect(back_url) + else: + + def callback(etud, data, filename): + return sco_photos.store_photo(etud, data, filename) + + ( + ignored_zipfiles, + unmatched_files, + stored_etud_filename, + ) = zip_excel_import_files( + xlsfile=tf[2]["xlsfile"], + zipfile=tf[2]["zipfile"], + callback=callback, + filename_title="fichier_photo", + back_url=back_url, + ) + return render_template( + "scolar/photos_import_files.html", + page_title="Téléchargement des photos des étudiants", + ignored_zipfiles=ignored_zipfiles, + unmatched_files=unmatched_files, + stored_etud_filename=stored_etud_filename, + next_page=url_for( + "scolar.groups_view", + scodoc_dept=g.scodoc_dept, + formsemestre_id=groups_infos.formsemestre_id, + curtab="tab-photos", + ), + ) + + +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 + callback() + est appelée pour chaque fichier trouvé. + Fonction utilisée pour les photos et les fichiers étudiants (archives). + """ + # 1- build mapping etudid -> filename + exceldata = xlsfile.read() + if not exceldata: + raise ScoValueError("Fichier excel vide ou invalide") + _, data = sco_excel.excel_bytes_to_list(exceldata) + if not data: + raise ScoValueError("Fichier excel vide !") + # on doit avoir une colonne etudid et une colonne filename_title ('fichier_photo') + titles = data[0] + try: + etudid_idx = titles.index("etudid") + filename_idx = titles.index(filename_title) + except Exception as exc: + raise ScoValueError( + f"Fichier excel incorrect (il faut une colonne etudid et une colonne {filename_title}) !" + ) from exc + + filename_to_etudid = {} # filename : etudid + for line_num, l in enumerate(data[1:]): + filename = l[filename_idx].strip() + if filename: + 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: + z = ZipFile(zipfile) + except BadZipfile: + raise ScoValueError("Fichier ZIP incorrect !") from BadZipfile + ignored_zipfiles = [] + stored_etud_filename = [] # [ (etud, filename) ] + for name in z.namelist(): + if len(name) > 4 and name[-1] != "/" and "." in 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 = _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_etudid[normname] + except Exception as exc: + raise ScoValueError( + f"ID étudiant invalide: {etudid}", dest_url=back_url + ) from exc + + status, err_msg = callback( + etud, + data, + _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(f"zip: zip name {name} not in excel !") + ignored_zipfiles.append(name) + else: + if name[-1] != "/": + ignored_zipfiles.append(name) + log(f"zip: ignoring {name}") + if filename_to_etudid: + # lignes excel non traitées + unmatched_files = list(filename_to_etudid.keys()) + else: + unmatched_files = [] + return ignored_zipfiles, unmatched_files, stored_etud_filename diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py index d35646a1..ca1bdba7 100644 --- a/app/scodoc/sco_ue_external.py +++ b/app/scodoc/sco_ue_external.py @@ -1,384 +1,384 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Fonction de gestion des UE "externes" (effectuees dans un cursus exterieur) - -On rapatrie (saisie) les notes (et crédits ECTS). - -Cas d'usage: les étudiants d'une formation gérée par ScoDoc peuvent -suivre un certain nombre d'UE à l'extérieur. L'établissement a reconnu -au préalable une forme d'équivalence entre ces UE et celles du -programme. Les UE effectuées à l'extérieur sont par nature variable -d'un étudiant à l'autre et d'une année à l'autre, et ne peuvent pas -être introduites dans le programme pédagogique ScoDoc sans alourdir -considérablement les opérations (saisie, affichage du programme, -gestion des inscriptions). -En outre, un suivi détaillé de ces UE n'est pas nécessaire: il suffit -de pouvoir y associer une note et une quantité de crédits ECTS. - -Solution proposée (nov 2014): - - un nouveau type d'UE qui - - - s'affichera à part dans le programme pédagogique - et les bulletins - - pas présentées lors de la mise en place de semestres - - affichage sur bulletin des étudiants qui y sont inscrit - - création en même temps que la saisie de la note - (chaine creation: UE/matière/module, inscription étudiant, entrée valeur note) - avec auto-suggestion du nom pour limiter la création de doublons - - seront aussi présentées (à part) sur la page "Voir les inscriptions aux modules" - -""" -import flask -from flask import request -from flask_login import current_user -from app.models.formsemestre import FormSemestre - -import app.scodoc.notesdb as ndb -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc import html_sco_header -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_edit_matiere -from app.scodoc import sco_edit_module -from app.scodoc import sco_edit_ue -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_formations -from app.scodoc import sco_formsemestre -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_saisie_notes -from app.scodoc import sco_etud -from app.scodoc.sco_exceptions import AccessDenied, ScoValueError -from app.scodoc.sco_permissions import Permission -from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message - - -def external_ue_create( - formsemestre_id, - titre="", - acronyme="", - ue_type=sco_codes_parcours.UE_STANDARD, - ects=0.0, -): - """Crée UE/matiere/module/evaluation puis saisie les notes""" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - log(f"creating external UE in {formsemestre}: {acronyme}") - - # Contrôle d'accès: - if not current_user.has_permission(Permission.ScoImplement): - if (not formsemestre.resp_can_edit) or ( - current_user.id not in [u.id for u in formsemestre.responsables] - ): - raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") - # - formation_id = formsemestre.formation.id - - numero = sco_edit_ue.next_ue_numero( - formation_id, semestre_id=formsemestre.semestre_id - ) - ue_id = sco_edit_ue.do_ue_create( - { - "formation_id": formation_id, - "semestre_idx": formsemestre.semestre_id, - "titre": titre, - "acronyme": acronyme, - "numero": numero, - "type": ue_type, - "ects": ects, - "is_external": True, - }, - ) - - matiere_id = sco_edit_matiere.do_matiere_create( - {"ue_id": ue_id, "titre": titre or acronyme, "numero": 1} - ) - - module_id = sco_edit_module.do_module_create( - { - "titre": "UE extérieure", - "code": acronyme, - "coefficient": ects, # tous le coef. module est egal à la quantite d'ECTS - "ue_id": ue_id, - "matiere_id": matiere_id, - "formation_id": formation_id, - "semestre_id": formsemestre.semestre_id, - "module_type": scu.ModuleType.STANDARD, - }, - ) - - moduleimpl_id = sco_moduleimpl.do_moduleimpl_create( - { - "module_id": module_id, - "formsemestre_id": formsemestre_id, - # affecte le 1er responsable du semestre comme resp. du module - "responsable_id": formsemestre.responsables[0].id - if len(formsemestre.responsables) - else None, - }, - ) - - return moduleimpl_id - - -def external_ue_inscrit_et_note( - moduleimpl_id: int, formsemestre_id: int, notes_etuds: dict -): - """Inscrit les étudiants au moduleimpl, crée au besoin une évaluation - et enregistre les notes. - """ - log( - f"external_ue_inscrit_et_note(moduleimpl_id={moduleimpl_id}, notes_etuds={notes_etuds})" - ) - # Inscription des étudiants - sco_moduleimpl.do_moduleimpl_inscrit_etuds( - moduleimpl_id, - formsemestre_id, - list(notes_etuds.keys()), - ) - - # Création d'une évaluation si il n'y en a pas déjà: - mod_evals = sco_evaluation_db.do_evaluation_list( - args={"moduleimpl_id": moduleimpl_id} - ) - if len(mod_evals): - # met la note dans le première évaluation existante: - evaluation_id = mod_evals[0]["evaluation_id"] - else: - # crée une évaluation: - evaluation_id = sco_evaluation_db.do_evaluation_create( - moduleimpl_id=moduleimpl_id, - note_max=20.0, - coefficient=1.0, - publish_incomplete=True, - evaluation_type=scu.EVALUATION_NORMALE, - visibulletin=False, - description="note externe", - ) - # Saisie des notes - _, _, _ = sco_saisie_notes.notes_add( - current_user, - evaluation_id, - list(notes_etuds.items()), - do_it=True, - ) - - -def get_existing_external_ue(formation_id: int) -> list[dict]: - "Liste de toutes les UE externes définies dans cette formation" - return sco_edit_ue.ue_list(args={"formation_id": formation_id, "is_external": True}) - - -def get_external_moduleimpl_id(formsemestre_id: int, ue_id: int) -> int: - "moduleimpl correspondant à l'UE externe indiquée de ce formsemestre" - r = ndb.SimpleDictFetch( - """ - SELECT mi.id AS moduleimpl_id FROM notes_moduleimpl mi, notes_modules mo - WHERE mi.formsemestre_id = %(formsemestre_id)s - AND mi.module_id = mo.id - AND mo.ue_id = %(ue_id)s - """, - {"ue_id": ue_id, "formsemestre_id": formsemestre_id}, - ) - if r: - return r[0]["moduleimpl_id"] - else: - raise ScoValueError( - f"""Aucun module externe ne correspond - (formsemestre_id={formsemestre_id}, ue_id={ue_id})""" - ) - - -# Web function -def external_ue_create_form(formsemestre_id: int, etudid: int): - """Formulaire création UE externe + inscription étudiant et saisie note - - Demande UE: peut-être existante (liste les UE externes de cette formation), - ou sinon spécifier titre, acronyme, type, ECTS - - Demande note à enregistrer. - - Note: pour l'édition éventuelle de ces informations, on utilisera les - fonctions standards sur les UE/modules/notes - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # Contrôle d'accès: - if not current_user.has_permission(Permission.ScoImplement): - if not sem["resp_can_edit"] or (current_user.id not in sem["responsables"]): - raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") - - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - formation_id = sem["formation_id"] - existing_external_ue = get_existing_external_ue(formation_id) - - H = [ - html_sco_header.html_sem_header( - "Ajout d'une UE externe pour %(nomprenom)s" % etud, - javascripts=["js/sco_ue_external.js"], - ), - """

      Cette page permet d'indiquer que l'étudiant a suivi une UE - dans un autre établissement et qu'elle doit être intégrée dans le semestre courant.
      - La note (/20) obtenue par l'étudiant doit toujours être spécifiée.
      - On peut choisir une UE externe existante (dans le menu), ou bien en créer une, qui sera - alors ajoutée à la formation. -

      - """, - ] - html_footer = html_sco_header.sco_footer() - Fo = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] - parcours = sco_codes_parcours.get_parcours_from_code(Fo["type_parcours"]) - ue_types = [ - typ for typ in parcours.ALLOWED_UE_TYPES if typ != sco_codes_parcours.UE_SPORT - ] - ue_types.sort() - ue_types_names = [sco_codes_parcours.UE_TYPE_NAME[k] for k in ue_types] - ue_types = [str(x) for x in ue_types] - - if existing_external_ue: - default_label = "Nouvelle UE" - else: - default_label = "Aucune UE externe existante" - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("formsemestre_id", {"input_type": "hidden"}), - ("etudid", {"input_type": "hidden"}), - ( - "existing_ue", - { - "input_type": "menu", - "title": "UE externe existante:", - "allowed_values": [""] - + [str(ue["ue_id"]) for ue in existing_external_ue], - "labels": [default_label] - + [ - "%s (%s)" % (ue["titre"], ue["acronyme"]) - for ue in existing_external_ue - ], - "attributes": ['onchange="update_external_ue_form();"'], - "explanation": "inscrire cet étudiant dans cette UE", - }, - ), - ( - "sep", - { - "input_type": "separator", - "title": "Ou bien déclarer une nouvelle UE externe:", - "dom_id": "tf_extue_decl", - }, - ), - # champs a desactiver si une UE existante est choisie - ( - "titre", - {"size": 30, "explanation": "nom de l'UE", "dom_id": "tf_extue_titre"}, - ), - ( - "acronyme", - { - "size": 8, - "explanation": "abbréviation", - "allow_null": True, # attention: verifier - "dom_id": "tf_extue_acronyme", - }, - ), - ( - "type", - { - "explanation": "type d'UE", - "input_type": "menu", - "allowed_values": ue_types, - "labels": ue_types_names, - "dom_id": "tf_extue_type", - }, - ), - ( - "ects", - { - "size": 4, - "type": "float", - "min_value": 0, - "max_value": 1000, - "title": "ECTS", - "explanation": "nombre de crédits ECTS", - "dom_id": "tf_extue_ects", - }, - ), - # - ( - "note", - {"size": 4, "explanation": "note sur 20", "dom_id": "tf_extue_note"}, - ), - ), - submitlabel="Enregistrer", - cancelbutton="Annuler", - ) - - bull_url = "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" % ( - formsemestre_id, - etudid, - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + html_footer - elif tf[0] == -1: - return flask.redirect(bull_url) - else: - note = tf[2]["note"].strip().upper() - note_value, invalid = sco_saisie_notes.convert_note_from_string(note, 20.0) - if invalid: - return ( - "\n".join(H) - + "\n" - + tf_error_message("valeur note invalide") - + tf[1] - + html_footer - ) - if tf[2]["existing_ue"]: - ue_id = int(tf[2]["existing_ue"]) - moduleimpl_id = get_external_moduleimpl_id(formsemestre_id, ue_id) - else: - acronyme = tf[2]["acronyme"].strip() - if not acronyme: - return ( - "\n".join(H) - + "\n" - + tf_error_message("spécifier acronyme d'UE") - + tf[1] - + html_footer - ) - moduleimpl_id = external_ue_create( - formsemestre_id, - titre=tf[2]["titre"], - acronyme=acronyme, - ue_type=tf[2]["type"], # type de l'UE - ects=tf[2]["ects"], - ) - - external_ue_inscrit_et_note( - moduleimpl_id, - formsemestre_id, - {etudid: note_value}, - ) - return flask.redirect(bull_url + "&head_message=Ajout%20effectué") +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Fonction de gestion des UE "externes" (effectuees dans un cursus exterieur) + +On rapatrie (saisie) les notes (et crédits ECTS). + +Cas d'usage: les étudiants d'une formation gérée par ScoDoc peuvent +suivre un certain nombre d'UE à l'extérieur. L'établissement a reconnu +au préalable une forme d'équivalence entre ces UE et celles du +programme. Les UE effectuées à l'extérieur sont par nature variable +d'un étudiant à l'autre et d'une année à l'autre, et ne peuvent pas +être introduites dans le programme pédagogique ScoDoc sans alourdir +considérablement les opérations (saisie, affichage du programme, +gestion des inscriptions). +En outre, un suivi détaillé de ces UE n'est pas nécessaire: il suffit +de pouvoir y associer une note et une quantité de crédits ECTS. + +Solution proposée (nov 2014): + - un nouveau type d'UE qui + + - s'affichera à part dans le programme pédagogique + et les bulletins + - pas présentées lors de la mise en place de semestres + - affichage sur bulletin des étudiants qui y sont inscrit + - création en même temps que la saisie de la note + (chaine creation: UE/matière/module, inscription étudiant, entrée valeur note) + avec auto-suggestion du nom pour limiter la création de doublons + - seront aussi présentées (à part) sur la page "Voir les inscriptions aux modules" + +""" +import flask +from flask import request +from flask_login import current_user +from app.models.formsemestre import FormSemestre + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc import html_sco_header +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_edit_matiere +from app.scodoc import sco_edit_module +from app.scodoc import sco_edit_ue +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_saisie_notes +from app.scodoc import sco_etud +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +from app.scodoc.sco_permissions import Permission +from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message + + +def external_ue_create( + formsemestre_id, + titre="", + acronyme="", + ue_type=sco_codes_parcours.UE_STANDARD, + ects=0.0, +): + """Crée UE/matiere/module/evaluation puis saisie les notes""" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + log(f"creating external UE in {formsemestre}: {acronyme}") + + # Contrôle d'accès: + if not current_user.has_permission(Permission.ScoImplement): + if (not formsemestre.resp_can_edit) or ( + current_user.id not in [u.id for u in formsemestre.responsables] + ): + raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") + # + formation_id = formsemestre.formation.id + + numero = sco_edit_ue.next_ue_numero( + formation_id, semestre_id=formsemestre.semestre_id + ) + ue_id = sco_edit_ue.do_ue_create( + { + "formation_id": formation_id, + "semestre_idx": formsemestre.semestre_id, + "titre": titre, + "acronyme": acronyme, + "numero": numero, + "type": ue_type, + "ects": ects, + "is_external": True, + }, + ) + + matiere_id = sco_edit_matiere.do_matiere_create( + {"ue_id": ue_id, "titre": titre or acronyme, "numero": 1} + ) + + module_id = sco_edit_module.do_module_create( + { + "titre": "UE extérieure", + "code": acronyme, + "coefficient": ects, # tous le coef. module est egal à la quantite d'ECTS + "ue_id": ue_id, + "matiere_id": matiere_id, + "formation_id": formation_id, + "semestre_id": formsemestre.semestre_id, + "module_type": scu.ModuleType.STANDARD, + }, + ) + + moduleimpl_id = sco_moduleimpl.do_moduleimpl_create( + { + "module_id": module_id, + "formsemestre_id": formsemestre_id, + # affecte le 1er responsable du semestre comme resp. du module + "responsable_id": formsemestre.responsables[0].id + if len(formsemestre.responsables) + else None, + }, + ) + + return moduleimpl_id + + +def external_ue_inscrit_et_note( + moduleimpl_id: int, formsemestre_id: int, notes_etuds: dict +): + """Inscrit les étudiants au moduleimpl, crée au besoin une évaluation + et enregistre les notes. + """ + log( + f"external_ue_inscrit_et_note(moduleimpl_id={moduleimpl_id}, notes_etuds={notes_etuds})" + ) + # Inscription des étudiants + sco_moduleimpl.do_moduleimpl_inscrit_etuds( + moduleimpl_id, + formsemestre_id, + list(notes_etuds.keys()), + ) + + # Création d'une évaluation si il n'y en a pas déjà: + mod_evals = sco_evaluation_db.do_evaluation_list( + args={"moduleimpl_id": moduleimpl_id} + ) + if len(mod_evals): + # met la note dans le première évaluation existante: + evaluation_id = mod_evals[0]["evaluation_id"] + else: + # crée une évaluation: + evaluation_id = sco_evaluation_db.do_evaluation_create( + moduleimpl_id=moduleimpl_id, + note_max=20.0, + coefficient=1.0, + publish_incomplete=True, + evaluation_type=scu.EVALUATION_NORMALE, + visibulletin=False, + description="note externe", + ) + # Saisie des notes + _, _, _ = sco_saisie_notes.notes_add( + current_user, + evaluation_id, + list(notes_etuds.items()), + do_it=True, + ) + + +def get_existing_external_ue(formation_id: int) -> list[dict]: + "Liste de toutes les UE externes définies dans cette formation" + return sco_edit_ue.ue_list(args={"formation_id": formation_id, "is_external": True}) + + +def get_external_moduleimpl_id(formsemestre_id: int, ue_id: int) -> int: + "moduleimpl correspondant à l'UE externe indiquée de ce formsemestre" + r = ndb.SimpleDictFetch( + """ + SELECT mi.id AS moduleimpl_id FROM notes_moduleimpl mi, notes_modules mo + WHERE mi.formsemestre_id = %(formsemestre_id)s + AND mi.module_id = mo.id + AND mo.ue_id = %(ue_id)s + """, + {"ue_id": ue_id, "formsemestre_id": formsemestre_id}, + ) + if r: + return r[0]["moduleimpl_id"] + else: + raise ScoValueError( + f"""Aucun module externe ne correspond + (formsemestre_id={formsemestre_id}, ue_id={ue_id})""" + ) + + +# Web function +def external_ue_create_form(formsemestre_id: int, etudid: int): + """Formulaire création UE externe + inscription étudiant et saisie note + - Demande UE: peut-être existante (liste les UE externes de cette formation), + ou sinon spécifier titre, acronyme, type, ECTS + - Demande note à enregistrer. + + Note: pour l'édition éventuelle de ces informations, on utilisera les + fonctions standards sur les UE/modules/notes + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # Contrôle d'accès: + if not current_user.has_permission(Permission.ScoImplement): + if not sem["resp_can_edit"] or (current_user.id not in sem["responsables"]): + raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") + + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + formation_id = sem["formation_id"] + existing_external_ue = get_existing_external_ue(formation_id) + + H = [ + html_sco_header.html_sem_header( + "Ajout d'une UE externe pour %(nomprenom)s" % etud, + javascripts=["js/sco_ue_external.js"], + ), + """

      Cette page permet d'indiquer que l'étudiant a suivi une UE + dans un autre établissement et qu'elle doit être intégrée dans le semestre courant.
      + La note (/20) obtenue par l'étudiant doit toujours être spécifiée.
      + On peut choisir une UE externe existante (dans le menu), ou bien en créer une, qui sera + alors ajoutée à la formation. +

      + """, + ] + html_footer = html_sco_header.sco_footer() + Fo = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] + parcours = sco_codes_parcours.get_parcours_from_code(Fo["type_parcours"]) + ue_types = [ + typ for typ in parcours.ALLOWED_UE_TYPES if typ != sco_codes_parcours.UE_SPORT + ] + ue_types.sort() + ue_types_names = [sco_codes_parcours.UE_TYPE_NAME[k] for k in ue_types] + ue_types = [str(x) for x in ue_types] + + if existing_external_ue: + default_label = "Nouvelle UE" + else: + default_label = "Aucune UE externe existante" + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("formsemestre_id", {"input_type": "hidden"}), + ("etudid", {"input_type": "hidden"}), + ( + "existing_ue", + { + "input_type": "menu", + "title": "UE externe existante:", + "allowed_values": [""] + + [str(ue["ue_id"]) for ue in existing_external_ue], + "labels": [default_label] + + [ + "%s (%s)" % (ue["titre"], ue["acronyme"]) + for ue in existing_external_ue + ], + "attributes": ['onchange="update_external_ue_form();"'], + "explanation": "inscrire cet étudiant dans cette UE", + }, + ), + ( + "sep", + { + "input_type": "separator", + "title": "Ou bien déclarer une nouvelle UE externe:", + "dom_id": "tf_extue_decl", + }, + ), + # champs a desactiver si une UE existante est choisie + ( + "titre", + {"size": 30, "explanation": "nom de l'UE", "dom_id": "tf_extue_titre"}, + ), + ( + "acronyme", + { + "size": 8, + "explanation": "abbréviation", + "allow_null": True, # attention: verifier + "dom_id": "tf_extue_acronyme", + }, + ), + ( + "type", + { + "explanation": "type d'UE", + "input_type": "menu", + "allowed_values": ue_types, + "labels": ue_types_names, + "dom_id": "tf_extue_type", + }, + ), + ( + "ects", + { + "size": 4, + "type": "float", + "min_value": 0, + "max_value": 1000, + "title": "ECTS", + "explanation": "nombre de crédits ECTS", + "dom_id": "tf_extue_ects", + }, + ), + # + ( + "note", + {"size": 4, "explanation": "note sur 20", "dom_id": "tf_extue_note"}, + ), + ), + submitlabel="Enregistrer", + cancelbutton="Annuler", + ) + + bull_url = "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" % ( + formsemestre_id, + etudid, + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + html_footer + elif tf[0] == -1: + return flask.redirect(bull_url) + else: + note = tf[2]["note"].strip().upper() + note_value, invalid = sco_saisie_notes.convert_note_from_string(note, 20.0) + if invalid: + return ( + "\n".join(H) + + "\n" + + tf_error_message("valeur note invalide") + + tf[1] + + html_footer + ) + if tf[2]["existing_ue"]: + ue_id = int(tf[2]["existing_ue"]) + moduleimpl_id = get_external_moduleimpl_id(formsemestre_id, ue_id) + else: + acronyme = tf[2]["acronyme"].strip() + if not acronyme: + return ( + "\n".join(H) + + "\n" + + tf_error_message("spécifier acronyme d'UE") + + tf[1] + + html_footer + ) + moduleimpl_id = external_ue_create( + formsemestre_id, + titre=tf[2]["titre"], + acronyme=acronyme, + ue_type=tf[2]["type"], # type de l'UE + ects=tf[2]["ects"], + ) + + external_ue_inscrit_et_note( + moduleimpl_id, + formsemestre_id, + {etudid: note_value}, + ) + return flask.redirect(bull_url + "&head_message=Ajout%20effectué") diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py index e52cd71a..a0f08c15 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -1,375 +1,375 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -"""Fonctions sur les utilisateurs -""" - -# Anciennement ZScoUsers.py, fonctions de gestion des données réécrite avec flask/SQLAlchemy -import re - -from flask import url_for, g, request -from flask.templating import render_template -from flask_login import current_user - - -from app import db, Departement - -from app.auth.models import Permission -from app.auth.models import User - -from app.scodoc import html_sco_header -from app.scodoc import sco_etud -from app.scodoc import sco_excel -from app.scodoc import sco_preferences -from app.scodoc.gen_tables import GenTable -from app import log, cache -from app.scodoc.scolog import logdb -import app.scodoc.sco_utils as scu - -from app.scodoc.sco_exceptions import ( - AccessDenied, - ScoValueError, -) - - -# --------------- - -# --------------- - - -def index_html(all_depts=False, with_inactives=False, format="html"): - "gestion utilisateurs..." - all_depts = int(all_depts) - with_inactives = int(with_inactives) - - H = [html_sco_header.html_sem_header("Gestion des utilisateurs")] - - if current_user.has_permission(Permission.ScoUsersAdmin, g.scodoc_dept): - H.append( - '

      Ajouter un utilisateur'.format( - url_for("users.create_user_form", scodoc_dept=g.scodoc_dept) - ) - ) - if current_user.is_administrator(): - H.append( - '   Importer des utilisateurs

      '.format( - url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) - ) - ) - else: - H.append( - "   Pour importer des utilisateurs en masse (via xlsx file) contactez votre administrateur scodoc." - ) - if all_depts: - checked = "checked" - else: - checked = "" - if with_inactives: - olds_checked = "checked" - else: - olds_checked = "" - H.append( - """

      - Tous les départements - Avec anciens utilisateurs -

      """ - % (request.base_url, checked, olds_checked) - ) - - L = list_users( - g.scodoc_dept, - all_depts=all_depts, - with_inactives=with_inactives, - format=format, - with_links=current_user.has_permission(Permission.ScoUsersAdmin, g.scodoc_dept), - ) - if format != "html": - return L - H.append(L) - - F = html_sco_header.sco_footer() - return "\n".join(H) + F - - -def list_users( - dept, - all_depts=False, # tous les departements - with_inactives=False, # inclut les anciens utilisateurs (status "old") - format="html", - with_links=True, -): - "List users, returns a table in the specified format" - from app.scodoc.sco_permissions_check import can_handle_passwd - - if dept and not all_depts: - users = get_user_list(dept=dept, with_inactives=with_inactives) - comm = "dept. %s" % dept - else: - users = get_user_list(with_inactives=with_inactives) - comm = "tous" - if with_inactives: - comm += ", avec anciens" - comm = "(" + comm + ")" - # -- Add some information and links: - r = [] - for u in users: - # Can current user modify this user ? - can_modify = can_handle_passwd(u, allow_admindepts=True) - - d = u.to_dict() - r.append(d) - # Add links - if with_links and can_modify: - target = url_for( - "users.user_info_page", scodoc_dept=dept, user_name=u.user_name - ) - d["_user_name_target"] = target - d["_nom_target"] = target - d["_prenom_target"] = target - - # Hide passwd modification date (depending on visitor's permission) - if not can_modify: - d["date_modif_passwd"] = "(non visible)" - - columns_ids = [ - "user_name", - "nom_fmt", - "prenom_fmt", - "email", - "dept", - "roles_string", - "date_expiration", - "date_modif_passwd", - "passwd_temp", - "status_txt", - ] - # Seul l'admin peut voir les dates de dernière connexion - if current_user.is_administrator(): - columns_ids.append("last_seen") - title = "Utilisateurs définis dans ScoDoc" - tab = GenTable( - rows=r, - columns_ids=columns_ids, - titles={ - "user_name": "Login", - "nom_fmt": "Nom", - "prenom_fmt": "Prénom", - "email": "Mail", - "dept": "Dept.", - "roles_string": "Rôles", - "date_expiration": "Expiration", - "date_modif_passwd": "Modif. mot de passe", - "last_seen": "Dernière cnx.", - "passwd_temp": "Temp.", - "status_txt": "Etat", - }, - caption=title, - page_title="title", - html_title="""

      %d utilisateurs %s

      -

      Cliquer sur un nom pour changer son mot de passe

      """ - % (len(r), comm), - html_class="table_leftalign list_users", - html_with_td_classes=True, - html_sortable=True, - base_url="%s?all_depts=%s" % (request.base_url, 1 if all_depts else 0), - pdf_link=False, # table is too wide to fit in a paper page => disable pdf - preferences=sco_preferences.SemPreferences(), - ) - - return tab.make_page(format=format, with_html_headers=False) - - -def get_user_list(dept=None, with_inactives=False): - """Returns list of users. - If dept, select users from this dept, - else return all users. - """ - # was get_userlist - q = User.query - if dept is not None: - q = q.filter_by(dept=dept) - if not with_inactives: - q = q.filter_by(active=True) - return q.order_by(User.nom, User.user_name).all() - - -def _user_list(user_name): - "return user as a dict" - u = User.query.filter_by(user_name=user_name).first() - if u: - return u.to_dict() - else: - return None - - -@cache.memoize(timeout=50) # seconds -def user_info(user_name_or_id=None, user: User = None): - """Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base). - Si user_name est specifie (string ou id), interroge la BD. Sinon, user doit etre une instance - de User. - """ - if user_name_or_id is not None: - if isinstance(user_name_or_id, int): - u = User.query.filter_by(id=user_name_or_id).first() - else: - u = User.query.filter_by(user_name=user_name_or_id).first() - if u: - user_name = u.user_name - info = u.to_dict() - else: - info = None - user_name = "inconnu" - else: - info = user.to_dict() - user_name = user.user_name - - if not info: - # special case: user is not in our database - return { - "user_name": user_name, - "nom": user_name, - "prenom": "", - "email": "", - "dept": "", - "nomprenom": user_name, - "prenomnom": user_name, - "prenom_fmt": "", - "nom_fmt": user_name, - "nomcomplet": user_name, - "nomplogin": user_name, - # "nomnoacc": scu.suppress_accents(user_name), - "passwd_temp": 0, - "status": "", - "date_expiration": None, - } - else: - # Ensure we never publish password hash - if "password_hash" in info: - del info["password_hash"] - return info - - -def check_modif_user( - edit, - enforce_optionals=False, - user_name="", - nom="", - prenom="", - email="", - dept="", - roles=[], -): - """Vérifie que cet utilisateur peut être créé (edit=0) ou modifié (edit=1) - Cherche homonymes. - returns (ok, msg) - - ok : si vrai, peut continuer avec ces parametres - (si ok est faux, l'utilisateur peut quand même forcer la creation) - - msg: message warning à presenter à l'utilisateur - """ - MSG_OPT = """
      Attention: (vous pouvez forcer l'opération en cochant "Ignorer les avertissements" en bas de page)""" - # ce login existe ? - user = _user_list(user_name) - if edit and not user: # safety net, le user_name ne devrait pas changer - return False, "identifiant %s inexistant" % user_name - if not edit and user: - return False, "identifiant %s déjà utilisé" % user_name - if not user_name or not nom or not prenom: - return False, "champ requis vide" - if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", user_name): - return ( - False, - "identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)" - % user_name, - ) - if enforce_optionals and len(user_name) > 64: - return False, "identifiant '%s' trop long (64 caractères)" % user_name - if enforce_optionals and len(nom) > 64: - return False, "nom '%s' trop long (64 caractères)" % nom + MSG_OPT - if enforce_optionals and len(prenom) > 64: - return False, "prenom '%s' trop long (64 caractères)" % prenom + MSG_OPT - # check that tha same user_name has not already been described in this import - if not email: - return False, "vous devriez indiquer le mail de l'utilisateur créé !" - if len(email) > 120: - return False, "email '%s' trop long (120 caractères)" % email - if not re.fullmatch(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email): - return False, "l'adresse mail semble incorrecte" - # check département - if ( - enforce_optionals - and dept - and Departement.query.filter_by(acronym=dept).first() is None - ): - return False, "département '%s' inexistant" % dept + MSG_OPT - if enforce_optionals and not roles: - return False, "aucun rôle sélectionné, êtes vous sûr ?" + MSG_OPT - # Unicité du mail - users_with_this_mail = User.query.filter_by(email=email).all() - if edit: # modification - if email != user["email"] and len(users_with_this_mail) > 0: - return False, "un autre utilisateur existe déjà avec cette adresse mail" - else: # création utilisateur - if len(users_with_this_mail) > 0: - return False, "un autre utilisateur existe déjà avec cette adresse mail" - - # ok - # Des noms/prénoms semblables existent ? - nom = nom.lower().strip() - prenom = prenom.lower().strip() - similar_users = User.query.filter( - User.nom.ilike(nom), User.prenom.ilike(prenom) - ).all() - if edit: - minmatch = 1 - else: - minmatch = 0 - if enforce_optionals and len(similar_users) > minmatch: - return ( - False, - "des utilisateurs proches existent: " - + ", ".join( - [ - "%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name) - for x in similar_users - ] - ) - + MSG_OPT, - ) - # Roles ? - return True, "" - - -def user_edit(user_name, vals): - """Edit the user specified by user_name - (ported from Zope to SQLAlchemy, hence strange !) - """ - u = User.query.filter_by(user_name=user_name).first() - if not u: - raise ScoValueError("Invalid user_name") - u.from_dict(vals) - db.session.add(u) - db.session.commit() +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Fonctions sur les utilisateurs +""" + +# Anciennement ZScoUsers.py, fonctions de gestion des données réécrite avec flask/SQLAlchemy +import re + +from flask import url_for, g, request +from flask.templating import render_template +from flask_login import current_user + + +from app import db, Departement + +from app.auth.models import Permission +from app.auth.models import User + +from app.scodoc import html_sco_header +from app.scodoc import sco_etud +from app.scodoc import sco_excel +from app.scodoc import sco_preferences +from app.scodoc.gen_tables import GenTable +from app import log, cache +from app.scodoc.scolog import logdb +import app.scodoc.sco_utils as scu + +from app.scodoc.sco_exceptions import ( + AccessDenied, + ScoValueError, +) + + +# --------------- + +# --------------- + + +def index_html(all_depts=False, with_inactives=False, format="html"): + "gestion utilisateurs..." + all_depts = int(all_depts) + with_inactives = int(with_inactives) + + H = [html_sco_header.html_sem_header("Gestion des utilisateurs")] + + if current_user.has_permission(Permission.ScoUsersAdmin, g.scodoc_dept): + H.append( + '

      Ajouter un utilisateur'.format( + url_for("users.create_user_form", scodoc_dept=g.scodoc_dept) + ) + ) + if current_user.is_administrator(): + H.append( + '   Importer des utilisateurs

      '.format( + url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) + ) + ) + else: + H.append( + "   Pour importer des utilisateurs en masse (via xlsx file) contactez votre administrateur scodoc." + ) + if all_depts: + checked = "checked" + else: + checked = "" + if with_inactives: + olds_checked = "checked" + else: + olds_checked = "" + H.append( + """

      + Tous les départements + Avec anciens utilisateurs +

      """ + % (request.base_url, checked, olds_checked) + ) + + L = list_users( + g.scodoc_dept, + all_depts=all_depts, + with_inactives=with_inactives, + format=format, + with_links=current_user.has_permission(Permission.ScoUsersAdmin, g.scodoc_dept), + ) + if format != "html": + return L + H.append(L) + + F = html_sco_header.sco_footer() + return "\n".join(H) + F + + +def list_users( + dept, + all_depts=False, # tous les departements + with_inactives=False, # inclut les anciens utilisateurs (status "old") + format="html", + with_links=True, +): + "List users, returns a table in the specified format" + from app.scodoc.sco_permissions_check import can_handle_passwd + + if dept and not all_depts: + users = get_user_list(dept=dept, with_inactives=with_inactives) + comm = "dept. %s" % dept + else: + users = get_user_list(with_inactives=with_inactives) + comm = "tous" + if with_inactives: + comm += ", avec anciens" + comm = "(" + comm + ")" + # -- Add some information and links: + r = [] + for u in users: + # Can current user modify this user ? + can_modify = can_handle_passwd(u, allow_admindepts=True) + + d = u.to_dict() + r.append(d) + # Add links + if with_links and can_modify: + target = url_for( + "users.user_info_page", scodoc_dept=dept, user_name=u.user_name + ) + d["_user_name_target"] = target + d["_nom_target"] = target + d["_prenom_target"] = target + + # Hide passwd modification date (depending on visitor's permission) + if not can_modify: + d["date_modif_passwd"] = "(non visible)" + + columns_ids = [ + "user_name", + "nom_fmt", + "prenom_fmt", + "email", + "dept", + "roles_string", + "date_expiration", + "date_modif_passwd", + "passwd_temp", + "status_txt", + ] + # Seul l'admin peut voir les dates de dernière connexion + if current_user.is_administrator(): + columns_ids.append("last_seen") + title = "Utilisateurs définis dans ScoDoc" + tab = GenTable( + rows=r, + columns_ids=columns_ids, + titles={ + "user_name": "Login", + "nom_fmt": "Nom", + "prenom_fmt": "Prénom", + "email": "Mail", + "dept": "Dept.", + "roles_string": "Rôles", + "date_expiration": "Expiration", + "date_modif_passwd": "Modif. mot de passe", + "last_seen": "Dernière cnx.", + "passwd_temp": "Temp.", + "status_txt": "Etat", + }, + caption=title, + page_title="title", + html_title="""

      %d utilisateurs %s

      +

      Cliquer sur un nom pour changer son mot de passe

      """ + % (len(r), comm), + html_class="table_leftalign list_users", + html_with_td_classes=True, + html_sortable=True, + base_url="%s?all_depts=%s" % (request.base_url, 1 if all_depts else 0), + pdf_link=False, # table is too wide to fit in a paper page => disable pdf + preferences=sco_preferences.SemPreferences(), + ) + + return tab.make_page(format=format, with_html_headers=False) + + +def get_user_list(dept=None, with_inactives=False): + """Returns list of users. + If dept, select users from this dept, + else return all users. + """ + # was get_userlist + q = User.query + if dept is not None: + q = q.filter_by(dept=dept) + if not with_inactives: + q = q.filter_by(active=True) + return q.order_by(User.nom, User.user_name).all() + + +def _user_list(user_name): + "return user as a dict" + u = User.query.filter_by(user_name=user_name).first() + if u: + return u.to_dict() + else: + return None + + +@cache.memoize(timeout=50) # seconds +def user_info(user_name_or_id=None, user: User = None): + """Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base). + Si user_name est specifie (string ou id), interroge la BD. Sinon, user doit etre une instance + de User. + """ + if user_name_or_id is not None: + if isinstance(user_name_or_id, int): + u = User.query.filter_by(id=user_name_or_id).first() + else: + u = User.query.filter_by(user_name=user_name_or_id).first() + if u: + user_name = u.user_name + info = u.to_dict() + else: + info = None + user_name = "inconnu" + else: + info = user.to_dict() + user_name = user.user_name + + if not info: + # special case: user is not in our database + return { + "user_name": user_name, + "nom": user_name, + "prenom": "", + "email": "", + "dept": "", + "nomprenom": user_name, + "prenomnom": user_name, + "prenom_fmt": "", + "nom_fmt": user_name, + "nomcomplet": user_name, + "nomplogin": user_name, + # "nomnoacc": scu.suppress_accents(user_name), + "passwd_temp": 0, + "status": "", + "date_expiration": None, + } + else: + # Ensure we never publish password hash + if "password_hash" in info: + del info["password_hash"] + return info + + +def check_modif_user( + edit, + enforce_optionals=False, + user_name="", + nom="", + prenom="", + email="", + dept="", + roles=[], +): + """Vérifie que cet utilisateur peut être créé (edit=0) ou modifié (edit=1) + Cherche homonymes. + returns (ok, msg) + - ok : si vrai, peut continuer avec ces parametres + (si ok est faux, l'utilisateur peut quand même forcer la creation) + - msg: message warning à presenter à l'utilisateur + """ + MSG_OPT = """
      Attention: (vous pouvez forcer l'opération en cochant "Ignorer les avertissements" en bas de page)""" + # ce login existe ? + user = _user_list(user_name) + if edit and not user: # safety net, le user_name ne devrait pas changer + return False, "identifiant %s inexistant" % user_name + if not edit and user: + return False, "identifiant %s déjà utilisé" % user_name + if not user_name or not nom or not prenom: + return False, "champ requis vide" + if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", user_name): + return ( + False, + "identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)" + % user_name, + ) + if enforce_optionals and len(user_name) > 64: + return False, "identifiant '%s' trop long (64 caractères)" % user_name + if enforce_optionals and len(nom) > 64: + return False, "nom '%s' trop long (64 caractères)" % nom + MSG_OPT + if enforce_optionals and len(prenom) > 64: + return False, "prenom '%s' trop long (64 caractères)" % prenom + MSG_OPT + # check that tha same user_name has not already been described in this import + if not email: + return False, "vous devriez indiquer le mail de l'utilisateur créé !" + if len(email) > 120: + return False, "email '%s' trop long (120 caractères)" % email + if not re.fullmatch(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email): + return False, "l'adresse mail semble incorrecte" + # check département + if ( + enforce_optionals + and dept + and Departement.query.filter_by(acronym=dept).first() is None + ): + return False, "département '%s' inexistant" % dept + MSG_OPT + if enforce_optionals and not roles: + return False, "aucun rôle sélectionné, êtes vous sûr ?" + MSG_OPT + # Unicité du mail + users_with_this_mail = User.query.filter_by(email=email).all() + if edit: # modification + if email != user["email"] and len(users_with_this_mail) > 0: + return False, "un autre utilisateur existe déjà avec cette adresse mail" + else: # création utilisateur + if len(users_with_this_mail) > 0: + return False, "un autre utilisateur existe déjà avec cette adresse mail" + + # ok + # Des noms/prénoms semblables existent ? + nom = nom.lower().strip() + prenom = prenom.lower().strip() + similar_users = User.query.filter( + User.nom.ilike(nom), User.prenom.ilike(prenom) + ).all() + if edit: + minmatch = 1 + else: + minmatch = 0 + if enforce_optionals and len(similar_users) > minmatch: + return ( + False, + "des utilisateurs proches existent: " + + ", ".join( + [ + "%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name) + for x in similar_users + ] + ) + + MSG_OPT, + ) + # Roles ? + return True, "" + + +def user_edit(user_name, vals): + """Edit the user specified by user_name + (ported from Zope to SQLAlchemy, hence strange !) + """ + u = User.query.filter_by(user_name=user_name).first() + if not u: + raise ScoValueError("Invalid user_name") + u.from_dict(vals) + db.session.add(u) + db.session.commit() diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ed16cbb6..b5cdee32 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2863,10 +2863,13 @@ select.tf-selglobal { } td.tf-fieldlabel { - /* font-weight: bold; */ vertical-align: top; } +td.tf-field { + max-width: 800px; +} + .tf-comment { font-size: 80%; font-style: italic; @@ -2876,6 +2879,12 @@ td.tf-fieldlabel { font-style: italic; } +#tf details summary { + font-size: 130%; + margin-top: 6px; + margin-bottom: 6px; +} + .radio_green { background-color: green; } diff --git a/app/views/absences.py b/app/views/absences.py index ed57bb01..d076fe78 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -1,1520 +1,1520 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -""" -Module absences: issu de ScoDoc7 / ZAbsences.py - -Emmanuel Viennet, 2021 - -Gestion des absences (v4) - -Code dérivé de la partie la plus ancienne de ScoDoc, et à revoir. - -L'API de plus bas niveau est en gros: - - AnnuleAbsencesDatesNoJust( dates) - count_abs(etudid, debut, fin, matin=None, moduleimpl_id=None) - count_abs_just(etudid, debut, fin, matin=None, moduleimpl_id=None) - list_abs_just(etudid, datedebut) [pas de fin ?] - list_abs_non_just(etudid, datedebut) [pas de fin ?] - list_abs_justifs(etudid, datedebut, datefin=None, only_no_abs=True) - - list_abs_jour(date, am=True, pm=True, is_abs=None, is_just=None) - list_abs_non_just_jour(date, am=True, pm=True) - -""" - -import calendar -import datetime -import dateutil -import dateutil.parser -import re -import time -import urllib -from xml.etree import ElementTree - -import flask -from flask import g, request -from flask import abort, flash, url_for -from flask_login import current_user - -from app import db, log -from app import api -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.decorators import ( - scodoc, - scodoc7func, - permission_required, - permission_required_compat_scodoc7, -) -from app.models import FormSemestre, GroupDescr, Partition -from app.models.absences import BilletAbsence -from app.models.etudiants import Identite -from app.views import absences_bp as bp - -# --------------- -from app.scodoc import sco_utils as scu -from app.scodoc import notesdb as ndb -from app.scodoc.scolog import logdb -from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import ScoValueError, APIInvalidParams -from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc.gen_tables import GenTable -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_abs_billets -from app.scodoc import sco_abs_views -from app.scodoc import sco_etud -from app.scodoc import sco_find_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups_view -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_preferences -from app.scodoc import sco_xml - - -CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS - - -def sco_publish(route, function, permission, methods=["GET"]): - """Declare a route for a python function, - protected by permission and called following ScoDoc 7 Zope standards. - """ - return bp.route(route, methods=methods)( - scodoc(permission_required(permission)(scodoc7func(function))) - ) - - -# -------------------------------------------------------------------- -# -# ABSENCES (/ScoDoc//Scolarite/Absences/...) -# -# -------------------------------------------------------------------- - - -@bp.route("/") -@bp.route("/index_html") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def index_html(): - """Gestionnaire absences, page principale""" - - H = [ - html_sco_header.sco_header( - page_title="Saisie des absences", - cssstyles=["css/calabs.css"], - javascripts=["js/calabs.js"], - ), - """

      Traitement des absences

      -

      - Pour saisir des absences ou consulter les états, il est recommandé par passer par - le semestre concerné (saisie par jours nommés ou par semaines). -

      - """, - ] - H.append( - """

      Pour signaler, annuler ou justifier une absence pour un seul étudiant, - choisissez d'abord concerné:

      """ - ) - H.append(sco_find_etud.form_search_etud()) - if current_user.has_permission( - Permission.ScoAbsChange - ) and sco_preferences.get_preference("handle_billets_abs"): - H.append( - f""" -

      Billets d'absence

      - - """ - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -@bp.route("/choix_semaine") -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def choix_semaine(group_id): - """Page choix semaine sur calendrier pour saisie absences d'un groupe""" - group = ( - GroupDescr.query.filter_by(id=group_id) - .join(Partition) - .join(FormSemestre) - .filter_by(dept_id=g.scodoc_dept_id) - .first_or_404() - ) - H = [ - html_sco_header.sco_header( - page_title="Saisie des absences", - cssstyles=["css/calabs.css"], - javascripts=["js/calabs.js"], - ), - f""" -

      Saisie des Absences

      -
      -

      - - Saisie par semaine - Groupe: {group.get_nom_with_part()} - - -

      - """, - cal_select_week(), - """

      Sélectionner le groupe d'étudiants, puis cliquez sur une semaine pour - saisir les absences de toute cette semaine.

      -
      - """, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def cal_select_week(year=None): - "display calendar allowing week selection" - if not year: - year = scu.AnneeScolaire() - sems = sco_formsemestre.do_formsemestre_list() - if not sems: - js = "" - else: - js = 'onmouseover="highlightweek(this);" onmouseout="deselectweeks();" onclick="wclick(this);"' - C = sco_abs.YearTable(int(year), dayattributes=js) - return C - - -sco_publish("/EtatAbsences", sco_abs_views.EtatAbsences, Permission.ScoView) -sco_publish("/CalAbs", sco_abs_views.CalAbs, Permission.ScoView) -sco_publish( - "/SignaleAbsenceEtud", - sco_abs_views.SignaleAbsenceEtud, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/doSignaleAbsence", - sco_abs_views.doSignaleAbsence, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/JustifAbsenceEtud", - sco_abs_views.JustifAbsenceEtud, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/doJustifAbsence", - sco_abs_views.doJustifAbsence, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/AnnuleAbsenceEtud", - sco_abs_views.AnnuleAbsenceEtud, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/doAnnuleAbsence", - sco_abs_views.doAnnuleAbsence, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/doAnnuleJustif", - sco_abs_views.doAnnuleJustif, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) -sco_publish( - "/AnnuleAbsencesDatesNoJust", - sco_abs_views.AnnuleAbsencesDatesNoJust, - Permission.ScoAbsChange, - methods=["GET", "POST"], -) - -# Antédiluvienne fonction: #deprecated -@bp.route("/ListeAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def ListeAbsEtud( - etudid=None, - code_nip=None, - with_evals=True, - format="html", - absjust_only=0, - sco_year=None, -): - return sco_abs_views.ListeAbsEtud( - etudid=etudid, - code_nip=str(code_nip), - with_evals=with_evals, - format=format, - absjust_only=absjust_only, - sco_year=sco_year, - ) - - -# -------------------------------------------------------------------- -# -# SQL METHODS (xxx #sco8 not views => à déplacer) -# -# -------------------------------------------------------------------- - -# API backward compatibility -sco_publish("/CountAbs", sco_abs.count_abs, Permission.ScoView) -sco_publish("/CountAbsJust", sco_abs.count_abs_just, Permission.ScoView) -# TODO nouvel appel rendnat les deux valeurs et utilisant le cache - - -@bp.route("/doSignaleAbsenceGrSemestre", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def doSignaleAbsenceGrSemestre( - moduleimpl_id=None, - abslist=[], - dates="", - etudids="", - destination=None, -): - """Enregistre absences aux dates indiquees (abslist et dates). - dates est une liste de dates ISO (séparées par des ','). - Efface les absences aux dates indiquées par dates, - ou bien ajoute celles de abslist. - """ - moduleimpl_id = moduleimpl_id or None - if etudids: - etudids = [int(x) for x in str(etudids).split(",")] - else: - etudids = [] - if dates: - dates = dates.split(",") - else: - dates = [] - - # 1- Efface les absences - if dates: - for etudid in etudids: - sco_abs_views.AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id) - return "Absences effacées" - - # 2- Ajoute les absences - if abslist: - sco_abs.add_abslist(abslist, moduleimpl_id) - return "Absences ajoutées" - - return ("", 204) - - -# ------------ HTML Interfaces -@bp.route("/SignaleAbsenceGrHebdo", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def SignaleAbsenceGrHebdo( - datelundi, group_ids=[], destination="", moduleimpl_id=None, formsemestre_id=None -): - "Saisie hebdomadaire des absences" - if not moduleimpl_id: - moduleimpl_id = None - - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id - ) - if not groups_infos.members: - return ( - html_sco_header.sco_header(page_title="Saisie des absences") - + "

      Aucun étudiant !

      " - + html_sco_header.sco_footer() - ) - - base_url = "SignaleAbsenceGrHebdo?datelundi=%s&%s&destination=%s" % ( - datelundi, - groups_infos.groups_query_args, - urllib.parse.quote(destination), - ) - - formsemestre_id = groups_infos.formsemestre_id - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if formsemestre.dept_id != g.scodoc_dept_id: - abort(404, "groupes inexistants dans ce département") - require_module = sco_preferences.get_preference( - "abs_require_module", formsemestre_id - ) - etuds = [ - sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - for m in groups_infos.members - ] - # Restreint aux inscrits au module sélectionné - if moduleimpl_id: - mod_inscrits = set( - [ - x["etudid"] - for x in sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=moduleimpl_id - ) - ] - ) - etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits] - if etuds_inscrits_module: - etuds = etuds_inscrits_module - else: - # Si aucun etudiant n'est inscrit au module choisi... - moduleimpl_id = None - - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - sem = formsemestre.to_dict() - - # calcule dates jours de cette semaine - # liste de dates iso "yyyy-mm-dd" - datessem = [ndb.DateDMYtoISO(datelundi)] - for _ in sco_abs.day_names()[1:]: - datessem.append(sco_abs.next_iso_day(datessem[-1])) - # - if groups_infos.tous_les_etuds_du_sem: - gr_tit = "en" - else: - if len(groups_infos.group_ids) > 1: - p = "des groupes" - else: - p = "du groupe" - gr_tit = p + ' ' + groups_infos.groups_titles + "" - - H = [ - html_sco_header.sco_header( - page_title="Saisie hebdomadaire des absences", - init_qtip=True, - javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS - + [ - "js/etud_info.js", - "js/abs_ajax.js", - "js/groups_view.js", - ], - cssstyles=CSSSTYLES, - no_side_bar=1, - ), - """
      -

      Saisie des absences %s %s, - semaine du lundi %s

      -
      -
      - - - - - Groupes: %s -
      -
      - """ - % ( - gr_tit, - sem["titre_num"], - datelundi, - groups_infos.formsemestre_id, - datelundi, - destination, - moduleimpl_id or "", - sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True), - ), - ] - # - modimpls_list = [] - ues = nt.get_ues_stat_dict() - for ue in ues: - modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) - - menu_module = "" - for modimpl in modimpls_list: - if modimpl["moduleimpl_id"] == moduleimpl_id: - sel = "selected" - else: - sel = "" - menu_module += ( - """\n""" - % { - "modimpl_id": modimpl["moduleimpl_id"], - "modname": (modimpl["module"]["code"] or "") - + " " - + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or ""), - "sel": sel, - } - ) - if moduleimpl_id: - sel = "" - else: - sel = "selected" # aucun module specifie - - H.append( - """Module concerné: - -
      """ - % {"menu_module": menu_module, "url": base_url, "sel": sel} - ) - - H += _gen_form_saisie_groupe( - etuds, datessem, destination, moduleimpl_id, require_module - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -@bp.route("/SignaleAbsenceGrSemestre", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def SignaleAbsenceGrSemestre( - datedebut, - datefin, - destination="", - group_ids=(), # list of groups to display - nbweeks=4, # ne montre que les nbweeks dernieres semaines - moduleimpl_id=None, -): - """Saisie des absences sur une journée sur un semestre (ou intervalle de dates) entier""" - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - if not groups_infos.members: - return ( - html_sco_header.sco_header(page_title="Saisie des absences") - + "

      Aucun étudiant !

      " - + html_sco_header.sco_footer() - ) - formsemestre_id = groups_infos.formsemestre_id - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - if formsemestre.dept_id != g.scodoc_dept_id: - return abort(404, "groupes inexistants dans ce département") - sem = formsemestre.to_dict() - require_module = sco_preferences.get_preference( - "abs_require_module", formsemestre_id - ) - etuds = [ - sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - for m in groups_infos.members - ] - # Restreint aux inscrits au module sélectionné - if moduleimpl_id: - mod_inscrits = set( - [ - x["etudid"] - for x in sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=moduleimpl_id - ) - ] - ) - etuds = [e for e in etuds if e["etudid"] in mod_inscrits] - if not moduleimpl_id: - moduleimpl_id = None - base_url_noweeks = ( - "SignaleAbsenceGrSemestre?datedebut=%s&datefin=%s&%s&destination=%s" - % ( - datedebut, - datefin, - groups_infos.groups_query_args, - urllib.parse.quote(destination), - ) - ) - base_url = base_url_noweeks + "&nbweeks=%s" % nbweeks # sans le moduleimpl_id - - if etuds: - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - work_saturday = sco_abs.is_work_saturday() - jourdebut = sco_abs.ddmmyyyy(datedebut, work_saturday=work_saturday) - jourfin = sco_abs.ddmmyyyy(datefin, work_saturday=work_saturday) - today = sco_abs.ddmmyyyy( - time.strftime("%d/%m/%Y", time.localtime()), - work_saturday=work_saturday, - ) - today.next_day() - if jourfin > today: # ne propose jamais les semaines dans le futur - jourfin = today - if jourdebut > today: - raise ScoValueError("date de début dans le futur (%s) !" % jourdebut) - # - if not jourdebut.iswork() or jourdebut > jourfin: - raise ValueError( - "date debut invalide (%s, ouvrable=%d)" - % (str(jourdebut), jourdebut.iswork()) - ) - # calcule dates - dates = [] # sco_abs.ddmmyyyy instances - d = sco_abs.ddmmyyyy(datedebut, work_saturday=work_saturday) - while d <= jourfin: - dates.append(d) - d = d.next_day(7) # avance d'une semaine - # - msg = "Montrer seulement les 4 dernières semaines" - nwl = 4 - if nbweeks: - nbweeks = int(nbweeks) - if nbweeks > 0: - dates = dates[-nbweeks:] - msg = "Montrer toutes les semaines" - nwl = 0 - url_link_semaines = base_url_noweeks + "&nbweeks=%s" % nwl - if moduleimpl_id: - url_link_semaines += "&moduleimpl_id=" + str(moduleimpl_id) - # - dates = [x.ISO() for x in dates] - day_name = sco_abs.day_names()[jourdebut.weekday] - - if groups_infos.tous_les_etuds_du_sem: - gr_tit = "en" - else: - if len(groups_infos.group_ids) > 1: - p = "des groupes " - else: - p = "du groupe " - gr_tit = p + '' + groups_infos.groups_titles + "" - - H = [ - html_sco_header.sco_header( - page_title=f"Saisie des absences du {day_name}", - init_qtip=True, - javascripts=["js/etud_info.js", "js/abs_ajax.js"], - no_side_bar=1, - ), - f"""
      -

      Saisie des absences {gr_tit} {sem["titre_num"]}, - les {day_name}s

      -

      - {msg} - - """, - ] - # - if etuds: - modimpls_list = [] - ues = nt.get_ues_stat_dict() - for ue in ues: - modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) - - menu_module = "" - for modimpl in modimpls_list: - if modimpl["moduleimpl_id"] == moduleimpl_id: - sel = "selected" - else: - sel = "" - menu_module += ( - """\n""" - % { - "modimpl_id": modimpl["moduleimpl_id"], - "modname": (modimpl["module"]["code"] or "") - + " " - + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or ""), - "sel": sel, - } - ) - if moduleimpl_id: - sel = "" - else: - sel = "selected" # aucun module specifie - H.append( - """

      -Module concerné par ces absences (%(optionel_txt)s): - -

      """ - % { - "menu_module": menu_module, - "url": base_url, - "sel": sel, - "optionel_txt": 'requis' - if require_module - else "optionnel", - } - ) - - H += _gen_form_saisie_groupe( - etuds, dates, destination, moduleimpl_id, require_module - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def _gen_form_saisie_groupe( - etuds, dates, destination="", moduleimpl_id=None, require_module=False -): - """Formulaire saisie absences - - Args: - etuds: liste des étudiants - dates: liste ordonnée de dates iso, par exemple: [ '2020-12-24', ... ] - moduleimpl_id: optionnel, module concerné. - """ - H = [ - """ - -
      -
      - - - """ - % ( - "true" if (require_module and not moduleimpl_id) else "false", - len(etuds), - ) - ] - # Dates - odates = [datetime.date(*[int(x) for x in d.split("-")]) for d in dates] - begin = dates[0] - end = dates[-1] - # Titres colonnes - noms_jours = [] # eg [ "Lundi", "mardi", "Samedi", ... ] - jn = sco_abs.day_names() - for d in odates: - idx_jour = d.weekday() - noms_jours.append(jn[idx_jour]) - for jour in noms_jours: - H.append( - '" - ) - H.append("") - for d in odates: - H.append( - '" - ) - H.append("") - H.append("" * len(dates)) - H.append("") - # - if not etuds: - H.append( - '' - ) - i = 1 - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for etud in etuds: - i += 1 - etudid = etud["etudid"] - etud_class = "etudinfo" # css - # UE capitalisee dans semestre courant ? - cap = [] - if etud["cursem"]: - formsemestre = FormSemestre.query.get_or_404( - etud["cursem"]["formsemestre_id"] - ) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - for ue in nt.get_ues_stat_dict(): - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) - if ue_status and ue_status["is_capitalized"]: - cap.append(ue["acronyme"]) - if cap: - capstr = ' (%s cap.)' % ", ".join(cap) - else: - capstr = "" - if etud["etatincursem"] == "D": - capstr += ' (dém.)' - etud_class += " etuddem" - tr_class = ("row_1", "row_2", "row_3")[i % 3] - td_matin_class = ("matin_1", "matin_2", "matin_3")[i % 3] - - H.append( - '' - % ( - tr_class, - etud_class, - etudid, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - etud["nomprenom"], - capstr, - ) - ) - etud_abs = sco_abs.list_abs_in_range( - etudid, begin, end, moduleimpl_id=moduleimpl_id, cursor=cursor - ) - for d in odates: - date = d.strftime("%Y-%m-%d") - # matin - is_abs = {"jour": d, "matin": True} in etud_abs - if is_abs: - checked = "checked" - else: - checked = "" - # bulle lors du passage souris - coljour = sco_abs.DAYNAMES[(calendar.weekday(d.year, d.month, d.day))] - datecol = coljour + " " + d.strftime("%d/%m/%Y") - bulle_am = '"' + etud["nomprenom"] + " - " + datecol + ' (matin)"' - bulle_pm = '"' + etud["nomprenom"] + " - " + datecol + ' (ap.midi)"' - - H.append( - '' - % ( - td_matin_class, - bulle_am, - str(etudid) + ":" + date + ":" + "am", - checked, - etudid, - date + ":am", - ) - ) - # après-midi - is_abs = {"jour": d, "matin": False} in etud_abs - if is_abs: - checked = "checked" - else: - checked = "" - H.append( - '' - % ( - bulle_pm, - str(etudid) + ":" + date + ":" + "pm", - checked, - etudid, - date + ":pm", - ) - ) - H.append("") - H.append("
      %d étudiants' - + jour - + "
       ' - + d.strftime("%d/%m/%Y") - + "
       AMPM
      Aucun étudiant inscrit !
      %s%s
      ") - # place la liste des etudiants et les dates pour pouvoir effacer les absences - H.append( - '' - % ",".join([str(etud["etudid"]) for etud in etuds]) - ) - H.append('' % dates[0]) - H.append('' % dates[-1]) - H.append('' % ",".join(dates)) - H.append( - '' - % urllib.parse.quote(destination) - ) - # - # version pour formulaire avec AJAX (Yann LB) - H.append( - """ -

      - -

      -
      -

      Les cases cochées correspondent à des absences. - Les absences saisies ne sont pas justifiées (sauf si un justificatif a été entré - par ailleurs). -

      Si vous "décochez" une case, l'absence correspondante sera supprimée. - Attention, les modifications sont automatiquement entregistrées au fur et à mesure. -

      - """ - ) - return H - - -@bp.route("/EtatAbsencesGr") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func # ported from dtml -def EtatAbsencesGr( - group_ids=[], # list of groups to display - debut="", - fin="", - with_boursier=True, # colonne boursier - format="html", -): - """Liste les absences de groupes""" - datedebut = ndb.DateDMYtoISO(debut) - datefin = ndb.DateDMYtoISO(fin) - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - formsemestre_id = groups_infos.formsemestre_id - sem = groups_infos.formsemestre - - # Construit tableau (etudid, statut, nomprenom, nbJust, nbNonJust, NbTotal) - T = [] - for m in groups_infos.members: - etud = sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - nbabs = sco_abs.count_abs(etudid=etud["etudid"], debut=datedebut, fin=datefin) - nbabsjust = sco_abs.count_abs_just( - etudid=etud["etudid"], debut=datedebut, fin=datefin - ) - nbjustifs_noabs = len( - sco_abs.list_abs_justifs( - etudid=etud["etudid"], - datedebut=datedebut, - datefin=datefin, - only_no_abs=True, - ) - ) - # retrouve sem dans etud['sems'] - s = None - for s in etud["sems"]: - if s["formsemestre_id"] == formsemestre_id: - break - if not s or s["formsemestre_id"] != formsemestre_id: - raise ValueError( - "EtatAbsencesGr: can't retreive sem" - ) # bug or malicious arg - T.append( - { - "etudid": etud["etudid"], - "etatincursem": s["ins"]["etat"], - "nomprenom": etud["nomprenom"], - "nbabsjust": nbabsjust, - "nbabsnonjust": nbabs - nbabsjust, - "nbabs": nbabs, - "nbjustifs_noabs": nbjustifs_noabs, - "_nomprenom_target": "CalAbs?etudid=%s" % etud["etudid"], - "_nomprenom_td_attrs": 'id="%s" class="etudinfo"' % etud["etudid"], - "boursier": "oui" if etud["boursier"] else "non", - } - ) - if s["ins"]["etat"] == "D": - T[-1]["_css_row_class"] = "etuddem" - T[-1]["nomprenom"] += " (dem)" - columns_ids = [ - "nomprenom", - "nbjustifs_noabs", - "nbabsjust", - "nbabsnonjust", - "nbabs", - ] - if with_boursier: - columns_ids[1:1] = ["boursier"] - if groups_infos.tous_les_etuds_du_sem: - gr_tit = "" - else: - if len(groups_infos.group_ids) > 1: - p = "des groupes" - else: - p = "du groupe" - if format == "html": - h = ' ' + groups_infos.groups_titles + "" - else: - h = groups_infos.groups_titles - gr_tit = p + h - - title = f"État des absences {gr_tit}" - if format == "xls" or format == "xml" or format == "json": - columns_ids = ["etudid"] + columns_ids - # --- Formulaire choix dates début / fin - form_date = ( - f""" -
      - - Période du - -  au  - - -   - (nombre de demi-journées) -
      """ - + """ - - """ - ) - tab = GenTable( - columns_ids=columns_ids, - rows=T, - preferences=sco_preferences.SemPreferences(formsemestre_id), - titles={ - "etatincursem": "Etat", - "nomprenom": "Nom", - "nbabsjust": "Justifiées", - "nbabsnonjust": "Non justifiées", - "nbabs": "Total", - "nbjustifs_noabs": "Justifs non utilisés", - "boursier": "Bourse", - }, - html_sortable=True, - html_class="table_leftalign", - html_header=html_sco_header.sco_header( - page_title=title, - init_qtip=True, - javascripts=["js/etud_info.js"], - ), - html_title=html_sco_header.html_sem_header(title, with_page_header=False) - + form_date, - # "

      Période du %s au %s (nombre de demi-journées)
      " % (debut, fin), - base_url="%s&formsemestre_id=%s&debut=%s&fin=%s" - % (groups_infos.base_url, formsemestre_id, debut, fin), - filename="etat_abs_" - + scu.make_filename( - "%s de %s" % (groups_infos.groups_filename, sem["titreannee"]) - ), - caption=title, - html_next_section="""

      -

      -Justifs non utilisés: nombre de demi-journées avec justificatif mais sans absences relevées. -

      -

      -Cliquez sur un nom pour afficher le calendrier des absences
      -ou entrez une date pour visualiser les absents un jour donné : -

      -
      -
      - -%s - - -
      - """ - % (request.base_url, formsemestre_id, groups_infos.get_form_elem()), - ) - return tab.make_page(format=format) - - -@bp.route("/EtatAbsencesDate") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def EtatAbsencesDate(group_ids=[], date=None): # list of groups to display - # ported from dtml - """Etat des absences pour un groupe à une date donnée""" - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - H = [html_sco_header.sco_header(page_title="Etat des absences")] - if date: - dateiso = ndb.DateDMYtoISO(date) - nbetud = 0 - t_nbabsjustam = 0 - t_nbabsam = 0 - t_nbabsjustpm = 0 - t_nbabspm = 0 - H.append("

      État des absences le %s

      " % date) - H.append( - """ - - - """ - ) - for etud in groups_infos.members: - nbabsam = sco_abs.count_abs( - etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=1 - ) - nbabspm = sco_abs.count_abs( - etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=0 - ) - if (nbabsam != 0) or (nbabspm != 0): - nbetud += 1 - nbabsjustam = sco_abs.count_abs_just( - etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=1 - ) - nbabsjustpm = sco_abs.count_abs_just( - etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=0 - ) - H.append( - """") - H.append( - """""" - % (t_nbabsam, t_nbabsjustam, t_nbabspm, t_nbabsjustpm) - ) - H.append("
       MatinAprès-midi
      - %(nomprenom)s""" - % etud - ) # """ - if nbabsam != 0: - if nbabsjustam: - H.append("Just.") - t_nbabsjustam += 1 - else: - H.append("Abs.") - t_nbabsam += 1 - else: - H.append("") - H.append('') - if nbabspm != 0: - if nbabsjustpm: - H.append("Just.") - t_nbabsjustam += 1 - else: - H.append("Abs.") - t_nbabspm += 1 - else: - H.append("") - H.append("
      %d abs, %d just.%d abs, %d just.
      ") - if nbetud == 0: - H.append("

      Aucune absence !

      ") - else: - H.append( - """

      Erreur: vous n'avez pas choisi de date !

      - """ - ) - - return "\n".join(H) + html_sco_header.sco_footer() - - -# ----- Gestion des "billets d'absence": signalement par les etudiants eux mêmes (à travers le portail) -@bp.route("/AddBilletAbsence", methods=["GET", "POST"]) # API ScoDoc 7 compat -@scodoc -@permission_required_compat_scodoc7(Permission.ScoAbsAddBillet) -@scodoc7func -def AddBilletAbsence( - begin, - end, - description, - etudid=None, - code_nip=None, - code_ine=None, - justified=True, - format="json", - xml_reply=True, # deprecated -): - """Mémorise un "billet" - begin et end sont au format ISO (eg "1999-01-08 04:05:06") - """ - log("Warning: calling deprecated AddBilletAbsence") - begin = str(begin) - end = str(end) - code_nip = str(code_nip) if code_nip else None - - etud = api.tools.get_etud(etudid=etudid, nip=code_nip, ine=code_ine) - # check dates - begin_date = dateutil.parser.isoparse(begin) # may raises ValueError - end_date = dateutil.parser.isoparse(end) - if begin_date > end_date: - raise ValueError("invalid dates") - # - justified = bool(justified) - xml_reply = bool(xml_reply) - if xml_reply: # backward compat - format = "xml" - # - billet = BilletAbsence( - etudid=etud.id, - abs_begin=begin, - abs_end=end, - description=description, - etat=False, - justified=justified, - ) - db.session.add(billet) - db.session.commit() - - # Renvoie le nouveau billet au format demandé - table = sco_abs_billets.table_billets([billet], etud=etud) - log(f"AddBilletAbsence: new billet_id={billet.id}") - return table.make_page(format=format) - - -@bp.route("/add_billets_absence_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoAbsAddBillet) -@scodoc7func -def add_billets_absence_form(etudid): - """Formulaire ajout billet (pour tests seulement, le vrai - formulaire accessible aux etudiants étant sur le portail étudiant). - """ - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - H = [ - html_sco_header.sco_header( - page_title="Billet d'absence de %s" % etud["nomprenom"] - ) - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("etudid", {"input_type": "hidden"}), - ("begin", {"input_type": "datedmy"}), - ("end", {"input_type": "datedmy"}), - ( - "justified", - {"input_type": "boolcheckbox", "default": 0, "title": "Justifiée"}, - ), - ("description", {"input_type": "textarea"}), - ), - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect(scu.ScoURL()) - else: - e = tf[2]["begin"].split("/") - begin = e[2] + "-" + e[1] + "-" + e[0] + " 00:00:00" - e = tf[2]["end"].split("/") - end = e[2] + "-" + e[1] + "-" + e[0] + " 00:00:00" - log( - AddBilletAbsence( - begin, - end, - tf[2]["description"], - etudid=etudid, - xml_reply=True, - justified=tf[2]["justified"], - ) - ) - return flask.redirect("billets_etud?etudid=" + str(etudid)) - - -@bp.route("/billets_etud/") -@scodoc -@permission_required(Permission.ScoView) -def billets_etud(etudid=False): - """Liste billets pour un etudiant""" - fmt = request.args.get("format", "html") - if not fmt in {"html", "json", "xml", "xls", "xlsx"}: - return ScoValueError("Format invalide") - table = sco_abs_billets.table_billets_etud(etudid) - if table: - return table.make_page(format=fmt) - return "" - - -# DEEPRECATED: pour compat anciens clients PHP -@bp.route("/XMLgetBilletsEtud", methods=["GET", "POST"]) -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def XMLgetBilletsEtud(etudid=False, code_nip=False): - """Liste billets pour un etudiant""" - log("Warning: called deprecated XMLgetBilletsEtud") - if etudid is False: - etud = Identite.query.filter_by( - code_nip=str(code_nip), dept_id=g.scodoc_dept_id - ).first_or_404() - etudid = etud.id - table = sco_abs_billets.table_billets_etud(etudid) - if table: - return table.make_page(format="xml") - return "" - - -@bp.route("/list_billets", methods=["GET"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def list_billets(): - """Page liste des billets non traités pour tous les étudiants du département - et formulaire recherche d'un billet. - """ - table = sco_abs_billets.table_billets_etud(etat=False) - T = table.html() - H = [ - html_sco_header.sco_header( - page_title="Billet d'absence non traités", - javascripts=["js/etud_info.js"], - init_qtip=True, - ), - f"

      Billets d'absence en attente de traitement ({table.get_nb_rows()})

      ", - ] - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - (("billet_id", {"input_type": "text", "title": "Numéro du billet :"}),), - method="get", - submitbutton=False, - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + T + html_sco_header.sco_footer() - else: - return flask.redirect( - url_for( - "absences.process_billet_absence_form", - billet_id=tf[2]["billet_id"], - scodoc_dept=g.scodoc_dept, - ) - ) - - -@bp.route("/delete_billets_absence", methods=["POST", "GET"]) -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def delete_billets_absence(billet_id, dialog_confirmed=False): - """Supprime un billet.""" - billet: BilletAbsence = ( - BilletAbsence.query.filter_by(id=billet_id) - .join(Identite) - .filter_by(dept_id=g.scodoc_dept_id) - .first_or_404() - ) - if not dialog_confirmed: - tab = sco_abs_billets.table_billets([billet]) - return scu.confirm_dialog( - """

      Supprimer ce billet ?

      """ + tab.html(), - dest_url="", - cancel_url="list_billets", - parameters={"billet_id": billet_id}, - ) - - db.session.delete(billet) - db.session.commit() - - flash("Billet supprimé") - return flask.redirect(url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)) - - -def _ProcessBilletAbsence( - billet: BilletAbsence, estjust: bool, description: str -) -> int: - """Traite un billet: ajoute absence(s) et éventuellement justificatifs, - et change l'état du billet à True. - return: nombre de demi-journées d'absence ajoutées, -1 si billet déjà traité. - NB: actuellement, les heures ne sont utilisées que pour déterminer - si matin et/ou après-midi. - """ - if billet.etat: - log(f"billet deja traite: {billet} !") - return -1 - n = 0 # nombre de demi-journées d'absence ajoutées - - # 1-- Ajout des absences (et justifs) - datedebut = billet.abs_begin.strftime("%d/%m/%Y") - datefin = billet.abs_end.strftime("%d/%m/%Y") - dates = sco_abs.DateRangeISO(datedebut, datefin) - # commence après-midi ? - if dates and billet.abs_begin.hour > 11: - sco_abs.add_absence( - billet.etudid, - dates[0], - 0, - estjust, - description=description, - ) - n += 1 - dates = dates[1:] - # termine matin ? - if dates and billet.abs_end.hour < 12: - sco_abs.add_absence( - billet.etudid, - dates[-1], - 1, - estjust, - description=description, - ) - n += 1 - dates = dates[:-1] - - for jour in dates: - sco_abs.add_absence( - billet.etudid, - jour, - 0, - estjust, - description=description, - ) - sco_abs.add_absence( - billet.etudid, - jour, - 1, - estjust, - description=description, - ) - n += 2 - - # 2- Change état du billet - billet.etat = True - db.session.add(billet) - db.session.commit() - return n - - -@bp.route("/process_billet_absence_form", methods=["POST", "GET"]) -@scodoc -@permission_required(Permission.ScoAbsChange) -@scodoc7func -def process_billet_absence_form(billet_id): - """Formulaire traitement d'un billet""" - billet: BilletAbsence = ( - BilletAbsence.query.filter_by(id=billet_id) - .join(Identite) - .filter_by(dept_id=g.scodoc_dept_id) - .first() - ) - if billet is None: - raise ScoValueError( - f"Aucun billet avec le numéro {billet_id} dans ce département.", - dest_url=url_for("absences.list_billets", scodoc_dept=g.scodoc_dept), - ) - etud = billet.etudiant - - H = [ - html_sco_header.sco_header( - page_title=f"Traitement billet d'absence de {etud.nomprenom}", - ), - f"""

      Traitement du billet {billet.id} : {etud.nomprenom}

      - """, - ] - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("billet_id", {"input_type": "hidden"}), - ( - "etudid", - {"input_type": "hidden"}, - ), - ( - "estjust", - {"input_type": "boolcheckbox", "title": "Absences justifiées"}, - ), - ("description", {"input_type": "text", "size": 42, "title": "Raison"}), - ), - initvalues={ - "description": billet.description or "", - "estjust": billet.justified, - "etudid": etud.id, - }, - submitlabel="Enregistrer ces absences", - ) - if tf[0] == 0: - tab = sco_abs_billets.table_billets([billet], etud=etud) - H.append(tab.html()) - if billet.justified: - H.append( - """

      L'étudiant pense pouvoir justifier cette absence.
      - Vérifiez le justificatif avant d'enregistrer.

      """ - ) - F = f"""

      Supprimer ce billet - (utiliser en cas d'erreur, par ex. billet en double) -

      -

      Liste de tous les billets en attente -

      - """ - - return "\n".join(H) + "
      " + tf[1] + F + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect(scu.ScoURL()) - else: - n = _ProcessBilletAbsence(billet, tf[2]["estjust"], tf[2]["description"]) - if tf[2]["estjust"]: - j = "justifiées" - else: - j = "non justifiées" - H.append('
      ') - if n > 0: - H.append("%d absences (1/2 journées) %s ajoutées" % (n, j)) - elif n == 0: - H.append("Aucun jour d'absence dans les dates indiquées !") - elif n < 0: - H.append("Ce billet avait déjà été traité !") - H.append( - f"""

      Autre billets en attente -

      -

      Billets déclarés par {etud.nomprenom}

      - """ - ) - billets = ( - BilletAbsence.query.filter_by(etudid=etud.id) - .join(Identite) - .filter_by(dept_id=g.scodoc_dept_id) - ) - tab = sco_abs_billets.table_billets(billets, etud=etud) - H.append(tab.html()) - return "\n".join(H) + html_sco_header.sco_footer() - - -# @bp.route("/essai_api7") -# @scodoc -# @permission_required_compat_scodoc7(Permission.ScoView) -# @scodoc7func -# def essai_api7(x="xxx"): -# "un essai" -# log("arfffffffffffffffffff") -# return "OK OK x=" + str(x) - - -@bp.route("/XMLgetAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def XMLgetAbsEtud(beg_date="", end_date=""): - """returns list of absences in date interval""" - t0 = time.time() - etuds = sco_etud.get_etud_info(filled=False) - if not etuds: - raise APIInvalidParams("étudiant inconnu") - # raise ScoValueError("étudiant inconnu") - etud = etuds[0] - exp = re.compile(r"^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$") - if not exp.match(beg_date): - raise ScoValueError("invalid date: %s" % beg_date) - if not exp.match(end_date): - raise ScoValueError("invalid date: %s" % end_date) - - abs_list = sco_abs.list_abs_date(etud["etudid"], beg_date, end_date) - - doc = ElementTree.Element( - "absences", etudid=str(etud["etudid"]), beg_date=beg_date, end_date=end_date - ) - for a in abs_list: - if a["estabs"]: # ne donne pas les justifications si pas d'absence - doc.append( - ElementTree.Element( - "abs", - begin=a["begin"], - end=a["end"], - description=a["description"], - justified=str(int(a["estjust"])), - ) - ) - log("XMLgetAbsEtud (%gs)" % (time.time() - t0)) - data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - return scu.send_file(data, mime=scu.XML_MIMETYPE, attached=False) +# -*- coding: utf-8 -*- +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +""" +Module absences: issu de ScoDoc7 / ZAbsences.py + +Emmanuel Viennet, 2021 + +Gestion des absences (v4) + +Code dérivé de la partie la plus ancienne de ScoDoc, et à revoir. + +L'API de plus bas niveau est en gros: + + AnnuleAbsencesDatesNoJust( dates) + count_abs(etudid, debut, fin, matin=None, moduleimpl_id=None) + count_abs_just(etudid, debut, fin, matin=None, moduleimpl_id=None) + list_abs_just(etudid, datedebut) [pas de fin ?] + list_abs_non_just(etudid, datedebut) [pas de fin ?] + list_abs_justifs(etudid, datedebut, datefin=None, only_no_abs=True) + + list_abs_jour(date, am=True, pm=True, is_abs=None, is_just=None) + list_abs_non_just_jour(date, am=True, pm=True) + +""" + +import calendar +import datetime +import dateutil +import dateutil.parser +import re +import time +import urllib +from xml.etree import ElementTree + +import flask +from flask import g, request +from flask import abort, flash, url_for +from flask_login import current_user + +from app import db, log +from app import api +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.decorators import ( + scodoc, + scodoc7func, + permission_required, + permission_required_compat_scodoc7, +) +from app.models import FormSemestre, GroupDescr, Partition +from app.models.absences import BilletAbsence +from app.models.etudiants import Identite +from app.views import absences_bp as bp + +# --------------- +from app.scodoc import sco_utils as scu +from app.scodoc import notesdb as ndb +from app.scodoc.scolog import logdb +from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_exceptions import ScoValueError, APIInvalidParams +from app.scodoc.TrivialFormulator import TrivialFormulator +from app.scodoc.gen_tables import GenTable +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_abs_billets +from app.scodoc import sco_abs_views +from app.scodoc import sco_etud +from app.scodoc import sco_find_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups_view +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_preferences +from app.scodoc import sco_xml + + +CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS + + +def sco_publish(route, function, permission, methods=["GET"]): + """Declare a route for a python function, + protected by permission and called following ScoDoc 7 Zope standards. + """ + return bp.route(route, methods=methods)( + scodoc(permission_required(permission)(scodoc7func(function))) + ) + + +# -------------------------------------------------------------------- +# +# ABSENCES (/ScoDoc//Scolarite/Absences/...) +# +# -------------------------------------------------------------------- + + +@bp.route("/") +@bp.route("/index_html") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def index_html(): + """Gestionnaire absences, page principale""" + + H = [ + html_sco_header.sco_header( + page_title="Saisie des absences", + cssstyles=["css/calabs.css"], + javascripts=["js/calabs.js"], + ), + """

      Traitement des absences

      +

      + Pour saisir des absences ou consulter les états, il est recommandé par passer par + le semestre concerné (saisie par jours nommés ou par semaines). +

      + """, + ] + H.append( + """

      Pour signaler, annuler ou justifier une absence pour un seul étudiant, + choisissez d'abord concerné:

      """ + ) + H.append(sco_find_etud.form_search_etud()) + if current_user.has_permission( + Permission.ScoAbsChange + ) and sco_preferences.get_preference("handle_billets_abs"): + H.append( + f""" +

      Billets d'absence

      + + """ + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +@bp.route("/choix_semaine") +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def choix_semaine(group_id): + """Page choix semaine sur calendrier pour saisie absences d'un groupe""" + group = ( + GroupDescr.query.filter_by(id=group_id) + .join(Partition) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + .first_or_404() + ) + H = [ + html_sco_header.sco_header( + page_title="Saisie des absences", + cssstyles=["css/calabs.css"], + javascripts=["js/calabs.js"], + ), + f""" +

      Saisie des Absences

      +
      +

      + + Saisie par semaine - Groupe: {group.get_nom_with_part()} + + +

      + """, + cal_select_week(), + """

      Sélectionner le groupe d'étudiants, puis cliquez sur une semaine pour + saisir les absences de toute cette semaine.

      +
      + """, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def cal_select_week(year=None): + "display calendar allowing week selection" + if not year: + year = scu.AnneeScolaire() + sems = sco_formsemestre.do_formsemestre_list() + if not sems: + js = "" + else: + js = 'onmouseover="highlightweek(this);" onmouseout="deselectweeks();" onclick="wclick(this);"' + C = sco_abs.YearTable(int(year), dayattributes=js) + return C + + +sco_publish("/EtatAbsences", sco_abs_views.EtatAbsences, Permission.ScoView) +sco_publish("/CalAbs", sco_abs_views.CalAbs, Permission.ScoView) +sco_publish( + "/SignaleAbsenceEtud", + sco_abs_views.SignaleAbsenceEtud, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/doSignaleAbsence", + sco_abs_views.doSignaleAbsence, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/JustifAbsenceEtud", + sco_abs_views.JustifAbsenceEtud, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/doJustifAbsence", + sco_abs_views.doJustifAbsence, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/AnnuleAbsenceEtud", + sco_abs_views.AnnuleAbsenceEtud, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/doAnnuleAbsence", + sco_abs_views.doAnnuleAbsence, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/doAnnuleJustif", + sco_abs_views.doAnnuleJustif, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) +sco_publish( + "/AnnuleAbsencesDatesNoJust", + sco_abs_views.AnnuleAbsencesDatesNoJust, + Permission.ScoAbsChange, + methods=["GET", "POST"], +) + +# Antédiluvienne fonction: #deprecated +@bp.route("/ListeAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def ListeAbsEtud( + etudid=None, + code_nip=None, + with_evals=True, + format="html", + absjust_only=0, + sco_year=None, +): + return sco_abs_views.ListeAbsEtud( + etudid=etudid, + code_nip=str(code_nip), + with_evals=with_evals, + format=format, + absjust_only=absjust_only, + sco_year=sco_year, + ) + + +# -------------------------------------------------------------------- +# +# SQL METHODS (xxx #sco8 not views => à déplacer) +# +# -------------------------------------------------------------------- + +# API backward compatibility +sco_publish("/CountAbs", sco_abs.count_abs, Permission.ScoView) +sco_publish("/CountAbsJust", sco_abs.count_abs_just, Permission.ScoView) +# TODO nouvel appel rendnat les deux valeurs et utilisant le cache + + +@bp.route("/doSignaleAbsenceGrSemestre", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def doSignaleAbsenceGrSemestre( + moduleimpl_id=None, + abslist=[], + dates="", + etudids="", + destination=None, +): + """Enregistre absences aux dates indiquees (abslist et dates). + dates est une liste de dates ISO (séparées par des ','). + Efface les absences aux dates indiquées par dates, + ou bien ajoute celles de abslist. + """ + moduleimpl_id = moduleimpl_id or None + if etudids: + etudids = [int(x) for x in str(etudids).split(",")] + else: + etudids = [] + if dates: + dates = dates.split(",") + else: + dates = [] + + # 1- Efface les absences + if dates: + for etudid in etudids: + sco_abs_views.AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id) + return "Absences effacées" + + # 2- Ajoute les absences + if abslist: + sco_abs.add_abslist(abslist, moduleimpl_id) + return "Absences ajoutées" + + return ("", 204) + + +# ------------ HTML Interfaces +@bp.route("/SignaleAbsenceGrHebdo", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def SignaleAbsenceGrHebdo( + datelundi, group_ids=[], destination="", moduleimpl_id=None, formsemestre_id=None +): + "Saisie hebdomadaire des absences" + if not moduleimpl_id: + moduleimpl_id = None + + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id + ) + if not groups_infos.members: + return ( + html_sco_header.sco_header(page_title="Saisie des absences") + + "

      Aucun étudiant !

      " + + html_sco_header.sco_footer() + ) + + base_url = "SignaleAbsenceGrHebdo?datelundi=%s&%s&destination=%s" % ( + datelundi, + groups_infos.groups_query_args, + urllib.parse.quote(destination), + ) + + formsemestre_id = groups_infos.formsemestre_id + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.dept_id != g.scodoc_dept_id: + abort(404, "groupes inexistants dans ce département") + require_module = sco_preferences.get_preference( + "abs_require_module", formsemestre_id + ) + etuds = [ + sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + for m in groups_infos.members + ] + # Restreint aux inscrits au module sélectionné + if moduleimpl_id: + mod_inscrits = set( + [ + x["etudid"] + for x in sco_moduleimpl.do_moduleimpl_inscription_list( + moduleimpl_id=moduleimpl_id + ) + ] + ) + etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits] + if etuds_inscrits_module: + etuds = etuds_inscrits_module + else: + # Si aucun etudiant n'est inscrit au module choisi... + moduleimpl_id = None + + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + sem = formsemestre.to_dict() + + # calcule dates jours de cette semaine + # liste de dates iso "yyyy-mm-dd" + datessem = [ndb.DateDMYtoISO(datelundi)] + for _ in sco_abs.day_names()[1:]: + datessem.append(sco_abs.next_iso_day(datessem[-1])) + # + if groups_infos.tous_les_etuds_du_sem: + gr_tit = "en" + else: + if len(groups_infos.group_ids) > 1: + p = "des groupes" + else: + p = "du groupe" + gr_tit = p + ' ' + groups_infos.groups_titles + "" + + H = [ + html_sco_header.sco_header( + page_title="Saisie hebdomadaire des absences", + init_qtip=True, + javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + + [ + "js/etud_info.js", + "js/abs_ajax.js", + "js/groups_view.js", + ], + cssstyles=CSSSTYLES, + no_side_bar=1, + ), + """
      +

      Saisie des absences %s %s, + semaine du lundi %s

      +
      +
      + + + + + Groupes: %s +
      +
      + """ + % ( + gr_tit, + sem["titre_num"], + datelundi, + groups_infos.formsemestre_id, + datelundi, + destination, + moduleimpl_id or "", + sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True), + ), + ] + # + modimpls_list = [] + ues = nt.get_ues_stat_dict() + for ue in ues: + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) + + menu_module = "" + for modimpl in modimpls_list: + if modimpl["moduleimpl_id"] == moduleimpl_id: + sel = "selected" + else: + sel = "" + menu_module += ( + """\n""" + % { + "modimpl_id": modimpl["moduleimpl_id"], + "modname": (modimpl["module"]["code"] or "") + + " " + + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or ""), + "sel": sel, + } + ) + if moduleimpl_id: + sel = "" + else: + sel = "selected" # aucun module specifie + + H.append( + """Module concerné: + +
      """ + % {"menu_module": menu_module, "url": base_url, "sel": sel} + ) + + H += _gen_form_saisie_groupe( + etuds, datessem, destination, moduleimpl_id, require_module + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +@bp.route("/SignaleAbsenceGrSemestre", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def SignaleAbsenceGrSemestre( + datedebut, + datefin, + destination="", + group_ids=(), # list of groups to display + nbweeks=4, # ne montre que les nbweeks dernieres semaines + moduleimpl_id=None, +): + """Saisie des absences sur une journée sur un semestre (ou intervalle de dates) entier""" + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + if not groups_infos.members: + return ( + html_sco_header.sco_header(page_title="Saisie des absences") + + "

      Aucun étudiant !

      " + + html_sco_header.sco_footer() + ) + formsemestre_id = groups_infos.formsemestre_id + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.dept_id != g.scodoc_dept_id: + return abort(404, "groupes inexistants dans ce département") + sem = formsemestre.to_dict() + require_module = sco_preferences.get_preference( + "abs_require_module", formsemestre_id + ) + etuds = [ + sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + for m in groups_infos.members + ] + # Restreint aux inscrits au module sélectionné + if moduleimpl_id: + mod_inscrits = set( + [ + x["etudid"] + for x in sco_moduleimpl.do_moduleimpl_inscription_list( + moduleimpl_id=moduleimpl_id + ) + ] + ) + etuds = [e for e in etuds if e["etudid"] in mod_inscrits] + if not moduleimpl_id: + moduleimpl_id = None + base_url_noweeks = ( + "SignaleAbsenceGrSemestre?datedebut=%s&datefin=%s&%s&destination=%s" + % ( + datedebut, + datefin, + groups_infos.groups_query_args, + urllib.parse.quote(destination), + ) + ) + base_url = base_url_noweeks + "&nbweeks=%s" % nbweeks # sans le moduleimpl_id + + if etuds: + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + work_saturday = sco_abs.is_work_saturday() + jourdebut = sco_abs.ddmmyyyy(datedebut, work_saturday=work_saturday) + jourfin = sco_abs.ddmmyyyy(datefin, work_saturday=work_saturday) + today = sco_abs.ddmmyyyy( + time.strftime("%d/%m/%Y", time.localtime()), + work_saturday=work_saturday, + ) + today.next_day() + if jourfin > today: # ne propose jamais les semaines dans le futur + jourfin = today + if jourdebut > today: + raise ScoValueError("date de début dans le futur (%s) !" % jourdebut) + # + if not jourdebut.iswork() or jourdebut > jourfin: + raise ValueError( + "date debut invalide (%s, ouvrable=%d)" + % (str(jourdebut), jourdebut.iswork()) + ) + # calcule dates + dates = [] # sco_abs.ddmmyyyy instances + d = sco_abs.ddmmyyyy(datedebut, work_saturday=work_saturday) + while d <= jourfin: + dates.append(d) + d = d.next_day(7) # avance d'une semaine + # + msg = "Montrer seulement les 4 dernières semaines" + nwl = 4 + if nbweeks: + nbweeks = int(nbweeks) + if nbweeks > 0: + dates = dates[-nbweeks:] + msg = "Montrer toutes les semaines" + nwl = 0 + url_link_semaines = base_url_noweeks + "&nbweeks=%s" % nwl + if moduleimpl_id: + url_link_semaines += "&moduleimpl_id=" + str(moduleimpl_id) + # + dates = [x.ISO() for x in dates] + day_name = sco_abs.day_names()[jourdebut.weekday] + + if groups_infos.tous_les_etuds_du_sem: + gr_tit = "en" + else: + if len(groups_infos.group_ids) > 1: + p = "des groupes " + else: + p = "du groupe " + gr_tit = p + '' + groups_infos.groups_titles + "" + + H = [ + html_sco_header.sco_header( + page_title=f"Saisie des absences du {day_name}", + init_qtip=True, + javascripts=["js/etud_info.js", "js/abs_ajax.js"], + no_side_bar=1, + ), + f"""
      +

      Saisie des absences {gr_tit} {sem["titre_num"]}, + les {day_name}s

      +

      + {msg} + + """, + ] + # + if etuds: + modimpls_list = [] + ues = nt.get_ues_stat_dict() + for ue in ues: + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) + + menu_module = "" + for modimpl in modimpls_list: + if modimpl["moduleimpl_id"] == moduleimpl_id: + sel = "selected" + else: + sel = "" + menu_module += ( + """\n""" + % { + "modimpl_id": modimpl["moduleimpl_id"], + "modname": (modimpl["module"]["code"] or "") + + " " + + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or ""), + "sel": sel, + } + ) + if moduleimpl_id: + sel = "" + else: + sel = "selected" # aucun module specifie + H.append( + """

      +Module concerné par ces absences (%(optionel_txt)s): + +

      """ + % { + "menu_module": menu_module, + "url": base_url, + "sel": sel, + "optionel_txt": 'requis' + if require_module + else "optionnel", + } + ) + + H += _gen_form_saisie_groupe( + etuds, dates, destination, moduleimpl_id, require_module + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def _gen_form_saisie_groupe( + etuds, dates, destination="", moduleimpl_id=None, require_module=False +): + """Formulaire saisie absences + + Args: + etuds: liste des étudiants + dates: liste ordonnée de dates iso, par exemple: [ '2020-12-24', ... ] + moduleimpl_id: optionnel, module concerné. + """ + H = [ + """ + +
      +
      + + + """ + % ( + "true" if (require_module and not moduleimpl_id) else "false", + len(etuds), + ) + ] + # Dates + odates = [datetime.date(*[int(x) for x in d.split("-")]) for d in dates] + begin = dates[0] + end = dates[-1] + # Titres colonnes + noms_jours = [] # eg [ "Lundi", "mardi", "Samedi", ... ] + jn = sco_abs.day_names() + for d in odates: + idx_jour = d.weekday() + noms_jours.append(jn[idx_jour]) + for jour in noms_jours: + H.append( + '" + ) + H.append("") + for d in odates: + H.append( + '" + ) + H.append("") + H.append("" * len(dates)) + H.append("") + # + if not etuds: + H.append( + '' + ) + i = 1 + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for etud in etuds: + i += 1 + etudid = etud["etudid"] + etud_class = "etudinfo" # css + # UE capitalisee dans semestre courant ? + cap = [] + if etud["cursem"]: + formsemestre = FormSemestre.query.get_or_404( + etud["cursem"]["formsemestre_id"] + ) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + for ue in nt.get_ues_stat_dict(): + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status and ue_status["is_capitalized"]: + cap.append(ue["acronyme"]) + if cap: + capstr = ' (%s cap.)' % ", ".join(cap) + else: + capstr = "" + if etud["etatincursem"] == "D": + capstr += ' (dém.)' + etud_class += " etuddem" + tr_class = ("row_1", "row_2", "row_3")[i % 3] + td_matin_class = ("matin_1", "matin_2", "matin_3")[i % 3] + + H.append( + '' + % ( + tr_class, + etud_class, + etudid, + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + etud["nomprenom"], + capstr, + ) + ) + etud_abs = sco_abs.list_abs_in_range( + etudid, begin, end, moduleimpl_id=moduleimpl_id, cursor=cursor + ) + for d in odates: + date = d.strftime("%Y-%m-%d") + # matin + is_abs = {"jour": d, "matin": True} in etud_abs + if is_abs: + checked = "checked" + else: + checked = "" + # bulle lors du passage souris + coljour = sco_abs.DAYNAMES[(calendar.weekday(d.year, d.month, d.day))] + datecol = coljour + " " + d.strftime("%d/%m/%Y") + bulle_am = '"' + etud["nomprenom"] + " - " + datecol + ' (matin)"' + bulle_pm = '"' + etud["nomprenom"] + " - " + datecol + ' (ap.midi)"' + + H.append( + '' + % ( + td_matin_class, + bulle_am, + str(etudid) + ":" + date + ":" + "am", + checked, + etudid, + date + ":am", + ) + ) + # après-midi + is_abs = {"jour": d, "matin": False} in etud_abs + if is_abs: + checked = "checked" + else: + checked = "" + H.append( + '' + % ( + bulle_pm, + str(etudid) + ":" + date + ":" + "pm", + checked, + etudid, + date + ":pm", + ) + ) + H.append("") + H.append("
      %d étudiants' + + jour + + "
       ' + + d.strftime("%d/%m/%Y") + + "
       AMPM
      Aucun étudiant inscrit !
      %s%s
      ") + # place la liste des etudiants et les dates pour pouvoir effacer les absences + H.append( + '' + % ",".join([str(etud["etudid"]) for etud in etuds]) + ) + H.append('' % dates[0]) + H.append('' % dates[-1]) + H.append('' % ",".join(dates)) + H.append( + '' + % urllib.parse.quote(destination) + ) + # + # version pour formulaire avec AJAX (Yann LB) + H.append( + """ +

      + +

      +
      +

      Les cases cochées correspondent à des absences. + Les absences saisies ne sont pas justifiées (sauf si un justificatif a été entré + par ailleurs). +

      Si vous "décochez" une case, l'absence correspondante sera supprimée. + Attention, les modifications sont automatiquement entregistrées au fur et à mesure. +

      + """ + ) + return H + + +@bp.route("/EtatAbsencesGr") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func # ported from dtml +def EtatAbsencesGr( + group_ids=[], # list of groups to display + debut="", + fin="", + with_boursier=True, # colonne boursier + format="html", +): + """Liste les absences de groupes""" + datedebut = ndb.DateDMYtoISO(debut) + datefin = ndb.DateDMYtoISO(fin) + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + formsemestre_id = groups_infos.formsemestre_id + sem = groups_infos.formsemestre + + # Construit tableau (etudid, statut, nomprenom, nbJust, nbNonJust, NbTotal) + T = [] + for m in groups_infos.members: + etud = sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + nbabs = sco_abs.count_abs(etudid=etud["etudid"], debut=datedebut, fin=datefin) + nbabsjust = sco_abs.count_abs_just( + etudid=etud["etudid"], debut=datedebut, fin=datefin + ) + nbjustifs_noabs = len( + sco_abs.list_abs_justifs( + etudid=etud["etudid"], + datedebut=datedebut, + datefin=datefin, + only_no_abs=True, + ) + ) + # retrouve sem dans etud['sems'] + s = None + for s in etud["sems"]: + if s["formsemestre_id"] == formsemestre_id: + break + if not s or s["formsemestre_id"] != formsemestre_id: + raise ValueError( + "EtatAbsencesGr: can't retreive sem" + ) # bug or malicious arg + T.append( + { + "etudid": etud["etudid"], + "etatincursem": s["ins"]["etat"], + "nomprenom": etud["nomprenom"], + "nbabsjust": nbabsjust, + "nbabsnonjust": nbabs - nbabsjust, + "nbabs": nbabs, + "nbjustifs_noabs": nbjustifs_noabs, + "_nomprenom_target": "CalAbs?etudid=%s" % etud["etudid"], + "_nomprenom_td_attrs": 'id="%s" class="etudinfo"' % etud["etudid"], + "boursier": "oui" if etud["boursier"] else "non", + } + ) + if s["ins"]["etat"] == "D": + T[-1]["_css_row_class"] = "etuddem" + T[-1]["nomprenom"] += " (dem)" + columns_ids = [ + "nomprenom", + "nbjustifs_noabs", + "nbabsjust", + "nbabsnonjust", + "nbabs", + ] + if with_boursier: + columns_ids[1:1] = ["boursier"] + if groups_infos.tous_les_etuds_du_sem: + gr_tit = "" + else: + if len(groups_infos.group_ids) > 1: + p = "des groupes" + else: + p = "du groupe" + if format == "html": + h = ' ' + groups_infos.groups_titles + "" + else: + h = groups_infos.groups_titles + gr_tit = p + h + + title = f"État des absences {gr_tit}" + if format == "xls" or format == "xml" or format == "json": + columns_ids = ["etudid"] + columns_ids + # --- Formulaire choix dates début / fin + form_date = ( + f""" +
      + + Période du + +  au  + + +   + (nombre de demi-journées) +
      """ + + """ + + """ + ) + tab = GenTable( + columns_ids=columns_ids, + rows=T, + preferences=sco_preferences.SemPreferences(formsemestre_id), + titles={ + "etatincursem": "Etat", + "nomprenom": "Nom", + "nbabsjust": "Justifiées", + "nbabsnonjust": "Non justifiées", + "nbabs": "Total", + "nbjustifs_noabs": "Justifs non utilisés", + "boursier": "Bourse", + }, + html_sortable=True, + html_class="table_leftalign", + html_header=html_sco_header.sco_header( + page_title=title, + init_qtip=True, + javascripts=["js/etud_info.js"], + ), + html_title=html_sco_header.html_sem_header(title, with_page_header=False) + + form_date, + # "

      Période du %s au %s (nombre de demi-journées)
      " % (debut, fin), + base_url="%s&formsemestre_id=%s&debut=%s&fin=%s" + % (groups_infos.base_url, formsemestre_id, debut, fin), + filename="etat_abs_" + + scu.make_filename( + "%s de %s" % (groups_infos.groups_filename, sem["titreannee"]) + ), + caption=title, + html_next_section="""

      +

      +Justifs non utilisés: nombre de demi-journées avec justificatif mais sans absences relevées. +

      +

      +Cliquez sur un nom pour afficher le calendrier des absences
      +ou entrez une date pour visualiser les absents un jour donné : +

      +
      +
      + +%s + + +
      + """ + % (request.base_url, formsemestre_id, groups_infos.get_form_elem()), + ) + return tab.make_page(format=format) + + +@bp.route("/EtatAbsencesDate") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def EtatAbsencesDate(group_ids=[], date=None): # list of groups to display + # ported from dtml + """Etat des absences pour un groupe à une date donnée""" + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + H = [html_sco_header.sco_header(page_title="Etat des absences")] + if date: + dateiso = ndb.DateDMYtoISO(date) + nbetud = 0 + t_nbabsjustam = 0 + t_nbabsam = 0 + t_nbabsjustpm = 0 + t_nbabspm = 0 + H.append("

      État des absences le %s

      " % date) + H.append( + """ + + + """ + ) + for etud in groups_infos.members: + nbabsam = sco_abs.count_abs( + etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=1 + ) + nbabspm = sco_abs.count_abs( + etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=0 + ) + if (nbabsam != 0) or (nbabspm != 0): + nbetud += 1 + nbabsjustam = sco_abs.count_abs_just( + etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=1 + ) + nbabsjustpm = sco_abs.count_abs_just( + etudid=etud["etudid"], debut=dateiso, fin=dateiso, matin=0 + ) + H.append( + """") + H.append( + """""" + % (t_nbabsam, t_nbabsjustam, t_nbabspm, t_nbabsjustpm) + ) + H.append("
       MatinAprès-midi
      + %(nomprenom)s""" + % etud + ) # """ + if nbabsam != 0: + if nbabsjustam: + H.append("Just.") + t_nbabsjustam += 1 + else: + H.append("Abs.") + t_nbabsam += 1 + else: + H.append("") + H.append('') + if nbabspm != 0: + if nbabsjustpm: + H.append("Just.") + t_nbabsjustam += 1 + else: + H.append("Abs.") + t_nbabspm += 1 + else: + H.append("") + H.append("
      %d abs, %d just.%d abs, %d just.
      ") + if nbetud == 0: + H.append("

      Aucune absence !

      ") + else: + H.append( + """

      Erreur: vous n'avez pas choisi de date !

      + """ + ) + + return "\n".join(H) + html_sco_header.sco_footer() + + +# ----- Gestion des "billets d'absence": signalement par les etudiants eux mêmes (à travers le portail) +@bp.route("/AddBilletAbsence", methods=["GET", "POST"]) # API ScoDoc 7 compat +@scodoc +@permission_required_compat_scodoc7(Permission.ScoAbsAddBillet) +@scodoc7func +def AddBilletAbsence( + begin, + end, + description, + etudid=None, + code_nip=None, + code_ine=None, + justified=True, + format="json", + xml_reply=True, # deprecated +): + """Mémorise un "billet" + begin et end sont au format ISO (eg "1999-01-08 04:05:06") + """ + log("Warning: calling deprecated AddBilletAbsence") + begin = str(begin) + end = str(end) + code_nip = str(code_nip) if code_nip else None + + etud = api.tools.get_etud(etudid=etudid, nip=code_nip, ine=code_ine) + # check dates + begin_date = dateutil.parser.isoparse(begin) # may raises ValueError + end_date = dateutil.parser.isoparse(end) + if begin_date > end_date: + raise ValueError("invalid dates") + # + justified = bool(justified) + xml_reply = bool(xml_reply) + if xml_reply: # backward compat + format = "xml" + # + billet = BilletAbsence( + etudid=etud.id, + abs_begin=begin, + abs_end=end, + description=description, + etat=False, + justified=justified, + ) + db.session.add(billet) + db.session.commit() + + # Renvoie le nouveau billet au format demandé + table = sco_abs_billets.table_billets([billet], etud=etud) + log(f"AddBilletAbsence: new billet_id={billet.id}") + return table.make_page(format=format) + + +@bp.route("/add_billets_absence_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoAbsAddBillet) +@scodoc7func +def add_billets_absence_form(etudid): + """Formulaire ajout billet (pour tests seulement, le vrai + formulaire accessible aux etudiants étant sur le portail étudiant). + """ + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + H = [ + html_sco_header.sco_header( + page_title="Billet d'absence de %s" % etud["nomprenom"] + ) + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("etudid", {"input_type": "hidden"}), + ("begin", {"input_type": "datedmy"}), + ("end", {"input_type": "datedmy"}), + ( + "justified", + {"input_type": "boolcheckbox", "default": 0, "title": "Justifiée"}, + ), + ("description", {"input_type": "textarea"}), + ), + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect(scu.ScoURL()) + else: + e = tf[2]["begin"].split("/") + begin = e[2] + "-" + e[1] + "-" + e[0] + " 00:00:00" + e = tf[2]["end"].split("/") + end = e[2] + "-" + e[1] + "-" + e[0] + " 00:00:00" + log( + AddBilletAbsence( + begin, + end, + tf[2]["description"], + etudid=etudid, + xml_reply=True, + justified=tf[2]["justified"], + ) + ) + return flask.redirect("billets_etud?etudid=" + str(etudid)) + + +@bp.route("/billets_etud/") +@scodoc +@permission_required(Permission.ScoView) +def billets_etud(etudid=False): + """Liste billets pour un etudiant""" + fmt = request.args.get("format", "html") + if not fmt in {"html", "json", "xml", "xls", "xlsx"}: + return ScoValueError("Format invalide") + table = sco_abs_billets.table_billets_etud(etudid) + if table: + return table.make_page(format=fmt) + return "" + + +# DEEPRECATED: pour compat anciens clients PHP +@bp.route("/XMLgetBilletsEtud", methods=["GET", "POST"]) +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def XMLgetBilletsEtud(etudid=False, code_nip=False): + """Liste billets pour un etudiant""" + log("Warning: called deprecated XMLgetBilletsEtud") + if etudid is False: + etud = Identite.query.filter_by( + code_nip=str(code_nip), dept_id=g.scodoc_dept_id + ).first_or_404() + etudid = etud.id + table = sco_abs_billets.table_billets_etud(etudid) + if table: + return table.make_page(format="xml") + return "" + + +@bp.route("/list_billets", methods=["GET"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def list_billets(): + """Page liste des billets non traités pour tous les étudiants du département + et formulaire recherche d'un billet. + """ + table = sco_abs_billets.table_billets_etud(etat=False) + T = table.html() + H = [ + html_sco_header.sco_header( + page_title="Billet d'absence non traités", + javascripts=["js/etud_info.js"], + init_qtip=True, + ), + f"

      Billets d'absence en attente de traitement ({table.get_nb_rows()})

      ", + ] + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + (("billet_id", {"input_type": "text", "title": "Numéro du billet :"}),), + method="get", + submitbutton=False, + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + T + html_sco_header.sco_footer() + else: + return flask.redirect( + url_for( + "absences.process_billet_absence_form", + billet_id=tf[2]["billet_id"], + scodoc_dept=g.scodoc_dept, + ) + ) + + +@bp.route("/delete_billets_absence", methods=["POST", "GET"]) +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def delete_billets_absence(billet_id, dialog_confirmed=False): + """Supprime un billet.""" + billet: BilletAbsence = ( + BilletAbsence.query.filter_by(id=billet_id) + .join(Identite) + .filter_by(dept_id=g.scodoc_dept_id) + .first_or_404() + ) + if not dialog_confirmed: + tab = sco_abs_billets.table_billets([billet]) + return scu.confirm_dialog( + """

      Supprimer ce billet ?

      """ + tab.html(), + dest_url="", + cancel_url="list_billets", + parameters={"billet_id": billet_id}, + ) + + db.session.delete(billet) + db.session.commit() + + flash("Billet supprimé") + return flask.redirect(url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)) + + +def _ProcessBilletAbsence( + billet: BilletAbsence, estjust: bool, description: str +) -> int: + """Traite un billet: ajoute absence(s) et éventuellement justificatifs, + et change l'état du billet à True. + return: nombre de demi-journées d'absence ajoutées, -1 si billet déjà traité. + NB: actuellement, les heures ne sont utilisées que pour déterminer + si matin et/ou après-midi. + """ + if billet.etat: + log(f"billet deja traite: {billet} !") + return -1 + n = 0 # nombre de demi-journées d'absence ajoutées + + # 1-- Ajout des absences (et justifs) + datedebut = billet.abs_begin.strftime("%d/%m/%Y") + datefin = billet.abs_end.strftime("%d/%m/%Y") + dates = sco_abs.DateRangeISO(datedebut, datefin) + # commence après-midi ? + if dates and billet.abs_begin.hour > 11: + sco_abs.add_absence( + billet.etudid, + dates[0], + 0, + estjust, + description=description, + ) + n += 1 + dates = dates[1:] + # termine matin ? + if dates and billet.abs_end.hour < 12: + sco_abs.add_absence( + billet.etudid, + dates[-1], + 1, + estjust, + description=description, + ) + n += 1 + dates = dates[:-1] + + for jour in dates: + sco_abs.add_absence( + billet.etudid, + jour, + 0, + estjust, + description=description, + ) + sco_abs.add_absence( + billet.etudid, + jour, + 1, + estjust, + description=description, + ) + n += 2 + + # 2- Change état du billet + billet.etat = True + db.session.add(billet) + db.session.commit() + return n + + +@bp.route("/process_billet_absence_form", methods=["POST", "GET"]) +@scodoc +@permission_required(Permission.ScoAbsChange) +@scodoc7func +def process_billet_absence_form(billet_id): + """Formulaire traitement d'un billet""" + billet: BilletAbsence = ( + BilletAbsence.query.filter_by(id=billet_id) + .join(Identite) + .filter_by(dept_id=g.scodoc_dept_id) + .first() + ) + if billet is None: + raise ScoValueError( + f"Aucun billet avec le numéro {billet_id} dans ce département.", + dest_url=url_for("absences.list_billets", scodoc_dept=g.scodoc_dept), + ) + etud = billet.etudiant + + H = [ + html_sco_header.sco_header( + page_title=f"Traitement billet d'absence de {etud.nomprenom}", + ), + f"""

      Traitement du billet {billet.id} : {etud.nomprenom}

      + """, + ] + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("billet_id", {"input_type": "hidden"}), + ( + "etudid", + {"input_type": "hidden"}, + ), + ( + "estjust", + {"input_type": "boolcheckbox", "title": "Absences justifiées"}, + ), + ("description", {"input_type": "text", "size": 42, "title": "Raison"}), + ), + initvalues={ + "description": billet.description or "", + "estjust": billet.justified, + "etudid": etud.id, + }, + submitlabel="Enregistrer ces absences", + ) + if tf[0] == 0: + tab = sco_abs_billets.table_billets([billet], etud=etud) + H.append(tab.html()) + if billet.justified: + H.append( + """

      L'étudiant pense pouvoir justifier cette absence.
      + Vérifiez le justificatif avant d'enregistrer.

      """ + ) + F = f"""

      Supprimer ce billet + (utiliser en cas d'erreur, par ex. billet en double) +

      +

      Liste de tous les billets en attente +

      + """ + + return "\n".join(H) + "
      " + tf[1] + F + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect(scu.ScoURL()) + else: + n = _ProcessBilletAbsence(billet, tf[2]["estjust"], tf[2]["description"]) + if tf[2]["estjust"]: + j = "justifiées" + else: + j = "non justifiées" + H.append('
      ') + if n > 0: + H.append("%d absences (1/2 journées) %s ajoutées" % (n, j)) + elif n == 0: + H.append("Aucun jour d'absence dans les dates indiquées !") + elif n < 0: + H.append("Ce billet avait déjà été traité !") + H.append( + f"""

      Autre billets en attente +

      +

      Billets déclarés par {etud.nomprenom}

      + """ + ) + billets = ( + BilletAbsence.query.filter_by(etudid=etud.id) + .join(Identite) + .filter_by(dept_id=g.scodoc_dept_id) + ) + tab = sco_abs_billets.table_billets(billets, etud=etud) + H.append(tab.html()) + return "\n".join(H) + html_sco_header.sco_footer() + + +# @bp.route("/essai_api7") +# @scodoc +# @permission_required_compat_scodoc7(Permission.ScoView) +# @scodoc7func +# def essai_api7(x="xxx"): +# "un essai" +# log("arfffffffffffffffffff") +# return "OK OK x=" + str(x) + + +@bp.route("/XMLgetAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def XMLgetAbsEtud(beg_date="", end_date=""): + """returns list of absences in date interval""" + t0 = time.time() + etuds = sco_etud.get_etud_info(filled=False) + if not etuds: + raise APIInvalidParams("étudiant inconnu") + # raise ScoValueError("étudiant inconnu") + etud = etuds[0] + exp = re.compile(r"^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$") + if not exp.match(beg_date): + raise ScoValueError("invalid date: %s" % beg_date) + if not exp.match(end_date): + raise ScoValueError("invalid date: %s" % end_date) + + abs_list = sco_abs.list_abs_date(etud["etudid"], beg_date, end_date) + + doc = ElementTree.Element( + "absences", etudid=str(etud["etudid"]), beg_date=beg_date, end_date=end_date + ) + for a in abs_list: + if a["estabs"]: # ne donne pas les justifications si pas d'absence + doc.append( + ElementTree.Element( + "abs", + begin=a["begin"], + end=a["end"], + description=a["description"], + justified=str(int(a["estjust"])), + ) + ) + log("XMLgetAbsEtud (%gs)" % (time.time() - t0)) + data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) + return scu.send_file(data, mime=scu.XML_MIMETYPE, attached=False) diff --git a/app/views/notes.py b/app/views/notes.py index 30b10b38..ae42f487 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -852,7 +852,7 @@ def formsemestre_change_lock(formsemestre_id, dialog_confirmed=False): helpmsg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées. Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment (par son responsable ou un administrateur). -
      +
      Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié. """, dest_url="", @@ -3045,12 +3045,12 @@ def check_sem_integrity(formsemestre_id, fix=False): if bad_ue: H += [ "

      Modules d'une autre formation que leur UE:

      ", - "
      ".join([str(x) for x in bad_ue]), + "
      ".join([str(x) for x in bad_ue]), ] if bad_sem: H += [ "

      Module du semestre dans une autre formation:

      ", - "
      ".join([str(x) for x in bad_sem]), + "
      ".join([str(x) for x in bad_sem]), ] if not bad_ue and not bad_sem: H.append("

      Aucun problème à signaler !

      ") @@ -3106,7 +3106,7 @@ def check_form_integrity(formation_id, fix=False): if mod["formation_id"] != formation_id: bad.append(mod) if bad: - txth = "
      ".join([str(x) for x in bad]) + txth = "
      ".join([str(x) for x in bad]) txt = "\n".join([str(x) for x in bad]) log("check_form_integrity: formation_id=%s\ninconsistencies:" % formation_id) log(txt) @@ -3162,7 +3162,7 @@ def check_formsemestre_integrity(formsemestre_id): diag = ["OK"] log("ok") return ( - html_sco_header.sco_header() + "
      ".join(diag) + html_sco_header.sco_footer() + html_sco_header.sco_header() + "
      ".join(diag) + html_sco_header.sco_footer() ) diff --git a/app/views/scolar.py b/app/views/scolar.py index 58211d7a..7b1079e9 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -2141,9 +2141,9 @@ def form_students_import_infos_admissions(formsemestre_id=None): Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés, les autres lignes de la feuille seront ignorées. Et seules les colonnes intéressant ScoDoc seront importées: il est inutile d'éliminer les autres. -
      +
      Seules les données "admission" seront modifiées (et pas l'identité de l'étudiant). -
      +
      Les colonnes "nom" et "prenom" sont requises, ou bien une colonne "etudid".

      @@ -2240,7 +2240,7 @@ def formsemestre_import_etud_admission(formsemestre_id, import_email=True): H.append("

      Adresses mails modifiées:

      ") for (info, new_mail) in changed_mails: H.append( - "%s: %s devient %s
      " + "%s: %s devient %s
      " % (info["nom"], info["email"], new_mail) ) return "\n".join(H) + html_sco_header.sco_footer() diff --git a/app/views/users.py b/app/views/users.py index 982d6276..8c726f1a 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -1,1003 +1,1003 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# ScoDoc -# -# Copyright (c) 1999 - 2022 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 -# -############################################################################## - -""" -Module users: interface gestion utilisateurs -ré-écriture pour Flask ScoDoc7 / ZScoUsers.py - -Vues s'appuyant sur auth et sco_users - -Emmanuel Viennet, 2021 -""" -import datetime -import re -from enum import auto, IntEnum -from xml.etree import ElementTree - -import flask -from flask import g, url_for, request, current_app, flash -from flask import redirect, render_template -from flask_login import current_user -from flask_wtf import FlaskForm - -from wtforms import HiddenField, PasswordField, StringField, SubmitField -from wtforms.validators import DataRequired, Email, ValidationError, EqualTo - -from app import db -from app.auth.forms import DeactivateUserForm -from app.auth.models import Permission -from app.auth.models import User -from app.auth.models import Role -from app.auth.models import UserRole -from app.auth.models import is_valid_password -from app.email import send_email -from app.models import Departement - -from app.decorators import ( - scodoc, - scodoc7func, - permission_required, -) - -from app.scodoc import html_sco_header, sco_import_users, sco_roles_default -from app.scodoc import sco_users -from app.scodoc import sco_utils as scu -from app.scodoc import sco_xml -from app import log -from app.scodoc.sco_exceptions import AccessDenied, ScoPermissionDenied, ScoValueError -from app.scodoc.sco_import_users import generate_password -from app.scodoc.sco_permissions_check import can_handle_passwd -from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message -from app.views import users_bp as bp - -_ = lambda x: x # sans babel -_l = _ - - -class ChangePasswordForm(FlaskForm): - """formulaire changement mot de passe et mail""" - - user_name = HiddenField() - old_password = PasswordField(_l("Identifiez-vous")) - new_password = PasswordField(_l("Nouveau mot de passe de l'utilisateur")) - bis_password = PasswordField( - _l("Répéter"), - validators=[ - EqualTo( - "new_password", - message="Les deux saisies sont " "différentes, recommencez", - ), - ], - ) - email = StringField( - _l("Email"), - validators=[ - DataRequired(), - Email(message="adresse email invalide, recommencez"), - ], - ) - submit = SubmitField() - cancel = SubmitField("Annuler") - - def validate_email(self, email): - "vérifie que le mail est unique" - user = User.query.filter_by(email=email.data.strip()).first() - if user is not None and self.user_name.data != user.user_name: - raise ValidationError( - _("Cette adresse e-mail est déjà attribuée à un autre compte") - ) - - def validate_new_password(self, new_password): - "vérifie que le mot de passe est acceptable" - if new_password.data != "" and not is_valid_password(new_password.data): - raise ValidationError("Mot de passe trop simple, recommencez") - - def validate_old_password(self, old_password): - if not current_user.check_password(old_password.data): - raise ValidationError("Mot de passe actuel incorrect, ré-essayez") - - -class Mode(IntEnum): - WELCOME_AND_CHANGE_PASSWORD = auto() - WELCOME_ONLY = auto() - SILENT = auto() - - -@bp.route("/") -@bp.route("/index_html") -@scodoc -@permission_required(Permission.ScoUsersView) -@scodoc7func -def index_html(all_depts=False, with_inactives=False, format="html"): - "Page accueil utilisateur: tableau avec liste des comptes" - return sco_users.index_html( - all_depts=all_depts, - with_inactives=with_inactives, - format=format, - ) - - -def _get_administrable_depts() -> list[str]: - """Liste des acronymes des départements dans lesquels l'utilisateur - courant peut administrer des utilisateurs. - Si SuperAdmin, tous les départements - Sinon, les départements dans lesquels l'utilisateur a la permission ScoUsersAdmin - """ - # - if current_user.is_administrator(): - log(f"create_user_form called by {current_user.user_name} (super admin)") - administrable_dept_acronyms = sorted( - [d.acronym for d in Departement.query.all()] - ) - else: - administrable_dept_acronyms = current_user.get_depts_with_permission( - Permission.ScoUsersAdmin - ) - if None in administrable_dept_acronyms: - administrable_dept_acronyms = sorted( - [d.acronym for d in Departement.query.all()] - ) - - return administrable_dept_acronyms - - -def _get_editable_roles( - administrable_dept_acronyms: list = None, all_roles=True -) -> set[tuple[Role, str]]: - """Rôles modifiables: ensemble de tuples (role, dept_acronym) - (où dept_acronym est None si tous dept.) - - Si all_roles, tous les rôles définis et modifiables par l'utilisateurs. - Sinon, seulement les rôles "standards" de ScoDoc. - """ - if all_roles: - # tous sauf SuperAdmin - roles = [ - r - for r in Role.query.all() - if r.permissions != Permission.ALL_PERMISSIONS[0] - ] - else: - # Les rôles standards créés à l'initialisation de ScoDoc: - roles = [ - Role.get_named_role(r) for r in sco_roles_default.ROLES_ATTRIBUABLES_DEPT - ] - - # Génère toutes les combinaisons roles/départements - editable_roles_set = { - (r, dept) for r in roles for dept in administrable_dept_acronyms - } - if current_user.is_administrator(): - editable_roles_set |= { - (Role.get_named_role(r), None) - for r in sco_roles_default.ROLES_ATTRIBUABLES_SCODOC - } - # Un super-admin peut nommer d'autres super-admin: - editable_roles_set |= {(Role.get_named_role("SuperAdmin"), None)} - return editable_roles_set - - -@bp.route("/create_user_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoUsersAdmin) -@scodoc7func -def create_user_form(user_name=None, edit=0, all_roles=True): - "form. création ou édition utilisateur" - if user_name is not None: # scodoc7func converti en int ! - user_name = str(user_name) - Role.ensure_standard_roles() # assure la présence des rôles en base - auth_dept = current_user.dept - from_mail = current_app.config["SCODOC_MAIL_FROM"] # current_user.email - initvalues = {} - edit = int(edit) - all_roles = int(all_roles) - H = [ - html_sco_header.sco_header( - bodyOnLoad="init_tf_form('')", - javascripts=["js/user_form.js"], - ) - ] - F = html_sco_header.sco_footer() - the_user: User = None - if edit: - if not user_name: - raise ValueError("missing argument: user_name") - the_user = User.query.filter_by(user_name=user_name).first() - if not the_user: - raise ScoValueError("utilisateur inexistant") - initvalues = the_user.to_dict() - H.append(f"

      Modification de l'utilisateur {user_name}

      ") - submitlabel = "Modifier utilisateur" - if "roles_string" in initvalues: - initvalues["roles"] = initvalues["roles_string"].split(",") - else: - initvalues["roles"] = [] - if "date_expiration" in initvalues: - initvalues["date_expiration"] = ( - the_user.date_expiration.strftime("%d/%m/%Y") - if the_user.date_expiration - else "" - ) - initvalues["status"] = "" if the_user.active else "old" - orig_roles = { # set des roles existants avant édition - UserRole.role_dept_from_string(role_dept) - for role_dept in initvalues["roles"] - if role_dept - } - if not initvalues["active"]: - editable_roles_set = set() # can't change roles of a disabled user - else: - H.append("

      Création d'un utilisateur

      ") - submitlabel = "Créer utilisateur" - orig_roles = set() - - is_super_admin = current_user.is_administrator() - if is_super_admin: - H.append("""

      Vous êtes super administrateur !

      """) - - administrable_dept_acronyms = _get_administrable_depts() - if edit: - if the_user.dept is None: # seul le super admin peut le toucher - edit_only_roles = not current_user.is_administrator() - else: - edit_only_roles = the_user.dept not in administrable_dept_acronyms - else: - edit_only_roles = False # création nouvel utilisateur - editable_roles_set = _get_editable_roles( - administrable_dept_acronyms, all_roles=all_roles - ) - editable_roles_strings = { - r.name + "_" + (dept or "") for (r, dept) in editable_roles_set - } - orig_roles_strings = {r.name + "_" + (dept or "") for (r, dept) in orig_roles} - # add existing user roles - displayed_roles = list(editable_roles_set.union(orig_roles)) - displayed_roles.sort( - key=lambda x: ( - x[1] or "", - (x[0].name or "") if x[0].name != "SuperAdmin" else "A", - ) - ) - displayed_roles_strings = [ - r.name + "_" + (dept or "") for (r, dept) in displayed_roles - ] - displayed_roles_labels = [ - f"{dept or 'tout dépt.'}: {r.name}" for (r, dept) in displayed_roles - ] - disabled_roles = {} # pour désactiver les roles que l'on ne peut pas éditer - for i, role_string in enumerate(displayed_roles_strings): - if role_string not in editable_roles_strings: - disabled_roles[i] = True - # Formulaire: - descr = [ - ("edit", {"input_type": "hidden", "default": edit}), - ( - "nom", - { - "title": "Nom", - "size": 20, - "allow_null": False, - "readonly": edit_only_roles, - }, - ), - ( - "prenom", - { - "title": "Prénom", - "size": 20, - "allow_null": False, - "readonly": edit_only_roles, - }, - ), - ] - if current_user.user_name != user_name and not edit_only_roles: - # no one can change its own status - descr.append( - ( - "status", - { - "title": "Statut", - "input_type": "radio", - "labels": ("actif", "ancien"), - "allowed_values": ("", "old"), - }, - ) - ) - if not edit: - descr += [ - ( - "user_name", - { - "title": "Pseudo (login)", - "size": 20, - "allow_null": False, - "explanation": "nom utilisé pour la connexion. Doit être unique parmi tous les utilisateurs. " - "Lettres ou chiffres uniquement.", - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ( - "password", - { - "title": "Mot de passe", - "input_type": "password", - "size": 14, - "allow_null": True, - "explanation": "optionnel, l'utilisateur pourra le saisir avec son mail", - }, - ), - ( - "password2", - { - "title": "Confirmer mot de passe", - "input_type": "password", - "size": 14, - "allow_null": True, - }, - ), - ] - else: # edition: on ne peut pas changer user_name - descr += [ - ( - "user_name", - {"input_type": "hidden", "default": initvalues["user_name"]}, - ) - ] - descr += [ - ( - "email", - { - "title": "e-mail", - "input_type": "text", - "explanation": "requis, doit fonctionner" - if not edit_only_roles - else "", - "size": 20, - "allow_null": False, - "readonly": edit_only_roles, - }, - ) - ] - if not edit: # options création utilisateur - descr += [ - ( - "welcome", - { - "title": "Message d'accueil", - "input_type": "checkbox", - "explanation": "Envoie un mail d'accueil à l'utilisateur.", - "labels": ("",), - "allowed_values": ("1",), - "default": "1", - }, - ), - ( - "reset_password", - { - "title": "", - "input_type": "checkbox", - "explanation": "indiquer par mail de changer le mot de passe initial", - "labels": ("",), - "allowed_values": ("1",), - "default": "1", - # "attributes": ["style='margin-left:20pt'"], - }, - ), - ] - # Si SuperAdmin, propose de choisir librement le dept du nouvel utilisateur - selectable_dept_acronyms = set(administrable_dept_acronyms) - if edit and the_user.dept is not None: # ajoute dept actuel de l'utilisateur - selectable_dept_acronyms |= {the_user.dept} - if is_super_admin and len(selectable_dept_acronyms) > 1: - selectable_dept_acronyms = sorted(list(selectable_dept_acronyms)) - descr.append( - ( - "dept", - { - "title": "Département", - "input_type": "menu", - "explanation": """département de rattachement de l'utilisateur""", - "labels": selectable_dept_acronyms, - "allowed_values": selectable_dept_acronyms, - "default": g.scodoc_dept - if g.scodoc_dept in selectable_dept_acronyms - else (auth_dept or ""), - }, - ) - ) - can_choose_dept = True - else: # pas de choix de département - can_choose_dept = False - if edit: - descr.append( - ( - "d", - { - "input_type": "separator", - "title": f"""L'utilisateur appartient au département {the_user.dept or "(tous)"}""", - }, - ) - ) - else: - descr.append( - ( - "d", - { - "input_type": "separator", - "title": f"L'utilisateur sera crée dans le département {auth_dept}", - }, - ) - ) - - descr += [ - ( - "date_expiration", - { - "title": "Date d'expiration", # j/m/a - "input_type": "datedmy", - "explanation": "j/m/a, laisser vide si pas de limite" - if not edit_only_roles - else "", - "size": 9, - "allow_null": True, - "readonly": edit_only_roles, - }, - ), - ( - "roles", - { - "title": "Rôles", - "input_type": "checkbox", - "vertical": True, - "labels": displayed_roles_labels, - "allowed_values": displayed_roles_strings, - "disabled_items": disabled_roles, - }, - ), - ] - if not edit_only_roles: - descr += [ - ( - "force", - { - "title": "Ignorer les avertissements", - "input_type": "checkbox", - "explanation": "passer outre les avertissements (homonymes, etc)", - "labels": ("",), - "allowed_values": ("1",), - }, - ), - ] - vals = scu.get_request_args() - if "tf_submitted" in vals and "roles" not in vals: - vals["roles"] = [] - if "tf_submitted" in vals: - # Ajoute roles existants mais non modifiables (disabled dans le form) - vals["roles"] = list( - set(vals["roles"]).union(orig_roles_strings - editable_roles_strings) - ) - - tf = TrivialFormulator( - request.base_url, - vals, - descr, - initvalues=initvalues, - submitlabel=submitlabel, - cancelbutton="Annuler", - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + F - elif tf[0] == -1: - return flask.redirect(scu.UsersURL()) - else: - vals = tf[2] - roles = set(vals["roles"]).intersection(editable_roles_strings) - if "edit" in vals: - edit = int(vals["edit"]) - else: - edit = 0 - try: - force = int(vals.get("force", "0")[0]) - except (IndexError, ValueError, TypeError): - force = 0 - - if edit: - user_name = initvalues["user_name"] - else: - user_name = vals["user_name"] - # ce login existe ? - err_msg = None - nb_existing_user = User.query.filter_by(user_name=user_name).count() > 0 - if edit and ( - nb_existing_user == 0 - ): # safety net, le user_name ne devrait pas changer - err_msg = f"identifiant {user_name} inexistant" - if not edit and nb_existing_user > 0: - err_msg = f"identifiant {user_name} déjà utilisé" - if err_msg: - H.append(tf_error_message(f"""Erreur: {err_msg}""")) - return "\n".join(H) + "\n" + tf[1] + F - - if not edit_only_roles: - ok_modif, msg = sco_users.check_modif_user( - edit, - enforce_optionals=not force, - user_name=user_name, - nom=vals["nom"], - prenom=vals["prenom"], - email=vals["email"], - dept=vals.get("dept", auth_dept), - roles=vals["roles"], - ) - if not ok_modif: - H.append(tf_error_message(msg)) - return "\n".join(H) + "\n" + tf[1] + F - - if "date_expiration" in vals: - try: - if vals["date_expiration"]: - vals["date_expiration"] = datetime.datetime.strptime( - vals["date_expiration"], "%d/%m/%Y" - ) - if vals["date_expiration"] < datetime.datetime.now(): - H.append(tf_error_message("date expiration passée")) - return "\n".join(H) + "\n" + tf[1] + F - else: - vals["date_expiration"] = None - except ValueError: - H.append(tf_error_message("date expiration invalide")) - return "\n".join(H) + "\n" + tf[1] + F - - if edit: # modif utilisateur (mais pas password ni user_name !) - if (not can_choose_dept) and "dept" in vals: - del vals["dept"] - if "password" in vals: - del vals["passwordd"] - if "date_modif_passwd" in vals: - del vals["date_modif_passwd"] - if "user_name" in vals: - del vals["user_name"] - if (current_user.user_name == user_name) and "status" in vals: - del vals["status"] # no one can't change its own status - if "status" in vals: - vals["active"] = vals["status"] == "" - # Département: - if auth_dept: # pas super-admin - if ("dept" in vals) and (vals["dept"] not in selectable_dept_acronyms): - del vals["dept"] # ne change pas de dept - # Traitement des roles: ne doit pas affecter les rôles - # que l'on en contrôle pas: - for role in orig_roles_strings: # { "Ens_RT", "Secr_CJ", ... } - if role and not role in editable_roles_strings: - roles.add(role) - - vals["roles_string"] = ",".join(roles) - - # ok, edit - if not edit_only_roles: - log(f"sco_users: editing {user_name} by {current_user.user_name}") - log(f"sco_users: previous_values={initvalues}") - log(f"sco_users: new_values={vals}") - sco_users.user_edit(user_name, vals) - flash(f"Utilisateur {user_name} modifié") - else: - sco_users.user_edit(user_name, {"roles_string": vals["roles_string"]}) - return flask.redirect( - url_for( - "users.user_info_page", - scodoc_dept=g.scodoc_dept, - user_name=user_name, - ) - ) - - else: # création utilisateur - vals["roles_string"] = ",".join(vals["roles"]) - # check identifiant - if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]+$", vals["user_name"]): - msg = tf_error_message( - "identifiant invalide (pas d'accents ni de caractères spéciaux)" - ) - return "\n".join(H) + msg + "\n" + tf[1] + F - # Traitement initial (mode) : 3 cas - # cf énumération Mode - # A: envoi de welcome + procedure de reset - # B: envoi de welcome seulement (mot de passe saisie dans le formulaire) - # C: Aucun envoi (mot de passe saisi dans le formulaire) - if vals["welcome"] != "1": - if vals["reset_password"] != "1": - mode = Mode.WELCOME_AND_CHANGE_PASSWORD - else: - mode = Mode.WELCOME_ONLY - else: - mode = Mode.SILENT - - # check passwords - if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: - vals["password"] = generate_password() - else: - if vals["password"]: - if vals["password"] != vals["password2"]: - msg = tf_error_message( - """Les deux mots de passes ne correspondent pas !""" - ) - return "\n".join(H) + msg + "\n" + tf[1] + F - if not is_valid_password(vals["password"]): - msg = tf_error_message( - """Mot de passe trop simple, recommencez !""" - ) - return "\n".join(H) + msg + "\n" + tf[1] + F - # Département: - if not can_choose_dept: - vals["dept"] = auth_dept - else: - if auth_dept: # pas super-admin - if vals["dept"] not in selectable_dept_acronyms: - raise ScoValueError("département invalide") - # ok, go - log( - f"""sco_users: new_user {vals["user_name"]} by {current_user.user_name}""" - ) - the_user = User() - the_user.from_dict(vals, new_user=True) - db.session.add(the_user) - db.session.commit() - # envoi éventuel d'un message - if mode == Mode.WELCOME_AND_CHANGE_PASSWORD or mode == Mode.WELCOME_ONLY: - if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: - token = the_user.get_reset_password_token() - else: - token = None - send_email( - "[ScoDoc] Création de votre compte", - sender=from_mail, # current_app.config["ADMINS"][0], - recipients=[the_user.email], - text_body=render_template( - "email/welcome.txt", user=the_user, token=token - ), - html_body=render_template( - "email/welcome.html", user=the_user, token=token - ), - ) - - return flask.redirect( - url_for( - "users.user_info_page", - scodoc_dept=g.scodoc_dept, - user_name=user_name, - head_message="Nouvel utilisateur créé", - ) - ) - - -@bp.route("/import_users_generate_excel_sample") -@scodoc -@permission_required(Permission.ScoUsersAdmin) -@scodoc7func -def import_users_generate_excel_sample(): - "une feuille excel pour importation utilisateurs" - data = sco_import_users.generate_excel_sample() - return scu.send_file(data, "ImportUtilisateurs", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) - - -@bp.route("/import_users_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoUsersAdmin) -@scodoc7func -def import_users_form(): - """Import utilisateurs depuis feuille Excel""" - head = html_sco_header.sco_header(page_title="Import utilisateurs") - H = [ - head, - """

      Téléchargement d'une nouvelle liste d'utilisateurs

      -

      A utiliser pour importer de nouveaux - utilisateurs (enseignants ou secrétaires) -

      -

      - L'opération se déroule en deux étapes. Dans un premier temps, - vous téléchargez une feuille Excel type. Vous devez remplir - cette feuille, une ligne décrivant chaque utilisateur. Ensuite, - vous indiquez le nom de votre fichier dans la case "Fichier Excel" - ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur - votre liste. -

      - """, - ] - help_msg = """

      - Lors de la creation des utilisateurs, les opérations suivantes sont effectuées: -

      -
        -
      1. vérification des données;
      2. -
      3. génération d'un mot de passe alétoire pour chaque utilisateur;
      4. -
      5. création de chaque utilisateur;
      6. -
      7. envoi à chaque utilisateur de son mot de passe initial par mail.
      8. -
      """ - H.append( - """
      1. - Obtenir la feuille excel à remplir
      2. """ - ) - F = html_sco_header.sco_footer() - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ( - "xlsfile", - {"title": "Fichier Excel:", "input_type": "file", "size": 40}, - ), - ( - "force", - { - "title": "Ignorer les avertissements", - "input_type": "checkbox", - "explanation": "passer outre les avertissements (homonymes, etc)", - "labels": ("",), - "allowed_values": ("1",), - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ), - submitlabel="Télécharger", - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + "
      " + help_msg + F - elif tf[0] == -1: - return flask.redirect(url_for("scolar.index_html", docodc_dept=g.scodoc_dept)) - - # IMPORT - ok, diags, nb_created = sco_import_users.import_excel_file( - tf[2]["xlsfile"], tf[2]["force"] - ) - H = [html_sco_header.sco_header(page_title="Import utilisateurs")] - H.append("
        ") - for diag in diags: - H.append(f"
      • {diag}
      • ") - H.append("
      ") - if ok: - dest_url = url_for("users.index_html", scodoc_dept=g.scodoc_dept, all_depts=1) - H.append( - f"""

      Ok, Import terminé ({nb_created} utilisateurs créés)!

      -

      Continuer

      - """ - ) - else: - dest_url = url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) - H.append( - f"""

      Erreur, importation annulée !

      -

      Continuer

      - """ - ) - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/user_info_page") -@scodoc -@permission_required(Permission.ScoUsersView) -@scodoc7func -def user_info_page(user_name=None): - """Display page of info about given user. - If user_name not specified, user current_user - """ - if user_name is not None: # scodoc7func converti en int ! - user_name = str(user_name) - # peut-on divulguer ces infos ? - if not can_handle_passwd(current_user, allow_admindepts=True): - raise AccessDenied("Vous n'avez pas la permission de voir cette page") - - dept = g.scodoc_dept - if not user_name: - user = current_user - else: - user = User.query.filter_by(user_name=user_name).first() - if not user: - raise ScoValueError("invalid user_name") - - return render_template( - "auth/user_info_page.html", - user=user, - title=f"Utilisateur {user.user_name}", - Permission=Permission, - dept=dept, - ) - - -@bp.route("/get_user_list_xml") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def get_user_list_xml(dept=None, start="", limit=25): - """Returns XML list of users with name (nomplogin) starting with start. - Used for forms auto-completion. - """ - # suggère seulement seulement les utilisateurs actifs: - userlist = sco_users.get_user_list(dept=dept) - start = scu.suppress_accents(str(start)).lower() - # TODO : à refaire avec une requete SQL #py3 - # (et en json) - userlist = [ - user - for user in userlist - if scu.suppress_accents((user.nom or "").lower()).startswith(start) - ] - doc = ElementTree.Element("results") - for user in userlist[:limit]: - x_rs = ElementTree.Element("rs", id=str(user.id), info="") - x_rs.text = user.get_nomplogin() - doc.append(x_rs) - - data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - return scu.send_file(data, mime=scu.XML_MIMETYPE) - - -@bp.route("/form_change_password", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def form_change_password(user_name=None): - """Formulaire de changement mot de passe de l'utilisateur user_name. - Un utilisateur peut toujours changer son propre mot de passe. - """ - if user_name is not None: # scodoc7func converti en int ! - user_name = str(user_name) - if not user_name: - user = current_user - else: - user = User.query.filter_by(user_name=user_name).first() - - # check access - if not can_handle_passwd(user): - return "\n".join( - [ - html_sco_header.sco_header(user_check=False), - "

      Vous n'avez pas la permission de changer ce mot de passe

      ", - html_sco_header.sco_footer(), - ] - ) - form = ChangePasswordForm(user_name=user.user_name, email=user.email) - destination = url_for( - "users.user_info_page", - scodoc_dept=g.scodoc_dept, - user_name=user_name, - ) - if request.method == "POST" and form.cancel.data: # cancel button clicked - return redirect(destination) - if form.validate_on_submit(): - messages = [] - if form.new_password.data != "": # change password - user.set_password(form.new_password.data) - messages.append("Mot de passe modifié") - if form.email.data.strip() != user.email: # change email - user.email = form.email.data.strip() - messages.append("Adresse email modifiée") - db.session.commit() - flash("\n".join(messages)) - return redirect(destination) - - return render_template( - "auth/change_password.html", - form=form, - title="Modification compte ScoDoc", - auth_username=current_user.user_name, - ) - - -@bp.route("/change_password", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def change_password(user_name, password, password2): - "Change the password for user given by user_name" - if user_name is not None: # scodoc7func converti en int ! - user_name = str(user_name) - u = User.query.filter_by(user_name=user_name).first() - # Check access permission - if not can_handle_passwd(u): - # access denied - log( - f"change_password: access denied (authuser={current_user}, user_name={user_name})" - ) - raise AccessDenied("vous n'avez pas la permission de changer ce mot de passe") - H = [] - F = html_sco_header.sco_footer() - # check password - dest_url = url_for( - "users.form_change_password", scodoc_dept=g.scodoc_dept, user_name=user_name - ) - if password != password2: - H.append( - f"""

      Les deux mots de passes saisis sont différents !

      -

      Recommencer

      - """ - ) - else: - if not is_valid_password(password): - H.append( - f"""

      ce mot de passe n'est pas assez compliqué ! -
      (oui, il faut un mot de passe vraiment compliqué !) -

      -

      Recommencer

      - """ - ) - else: - # ok, strong password - db.session.add(u) - u.set_password(password) - db.session.commit() - # - # ici page simplifiee car on peut ne plus avoir - # le droit d'acceder aux feuilles de style - H.append( - """

      Changement effectué !

      -

      Ne notez pas ce mot de passe, mais mémorisez le !

      -

      Rappel: il est interdit de communiquer son mot de passe à - un tiers, même si c'est un collègue de confiance !

      -

      Si vous n'êtes pas administrateur, le système va vous redemander - votre login et nouveau mot de passe au prochain accès. -

      """ - ) - return ( - f""" - - - -Mot de passe changé - -

      Mot de passe changé !

      -""" - + "\n".join(H) - + f'Continuer' - ) - return html_sco_header.sco_header() + "\n".join(H) + F - - -@bp.route("/toggle_active_user/", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoUsersAdmin) -def toggle_active_user(user_name: str = None): - """Change active status of a user account""" - if user_name is not None: # scodoc7func converti en int ! - user_name = str(user_name) - - u = User.query.filter_by(user_name=user_name).first() - if not u: - raise ScoValueError("invalid user_name") - # permission check: - if not ( - current_user.is_administrator() - or current_user.has_permission(Permission.ScoUsersAdmin, u.dept) - ): - raise ScoPermissionDenied() - form = DeactivateUserForm() - if request.method == "POST" and form.cancel.data: - # if cancel button is clicked, the form.cancel.data will be True - return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept)) - if form.validate_on_submit(): - u.active = not u.active - db.session.add(u) - db.session.commit() - return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept)) - return render_template("auth/toogle_active_user.html", form=form, u=u) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +""" +Module users: interface gestion utilisateurs +ré-écriture pour Flask ScoDoc7 / ZScoUsers.py + +Vues s'appuyant sur auth et sco_users + +Emmanuel Viennet, 2021 +""" +import datetime +import re +from enum import auto, IntEnum +from xml.etree import ElementTree + +import flask +from flask import g, url_for, request, current_app, flash +from flask import redirect, render_template +from flask_login import current_user +from flask_wtf import FlaskForm + +from wtforms import HiddenField, PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired, Email, ValidationError, EqualTo + +from app import db +from app.auth.forms import DeactivateUserForm +from app.auth.models import Permission +from app.auth.models import User +from app.auth.models import Role +from app.auth.models import UserRole +from app.auth.models import is_valid_password +from app.email import send_email +from app.models import Departement + +from app.decorators import ( + scodoc, + scodoc7func, + permission_required, +) + +from app.scodoc import html_sco_header, sco_import_users, sco_roles_default +from app.scodoc import sco_users +from app.scodoc import sco_utils as scu +from app.scodoc import sco_xml +from app import log +from app.scodoc.sco_exceptions import AccessDenied, ScoPermissionDenied, ScoValueError +from app.scodoc.sco_import_users import generate_password +from app.scodoc.sco_permissions_check import can_handle_passwd +from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message +from app.views import users_bp as bp + +_ = lambda x: x # sans babel +_l = _ + + +class ChangePasswordForm(FlaskForm): + """formulaire changement mot de passe et mail""" + + user_name = HiddenField() + old_password = PasswordField(_l("Identifiez-vous")) + new_password = PasswordField(_l("Nouveau mot de passe de l'utilisateur")) + bis_password = PasswordField( + _l("Répéter"), + validators=[ + EqualTo( + "new_password", + message="Les deux saisies sont " "différentes, recommencez", + ), + ], + ) + email = StringField( + _l("Email"), + validators=[ + DataRequired(), + Email(message="adresse email invalide, recommencez"), + ], + ) + submit = SubmitField() + cancel = SubmitField("Annuler") + + def validate_email(self, email): + "vérifie que le mail est unique" + user = User.query.filter_by(email=email.data.strip()).first() + if user is not None and self.user_name.data != user.user_name: + raise ValidationError( + _("Cette adresse e-mail est déjà attribuée à un autre compte") + ) + + def validate_new_password(self, new_password): + "vérifie que le mot de passe est acceptable" + if new_password.data != "" and not is_valid_password(new_password.data): + raise ValidationError("Mot de passe trop simple, recommencez") + + def validate_old_password(self, old_password): + if not current_user.check_password(old_password.data): + raise ValidationError("Mot de passe actuel incorrect, ré-essayez") + + +class Mode(IntEnum): + WELCOME_AND_CHANGE_PASSWORD = auto() + WELCOME_ONLY = auto() + SILENT = auto() + + +@bp.route("/") +@bp.route("/index_html") +@scodoc +@permission_required(Permission.ScoUsersView) +@scodoc7func +def index_html(all_depts=False, with_inactives=False, format="html"): + "Page accueil utilisateur: tableau avec liste des comptes" + return sco_users.index_html( + all_depts=all_depts, + with_inactives=with_inactives, + format=format, + ) + + +def _get_administrable_depts() -> list[str]: + """Liste des acronymes des départements dans lesquels l'utilisateur + courant peut administrer des utilisateurs. + Si SuperAdmin, tous les départements + Sinon, les départements dans lesquels l'utilisateur a la permission ScoUsersAdmin + """ + # + if current_user.is_administrator(): + log(f"create_user_form called by {current_user.user_name} (super admin)") + administrable_dept_acronyms = sorted( + [d.acronym for d in Departement.query.all()] + ) + else: + administrable_dept_acronyms = current_user.get_depts_with_permission( + Permission.ScoUsersAdmin + ) + if None in administrable_dept_acronyms: + administrable_dept_acronyms = sorted( + [d.acronym for d in Departement.query.all()] + ) + + return administrable_dept_acronyms + + +def _get_editable_roles( + administrable_dept_acronyms: list = None, all_roles=True +) -> set[tuple[Role, str]]: + """Rôles modifiables: ensemble de tuples (role, dept_acronym) + (où dept_acronym est None si tous dept.) + + Si all_roles, tous les rôles définis et modifiables par l'utilisateurs. + Sinon, seulement les rôles "standards" de ScoDoc. + """ + if all_roles: + # tous sauf SuperAdmin + roles = [ + r + for r in Role.query.all() + if r.permissions != Permission.ALL_PERMISSIONS[0] + ] + else: + # Les rôles standards créés à l'initialisation de ScoDoc: + roles = [ + Role.get_named_role(r) for r in sco_roles_default.ROLES_ATTRIBUABLES_DEPT + ] + + # Génère toutes les combinaisons roles/départements + editable_roles_set = { + (r, dept) for r in roles for dept in administrable_dept_acronyms + } + if current_user.is_administrator(): + editable_roles_set |= { + (Role.get_named_role(r), None) + for r in sco_roles_default.ROLES_ATTRIBUABLES_SCODOC + } + # Un super-admin peut nommer d'autres super-admin: + editable_roles_set |= {(Role.get_named_role("SuperAdmin"), None)} + return editable_roles_set + + +@bp.route("/create_user_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoUsersAdmin) +@scodoc7func +def create_user_form(user_name=None, edit=0, all_roles=True): + "form. création ou édition utilisateur" + if user_name is not None: # scodoc7func converti en int ! + user_name = str(user_name) + Role.ensure_standard_roles() # assure la présence des rôles en base + auth_dept = current_user.dept + from_mail = current_app.config["SCODOC_MAIL_FROM"] # current_user.email + initvalues = {} + edit = int(edit) + all_roles = int(all_roles) + H = [ + html_sco_header.sco_header( + bodyOnLoad="init_tf_form('')", + javascripts=["js/user_form.js"], + ) + ] + F = html_sco_header.sco_footer() + the_user: User = None + if edit: + if not user_name: + raise ValueError("missing argument: user_name") + the_user = User.query.filter_by(user_name=user_name).first() + if not the_user: + raise ScoValueError("utilisateur inexistant") + initvalues = the_user.to_dict() + H.append(f"

      Modification de l'utilisateur {user_name}

      ") + submitlabel = "Modifier utilisateur" + if "roles_string" in initvalues: + initvalues["roles"] = initvalues["roles_string"].split(",") + else: + initvalues["roles"] = [] + if "date_expiration" in initvalues: + initvalues["date_expiration"] = ( + the_user.date_expiration.strftime("%d/%m/%Y") + if the_user.date_expiration + else "" + ) + initvalues["status"] = "" if the_user.active else "old" + orig_roles = { # set des roles existants avant édition + UserRole.role_dept_from_string(role_dept) + for role_dept in initvalues["roles"] + if role_dept + } + if not initvalues["active"]: + editable_roles_set = set() # can't change roles of a disabled user + else: + H.append("

      Création d'un utilisateur

      ") + submitlabel = "Créer utilisateur" + orig_roles = set() + + is_super_admin = current_user.is_administrator() + if is_super_admin: + H.append("""

      Vous êtes super administrateur !

      """) + + administrable_dept_acronyms = _get_administrable_depts() + if edit: + if the_user.dept is None: # seul le super admin peut le toucher + edit_only_roles = not current_user.is_administrator() + else: + edit_only_roles = the_user.dept not in administrable_dept_acronyms + else: + edit_only_roles = False # création nouvel utilisateur + editable_roles_set = _get_editable_roles( + administrable_dept_acronyms, all_roles=all_roles + ) + editable_roles_strings = { + r.name + "_" + (dept or "") for (r, dept) in editable_roles_set + } + orig_roles_strings = {r.name + "_" + (dept or "") for (r, dept) in orig_roles} + # add existing user roles + displayed_roles = list(editable_roles_set.union(orig_roles)) + displayed_roles.sort( + key=lambda x: ( + x[1] or "", + (x[0].name or "") if x[0].name != "SuperAdmin" else "A", + ) + ) + displayed_roles_strings = [ + r.name + "_" + (dept or "") for (r, dept) in displayed_roles + ] + displayed_roles_labels = [ + f"{dept or 'tout dépt.'}: {r.name}" for (r, dept) in displayed_roles + ] + disabled_roles = {} # pour désactiver les roles que l'on ne peut pas éditer + for i, role_string in enumerate(displayed_roles_strings): + if role_string not in editable_roles_strings: + disabled_roles[i] = True + # Formulaire: + descr = [ + ("edit", {"input_type": "hidden", "default": edit}), + ( + "nom", + { + "title": "Nom", + "size": 20, + "allow_null": False, + "readonly": edit_only_roles, + }, + ), + ( + "prenom", + { + "title": "Prénom", + "size": 20, + "allow_null": False, + "readonly": edit_only_roles, + }, + ), + ] + if current_user.user_name != user_name and not edit_only_roles: + # no one can change its own status + descr.append( + ( + "status", + { + "title": "Statut", + "input_type": "radio", + "labels": ("actif", "ancien"), + "allowed_values": ("", "old"), + }, + ) + ) + if not edit: + descr += [ + ( + "user_name", + { + "title": "Pseudo (login)", + "size": 20, + "allow_null": False, + "explanation": "nom utilisé pour la connexion. Doit être unique parmi tous les utilisateurs. " + "Lettres ou chiffres uniquement.", + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ( + "password", + { + "title": "Mot de passe", + "input_type": "password", + "size": 14, + "allow_null": True, + "explanation": "optionnel, l'utilisateur pourra le saisir avec son mail", + }, + ), + ( + "password2", + { + "title": "Confirmer mot de passe", + "input_type": "password", + "size": 14, + "allow_null": True, + }, + ), + ] + else: # edition: on ne peut pas changer user_name + descr += [ + ( + "user_name", + {"input_type": "hidden", "default": initvalues["user_name"]}, + ) + ] + descr += [ + ( + "email", + { + "title": "e-mail", + "input_type": "text", + "explanation": "requis, doit fonctionner" + if not edit_only_roles + else "", + "size": 20, + "allow_null": False, + "readonly": edit_only_roles, + }, + ) + ] + if not edit: # options création utilisateur + descr += [ + ( + "welcome", + { + "title": "Message d'accueil", + "input_type": "checkbox", + "explanation": "Envoie un mail d'accueil à l'utilisateur.", + "labels": ("",), + "allowed_values": ("1",), + "default": "1", + }, + ), + ( + "reset_password", + { + "title": "", + "input_type": "checkbox", + "explanation": "indiquer par mail de changer le mot de passe initial", + "labels": ("",), + "allowed_values": ("1",), + "default": "1", + # "attributes": ["style='margin-left:20pt'"], + }, + ), + ] + # Si SuperAdmin, propose de choisir librement le dept du nouvel utilisateur + selectable_dept_acronyms = set(administrable_dept_acronyms) + if edit and the_user.dept is not None: # ajoute dept actuel de l'utilisateur + selectable_dept_acronyms |= {the_user.dept} + if is_super_admin and len(selectable_dept_acronyms) > 1: + selectable_dept_acronyms = sorted(list(selectable_dept_acronyms)) + descr.append( + ( + "dept", + { + "title": "Département", + "input_type": "menu", + "explanation": """département de rattachement de l'utilisateur""", + "labels": selectable_dept_acronyms, + "allowed_values": selectable_dept_acronyms, + "default": g.scodoc_dept + if g.scodoc_dept in selectable_dept_acronyms + else (auth_dept or ""), + }, + ) + ) + can_choose_dept = True + else: # pas de choix de département + can_choose_dept = False + if edit: + descr.append( + ( + "d", + { + "input_type": "separator", + "title": f"""L'utilisateur appartient au département {the_user.dept or "(tous)"}""", + }, + ) + ) + else: + descr.append( + ( + "d", + { + "input_type": "separator", + "title": f"L'utilisateur sera crée dans le département {auth_dept}", + }, + ) + ) + + descr += [ + ( + "date_expiration", + { + "title": "Date d'expiration", # j/m/a + "input_type": "datedmy", + "explanation": "j/m/a, laisser vide si pas de limite" + if not edit_only_roles + else "", + "size": 9, + "allow_null": True, + "readonly": edit_only_roles, + }, + ), + ( + "roles", + { + "title": "Rôles", + "input_type": "checkbox", + "vertical": True, + "labels": displayed_roles_labels, + "allowed_values": displayed_roles_strings, + "disabled_items": disabled_roles, + }, + ), + ] + if not edit_only_roles: + descr += [ + ( + "force", + { + "title": "Ignorer les avertissements", + "input_type": "checkbox", + "explanation": "passer outre les avertissements (homonymes, etc)", + "labels": ("",), + "allowed_values": ("1",), + }, + ), + ] + vals = scu.get_request_args() + if "tf_submitted" in vals and "roles" not in vals: + vals["roles"] = [] + if "tf_submitted" in vals: + # Ajoute roles existants mais non modifiables (disabled dans le form) + vals["roles"] = list( + set(vals["roles"]).union(orig_roles_strings - editable_roles_strings) + ) + + tf = TrivialFormulator( + request.base_url, + vals, + descr, + initvalues=initvalues, + submitlabel=submitlabel, + cancelbutton="Annuler", + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + F + elif tf[0] == -1: + return flask.redirect(scu.UsersURL()) + else: + vals = tf[2] + roles = set(vals["roles"]).intersection(editable_roles_strings) + if "edit" in vals: + edit = int(vals["edit"]) + else: + edit = 0 + try: + force = int(vals.get("force", "0")[0]) + except (IndexError, ValueError, TypeError): + force = 0 + + if edit: + user_name = initvalues["user_name"] + else: + user_name = vals["user_name"] + # ce login existe ? + err_msg = None + nb_existing_user = User.query.filter_by(user_name=user_name).count() > 0 + if edit and ( + nb_existing_user == 0 + ): # safety net, le user_name ne devrait pas changer + err_msg = f"identifiant {user_name} inexistant" + if not edit and nb_existing_user > 0: + err_msg = f"identifiant {user_name} déjà utilisé" + if err_msg: + H.append(tf_error_message(f"""Erreur: {err_msg}""")) + return "\n".join(H) + "\n" + tf[1] + F + + if not edit_only_roles: + ok_modif, msg = sco_users.check_modif_user( + edit, + enforce_optionals=not force, + user_name=user_name, + nom=vals["nom"], + prenom=vals["prenom"], + email=vals["email"], + dept=vals.get("dept", auth_dept), + roles=vals["roles"], + ) + if not ok_modif: + H.append(tf_error_message(msg)) + return "\n".join(H) + "\n" + tf[1] + F + + if "date_expiration" in vals: + try: + if vals["date_expiration"]: + vals["date_expiration"] = datetime.datetime.strptime( + vals["date_expiration"], "%d/%m/%Y" + ) + if vals["date_expiration"] < datetime.datetime.now(): + H.append(tf_error_message("date expiration passée")) + return "\n".join(H) + "\n" + tf[1] + F + else: + vals["date_expiration"] = None + except ValueError: + H.append(tf_error_message("date expiration invalide")) + return "\n".join(H) + "\n" + tf[1] + F + + if edit: # modif utilisateur (mais pas password ni user_name !) + if (not can_choose_dept) and "dept" in vals: + del vals["dept"] + if "password" in vals: + del vals["passwordd"] + if "date_modif_passwd" in vals: + del vals["date_modif_passwd"] + if "user_name" in vals: + del vals["user_name"] + if (current_user.user_name == user_name) and "status" in vals: + del vals["status"] # no one can't change its own status + if "status" in vals: + vals["active"] = vals["status"] == "" + # Département: + if auth_dept: # pas super-admin + if ("dept" in vals) and (vals["dept"] not in selectable_dept_acronyms): + del vals["dept"] # ne change pas de dept + # Traitement des roles: ne doit pas affecter les rôles + # que l'on en contrôle pas: + for role in orig_roles_strings: # { "Ens_RT", "Secr_CJ", ... } + if role and not role in editable_roles_strings: + roles.add(role) + + vals["roles_string"] = ",".join(roles) + + # ok, edit + if not edit_only_roles: + log(f"sco_users: editing {user_name} by {current_user.user_name}") + log(f"sco_users: previous_values={initvalues}") + log(f"sco_users: new_values={vals}") + sco_users.user_edit(user_name, vals) + flash(f"Utilisateur {user_name} modifié") + else: + sco_users.user_edit(user_name, {"roles_string": vals["roles_string"]}) + return flask.redirect( + url_for( + "users.user_info_page", + scodoc_dept=g.scodoc_dept, + user_name=user_name, + ) + ) + + else: # création utilisateur + vals["roles_string"] = ",".join(vals["roles"]) + # check identifiant + if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]+$", vals["user_name"]): + msg = tf_error_message( + "identifiant invalide (pas d'accents ni de caractères spéciaux)" + ) + return "\n".join(H) + msg + "\n" + tf[1] + F + # Traitement initial (mode) : 3 cas + # cf énumération Mode + # A: envoi de welcome + procedure de reset + # B: envoi de welcome seulement (mot de passe saisie dans le formulaire) + # C: Aucun envoi (mot de passe saisi dans le formulaire) + if vals["welcome"] != "1": + if vals["reset_password"] != "1": + mode = Mode.WELCOME_AND_CHANGE_PASSWORD + else: + mode = Mode.WELCOME_ONLY + else: + mode = Mode.SILENT + + # check passwords + if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: + vals["password"] = generate_password() + else: + if vals["password"]: + if vals["password"] != vals["password2"]: + msg = tf_error_message( + """Les deux mots de passes ne correspondent pas !""" + ) + return "\n".join(H) + msg + "\n" + tf[1] + F + if not is_valid_password(vals["password"]): + msg = tf_error_message( + """Mot de passe trop simple, recommencez !""" + ) + return "\n".join(H) + msg + "\n" + tf[1] + F + # Département: + if not can_choose_dept: + vals["dept"] = auth_dept + else: + if auth_dept: # pas super-admin + if vals["dept"] not in selectable_dept_acronyms: + raise ScoValueError("département invalide") + # ok, go + log( + f"""sco_users: new_user {vals["user_name"]} by {current_user.user_name}""" + ) + the_user = User() + the_user.from_dict(vals, new_user=True) + db.session.add(the_user) + db.session.commit() + # envoi éventuel d'un message + if mode == Mode.WELCOME_AND_CHANGE_PASSWORD or mode == Mode.WELCOME_ONLY: + if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: + token = the_user.get_reset_password_token() + else: + token = None + send_email( + "[ScoDoc] Création de votre compte", + sender=from_mail, # current_app.config["ADMINS"][0], + recipients=[the_user.email], + text_body=render_template( + "email/welcome.txt", user=the_user, token=token + ), + html_body=render_template( + "email/welcome.html", user=the_user, token=token + ), + ) + + return flask.redirect( + url_for( + "users.user_info_page", + scodoc_dept=g.scodoc_dept, + user_name=user_name, + head_message="Nouvel utilisateur créé", + ) + ) + + +@bp.route("/import_users_generate_excel_sample") +@scodoc +@permission_required(Permission.ScoUsersAdmin) +@scodoc7func +def import_users_generate_excel_sample(): + "une feuille excel pour importation utilisateurs" + data = sco_import_users.generate_excel_sample() + return scu.send_file(data, "ImportUtilisateurs", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + + +@bp.route("/import_users_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoUsersAdmin) +@scodoc7func +def import_users_form(): + """Import utilisateurs depuis feuille Excel""" + head = html_sco_header.sco_header(page_title="Import utilisateurs") + H = [ + head, + """

      Téléchargement d'une nouvelle liste d'utilisateurs

      +

      A utiliser pour importer de nouveaux + utilisateurs (enseignants ou secrétaires) +

      +

      + L'opération se déroule en deux étapes. Dans un premier temps, + vous téléchargez une feuille Excel type. Vous devez remplir + cette feuille, une ligne décrivant chaque utilisateur. Ensuite, + vous indiquez le nom de votre fichier dans la case "Fichier Excel" + ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur + votre liste. +

      + """, + ] + help_msg = """

      + Lors de la creation des utilisateurs, les opérations suivantes sont effectuées: +

      +
        +
      1. vérification des données;
      2. +
      3. génération d'un mot de passe alétoire pour chaque utilisateur;
      4. +
      5. création de chaque utilisateur;
      6. +
      7. envoi à chaque utilisateur de son mot de passe initial par mail.
      8. +
      """ + H.append( + """
      1. + Obtenir la feuille excel à remplir
      2. """ + ) + F = html_sco_header.sco_footer() + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ( + "xlsfile", + {"title": "Fichier Excel:", "input_type": "file", "size": 40}, + ), + ( + "force", + { + "title": "Ignorer les avertissements", + "input_type": "checkbox", + "explanation": "passer outre les avertissements (homonymes, etc)", + "labels": ("",), + "allowed_values": ("1",), + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ), + submitlabel="Télécharger", + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + "
      " + help_msg + F + elif tf[0] == -1: + return flask.redirect(url_for("scolar.index_html", docodc_dept=g.scodoc_dept)) + + # IMPORT + ok, diags, nb_created = sco_import_users.import_excel_file( + tf[2]["xlsfile"], tf[2]["force"] + ) + H = [html_sco_header.sco_header(page_title="Import utilisateurs")] + H.append("
        ") + for diag in diags: + H.append(f"
      • {diag}
      • ") + H.append("
      ") + if ok: + dest_url = url_for("users.index_html", scodoc_dept=g.scodoc_dept, all_depts=1) + H.append( + f"""

      Ok, Import terminé ({nb_created} utilisateurs créés)!

      +

      Continuer

      + """ + ) + else: + dest_url = url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) + H.append( + f"""

      Erreur, importation annulée !

      +

      Continuer

      + """ + ) + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route("/user_info_page") +@scodoc +@permission_required(Permission.ScoUsersView) +@scodoc7func +def user_info_page(user_name=None): + """Display page of info about given user. + If user_name not specified, user current_user + """ + if user_name is not None: # scodoc7func converti en int ! + user_name = str(user_name) + # peut-on divulguer ces infos ? + if not can_handle_passwd(current_user, allow_admindepts=True): + raise AccessDenied("Vous n'avez pas la permission de voir cette page") + + dept = g.scodoc_dept + if not user_name: + user = current_user + else: + user = User.query.filter_by(user_name=user_name).first() + if not user: + raise ScoValueError("invalid user_name") + + return render_template( + "auth/user_info_page.html", + user=user, + title=f"Utilisateur {user.user_name}", + Permission=Permission, + dept=dept, + ) + + +@bp.route("/get_user_list_xml") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def get_user_list_xml(dept=None, start="", limit=25): + """Returns XML list of users with name (nomplogin) starting with start. + Used for forms auto-completion. + """ + # suggère seulement seulement les utilisateurs actifs: + userlist = sco_users.get_user_list(dept=dept) + start = scu.suppress_accents(str(start)).lower() + # TODO : à refaire avec une requete SQL #py3 + # (et en json) + userlist = [ + user + for user in userlist + if scu.suppress_accents((user.nom or "").lower()).startswith(start) + ] + doc = ElementTree.Element("results") + for user in userlist[:limit]: + x_rs = ElementTree.Element("rs", id=str(user.id), info="") + x_rs.text = user.get_nomplogin() + doc.append(x_rs) + + data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) + return scu.send_file(data, mime=scu.XML_MIMETYPE) + + +@bp.route("/form_change_password", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def form_change_password(user_name=None): + """Formulaire de changement mot de passe de l'utilisateur user_name. + Un utilisateur peut toujours changer son propre mot de passe. + """ + if user_name is not None: # scodoc7func converti en int ! + user_name = str(user_name) + if not user_name: + user = current_user + else: + user = User.query.filter_by(user_name=user_name).first() + + # check access + if not can_handle_passwd(user): + return "\n".join( + [ + html_sco_header.sco_header(user_check=False), + "

      Vous n'avez pas la permission de changer ce mot de passe

      ", + html_sco_header.sco_footer(), + ] + ) + form = ChangePasswordForm(user_name=user.user_name, email=user.email) + destination = url_for( + "users.user_info_page", + scodoc_dept=g.scodoc_dept, + user_name=user_name, + ) + if request.method == "POST" and form.cancel.data: # cancel button clicked + return redirect(destination) + if form.validate_on_submit(): + messages = [] + if form.new_password.data != "": # change password + user.set_password(form.new_password.data) + messages.append("Mot de passe modifié") + if form.email.data.strip() != user.email: # change email + user.email = form.email.data.strip() + messages.append("Adresse email modifiée") + db.session.commit() + flash("\n".join(messages)) + return redirect(destination) + + return render_template( + "auth/change_password.html", + form=form, + title="Modification compte ScoDoc", + auth_username=current_user.user_name, + ) + + +@bp.route("/change_password", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def change_password(user_name, password, password2): + "Change the password for user given by user_name" + if user_name is not None: # scodoc7func converti en int ! + user_name = str(user_name) + u = User.query.filter_by(user_name=user_name).first() + # Check access permission + if not can_handle_passwd(u): + # access denied + log( + f"change_password: access denied (authuser={current_user}, user_name={user_name})" + ) + raise AccessDenied("vous n'avez pas la permission de changer ce mot de passe") + H = [] + F = html_sco_header.sco_footer() + # check password + dest_url = url_for( + "users.form_change_password", scodoc_dept=g.scodoc_dept, user_name=user_name + ) + if password != password2: + H.append( + f"""

      Les deux mots de passes saisis sont différents !

      +

      Recommencer

      + """ + ) + else: + if not is_valid_password(password): + H.append( + f"""

      ce mot de passe n'est pas assez compliqué ! +
      (oui, il faut un mot de passe vraiment compliqué !) +

      +

      Recommencer

      + """ + ) + else: + # ok, strong password + db.session.add(u) + u.set_password(password) + db.session.commit() + # + # ici page simplifiee car on peut ne plus avoir + # le droit d'acceder aux feuilles de style + H.append( + """

      Changement effectué !

      +

      Ne notez pas ce mot de passe, mais mémorisez le !

      +

      Rappel: il est interdit de communiquer son mot de passe à + un tiers, même si c'est un collègue de confiance !

      +

      Si vous n'êtes pas administrateur, le système va vous redemander + votre login et nouveau mot de passe au prochain accès. +

      """ + ) + return ( + f""" + + + +Mot de passe changé + +

      Mot de passe changé !

      +""" + + "\n".join(H) + + f'Continuer' + ) + return html_sco_header.sco_header() + "\n".join(H) + F + + +@bp.route("/toggle_active_user/", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoUsersAdmin) +def toggle_active_user(user_name: str = None): + """Change active status of a user account""" + if user_name is not None: # scodoc7func converti en int ! + user_name = str(user_name) + + u = User.query.filter_by(user_name=user_name).first() + if not u: + raise ScoValueError("invalid user_name") + # permission check: + if not ( + current_user.is_administrator() + or current_user.has_permission(Permission.ScoUsersAdmin, u.dept) + ): + raise ScoPermissionDenied() + form = DeactivateUserForm() + if request.method == "POST" and form.cancel.data: + # if cancel button is clicked, the form.cancel.data will be True + return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept)) + if form.validate_on_submit(): + u.active = not u.active + db.session.add(u) + db.session.commit() + return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept)) + return render_template("auth/toogle_active_user.html", form=form, u=u)