# -*- 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 # ############################################################################## """Edition des PV de jury PV Jury IUTV 2006: on détaillait 8 cas: Jury de semestre n On a 8 types de décisions: Passages: 1. passage de ceux qui ont validés Sn-1 2. passage avec compensation Sn-1, Sn 3. passage sans validation de Sn avec validation d'UE 4. passage sans validation de Sn sans validation d'UE Redoublements: 5. redoublement de Sn-1 et Sn sans validation d'UE pour Sn 6. redoublement de Sn-1 et Sn avec validation d'UE pour Sn Reports 7. report sans validation d'UE 8. non validation de Sn-1 et Sn et non redoublement """ import time from operator import itemgetter from reportlab.platypus import Paragraph from reportlab.lib import styles import flask from flask import flash, redirect, url_for from flask import g, request from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.models import ( FormSemestre, UniteEns, ScolarAutorisationInscription, but_validations, ) from app.models.etudiants import Identite import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log from app.scodoc import html_sco_header from app.scodoc import sco_codes_parcours from app.scodoc import sco_cursus from app.scodoc import sco_cursus_dut from app.scodoc import sco_edit_ue from app.scodoc import sco_etud from app.scodoc import sco_formations from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_groups_view from app.scodoc import sco_pdf from app.scodoc import sco_preferences from app.scodoc import sco_pvpdf from app.scodoc.gen_tables import GenTable from app.scodoc.sco_codes_parcours import NO_SEMESTRE_ID from app.scodoc.sco_pdf import PDFLOCK from app.scodoc.TrivialFormulator import TrivialFormulator def _descr_decisions_ues(nt, etudid, decisions_ue, decision_sem) -> list[dict]: """Liste des UE validées dans ce semestre (incluant les UE capitalisées)""" if not decisions_ue: return [] uelist = [] # Les UE validées dans ce semestre: for ue_id in decisions_ue.keys(): try: if decisions_ue[ue_id] and ( sco_codes_parcours.code_ue_validant(decisions_ue[ue_id]["code"]) or ( # XXX ceci devrait dépendre du parcours et non pas être une option ! #sco8 decision_sem and scu.CONFIG.CAPITALIZE_ALL_UES and sco_codes_parcours.code_semestre_validant(decision_sem["code"]) ) ): ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0] uelist.append(ue) except: log( f"Exception in descr_decisions_ues: ue_id={ue_id} decisions_ue={decisions_ue}" ) # Les UE capitalisées dans d'autres semestres: if etudid in nt.validations.ue_capitalisees.index: for ue_id in nt.validations.ue_capitalisees.loc[[etudid]]["ue_id"]: try: uelist.append(nt.get_etud_ue_status(etudid, ue_id)["ue"]) except (KeyError, TypeError): pass uelist.sort(key=itemgetter("numero")) return uelist def _descr_decision_sem(etat, decision_sem): "résumé textuel de la décision de semestre" if etat == "D": decision = "Démission" else: if decision_sem: cod = decision_sem["code"] decision = sco_codes_parcours.CODES_EXPL.get(cod, "") # + ' (%s)' % cod else: decision = "" return decision def _descr_decision_sem_abbrev(etat, decision_sem): "résumé textuel tres court (code) de la décision de semestre" if etat == "D": decision = "Démission" else: if decision_sem: decision = decision_sem["code"] else: decision = "" return decision def descr_autorisations(autorisations: list[ScolarAutorisationInscription]) -> str: "résumé textuel des autorisations d'inscription (-> 'S1, S3' )" return ", ".join([f"S{a.semestre_id}" for a in autorisations]) def _comp_ects_by_ue_code(nt, decision_ues): """Calcul somme des ECTS validés dans ce semestre (sans les UE capitalisées) decision_ues est le resultat de nt.get_etud_decision_ues Chaque resultat est un dict: { ue_code : ects } """ if not decision_ues: return {} ects_by_ue_code = {} for ue_id in decision_ues: d = decision_ues[ue_id] ue = UniteEns.query.get(ue_id) ects_by_ue_code[ue.ue_code] = d["ects"] return ects_by_ue_code def _comp_ects_capitalises_by_ue_code(nt: NotesTableCompat, etudid: int): """Calcul somme des ECTS des UE capitalisees""" ues = nt.get_ues_stat_dict() ects_by_ue_code = {} for ue in ues: ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) if ue_status and ue_status["is_capitalized"]: ects_val = float(ue_status["ue"]["ects"] or 0.0) ects_by_ue_code[ue["ue_code"]] = ects_val return ects_by_ue_code def _sum_ects_dicts(s, t): """Somme deux dictionnaires { ue_code : ects }, quand une UE de même code apparait deux fois, prend celle avec le plus d'ECTS. """ sum_ects = sum(s.values()) + sum(t.values()) for ue_code in set(s).intersection(set(t)): sum_ects -= min(s[ue_code], t[ue_code]) return sum_ects def dict_pvjury( formsemestre_id, etudids=None, with_prev=False, with_parcours_decisions=False, ): """Données pour édition jury etudids == None => tous les inscrits, sinon donne la liste des ids Si with_prev: ajoute infos sur code jury semestre precedent Si with_parcours_decisions: ajoute infos sur code decision jury de tous les semestre du parcours Résultat: { 'date' : date de la decision la plus recente, 'formsemestre' : sem, 'is_apc' : bool, 'formation' : { 'acronyme' :, 'titre': ... } 'decisions' : { [ { 'identite' : {'nom' :, 'prenom':, ...,}, 'etat' : I ou D ou DEF 'decision_sem' : {'code':, 'code_prev': }, 'decisions_ue' : { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' :, 'acronyme', 'numero': } }, 'autorisations' : [ { 'semestre_id' : { ... } } ], 'validation_parcours' : True si parcours validé (diplome obtenu) 'prev_code' : code (calculé slt si with_prev), 'mention' : mention (en fct moy gen), 'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées) 'sum_ects_capitalises' : somme des ECTS des UE capitalisees } ] }, 'decisions_dict' : { etudid : decision (comme ci-dessus) }, } """ formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) if etudids is None: etudids = nt.get_etudids() if not etudids: return {} cnx = ndb.GetDBConnexion() sem = sco_formsemestre.get_formsemestre(formsemestre_id) max_date = "0000-01-01" has_prev = False # vrai si au moins un etudiant a un code prev semestre_non_terminal = False # True si au moins un etudiant a un devenir decisions = [] D = {} # même chose que decisions, mais { etudid : dec } for etudid in etudids: # etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud: Identite = Identite.query.get(etudid) Se = sco_cursus.get_situation_etud_cursus( etud.to_dict_scodoc7(), formsemestre_id ) semestre_non_terminal = semestre_non_terminal or Se.semestre_non_terminal d = {} d["identite"] = nt.identdict[etudid] d["etat"] = nt.get_etud_etat( etudid ) # I|D|DEF (inscription ou démission ou défaillant) d["decision_sem"] = nt.get_etud_decision_sem(etudid) d["decisions_ue"] = nt.get_etud_decision_ues(etudid) if formsemestre.formation.is_apc(): d.update(but_validations.dict_decision_jury(etud, formsemestre)) d["last_formsemestre_id"] = Se.get_semestres()[ -1 ] # id du dernier semestre (chronologiquement) dans lequel il a été inscrit ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid) d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values()) ects_by_ue_code = _comp_ects_by_ue_code(nt, d["decisions_ue"]) d["sum_ects"] = _sum_ects_dicts(ects_capitalises_by_ue_code, ects_by_ue_code) if d["decision_sem"] and sco_codes_parcours.code_semestre_validant( d["decision_sem"]["code"] ): d["mention"] = scu.get_mention(nt.get_etud_moy_gen(etudid)) else: d["mention"] = "" # Versions "en français": (avec les UE capitalisées d'ailleurs) dec_ue_list = _descr_decisions_ues( nt, etudid, d["decisions_ue"], d["decision_sem"] ) d["decisions_ue_nb"] = len( dec_ue_list ) # avec les UE capitalisées, donc des éventuels doublons # Mais sur la description (eg sur les bulletins), on ne veut pas # afficher ces doublons: on uniquifie sur ue_code _codes = set() ue_uniq = [] for ue in dec_ue_list: if ue["ue_code"] not in _codes: ue_uniq.append(ue) _codes.add(ue["ue_code"]) d["decisions_ue_descr"] = ", ".join([ue["acronyme"] for ue in ue_uniq]) if nt.is_apc: d["decision_sem_descr"] = "" # pas de validation de semestre en BUT else: d["decision_sem_descr"] = _descr_decision_sem(d["etat"], d["decision_sem"]) autorisations = ScolarAutorisationInscription.query.filter_by( etudid=etudid, origin_formsemestre_id=formsemestre_id ).all() d["autorisations"] = [a.to_dict() for a in autorisations] d["autorisations_descr"] = descr_autorisations(autorisations) d["validation_parcours"] = Se.parcours_validated() d["parcours"] = Se.get_parcours_descr(filter_futur=True) if with_parcours_decisions: d["parcours_decisions"] = Se.get_parcours_decisions() # Observations sur les compensations: compensators = sco_cursus_dut.scolar_formsemestre_validation_list( cnx, args={"compense_formsemestre_id": formsemestre_id, "etudid": etudid} ) obs = [] for compensator in compensators: # nb: il ne devrait y en avoir qu'un ! csem = sco_formsemestre.get_formsemestre(compensator["formsemestre_id"]) obs.append( "%s compensé par %s (%s)" % (sem["sem_id_txt"], csem["sem_id_txt"], csem["anneescolaire"]) ) if d["decision_sem"] and d["decision_sem"]["compense_formsemestre_id"]: compensed = sco_formsemestre.get_formsemestre( d["decision_sem"]["compense_formsemestre_id"] ) obs.append( f"""{sem["sem_id_txt"]} compense {compensed["sem_id_txt"]} ({compensed["anneescolaire"]})""" ) d["observation"] = ", ".join(obs) # Cherche la date de decision (sem ou UE) la plus récente: if d["decision_sem"]: date = ndb.DateDMYtoISO(d["decision_sem"]["event_date"]) if date and date > max_date: # decision plus recente max_date = date if d["decisions_ue"]: for dec_ue in d["decisions_ue"].values(): if dec_ue: date = ndb.DateDMYtoISO(dec_ue["event_date"]) if date and date > max_date: # decision plus recente max_date = date # Code semestre precedent if with_prev: # optionnel car un peu long... info = sco_etud.get_etud_info(etudid=etudid, filled=True) if not info: continue # should not occur etud = info[0] if Se.prev and Se.prev_decision: d["prev_decision_sem"] = Se.prev_decision d["prev_code"] = Se.prev_decision["code"] d["prev_code_descr"] = _descr_decision_sem("I", Se.prev_decision) d["prev"] = Se.prev has_prev = True else: d["prev_decision_sem"] = None d["prev_code"] = "" d["prev_code_descr"] = "" d["Se"] = Se decisions.append(d) D[etudid] = d return { "date": ndb.DateISOtoDMY(max_date), "formsemestre": sem, "is_apc": nt.is_apc, "has_prev": has_prev, "semestre_non_terminal": semestre_non_terminal, "formation": sco_formations.formation_list( args={"formation_id": sem["formation_id"]} )[0], "decisions": decisions, "decisions_dict": D, } def pvjury_table( dpv, only_diplome=False, anonymous=False, with_parcours_decisions=False, with_paragraph_nom=False, # cellule paragraphe avec nom, date, code NIP ): """idem mais rend list de dicts Si only_diplome, n'extrait que les etudiants qui valident leur diplome. """ sem = dpv["formsemestre"] formsemestre_id = sem["formsemestre_id"] sem_id_txt_sp = sem["sem_id_txt"] if sem_id_txt_sp: sem_id_txt_sp = " " + sem_id_txt_sp titles = { "etudid": "etudid", "code_nip": "NIP", "nomprenom": "Nom", # si with_paragraph_nom, sera un Paragraph "parcours": "Parcours", "decision": "Décision" + sem_id_txt_sp, "mention": "Mention", "ue_cap": "UE" + sem_id_txt_sp + " capitalisées", "ects": "ECTS", "devenir": "Devenir", "validation_parcours_code": "Résultat au diplôme", "observations": "Observations", } if anonymous: titles["nomprenom"] = "Code" columns_ids = ["nomprenom", "parcours"] if with_parcours_decisions: all_idx = set() for e in dpv["decisions"]: all_idx |= set(e["parcours_decisions"].keys()) sem_ids = sorted(all_idx) for i in sem_ids: if i != NO_SEMESTRE_ID: titles[i] = "S%d" % i else: titles[i] = "S" # pas très parlant ? columns_ids += [i] if dpv["has_prev"]: id_prev = sem["semestre_id"] - 1 # numero du semestre precedent titles["prev_decision"] = "Décision S%s" % id_prev columns_ids += ["prev_decision"] if not dpv["is_apc"]: # Décision de jury sur le semestre, sauf en BUT columns_ids += ["decision"] if sco_preferences.get_preference("bul_show_mention", formsemestre_id): columns_ids += ["mention"] columns_ids += ["ue_cap"] if sco_preferences.get_preference("bul_show_ects", formsemestre_id): columns_ids += ["ects"] # XXX if not dpv["semestre_non_terminal"]: # La colonne doit être présente: redoublants validant leur diplome # en répétant un semestre ancien: exemple: S1 (ADM), S2 (ADM), S3 (AJ), S4 (ADM), S3 (ADM)=> diplôme columns_ids += ["validation_parcours_code"] columns_ids += ["devenir"] columns_ids += ["observations"] lines = [] for e in dpv["decisions"]: sco_etud.format_etud_ident(e["identite"]) l = { "etudid": e["identite"]["etudid"], "code_nip": e["identite"]["code_nip"], "nomprenom": e["identite"]["nomprenom"], "_nomprenom_target": url_for( "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["identite"]["etudid"], ), "_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """, "parcours": e["parcours"], "decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]), "ue_cap": e["decisions_ue_descr"], "validation_parcours_code": "ADM" if e["validation_parcours"] else "", "devenir": e["autorisations_descr"], "observations": ndb.unquote(e["observation"]), "mention": e["mention"], "ects": str(e["sum_ects"]), } if with_paragraph_nom: cell_style = styles.ParagraphStyle({}) cell_style.fontSize = sco_preferences.get_preference( "SCOLAR_FONT_SIZE", formsemestre_id ) cell_style.fontName = sco_preferences.get_preference( "PV_FONTNAME", formsemestre_id ) cell_style.leading = 1.0 * sco_preferences.get_preference( "SCOLAR_FONT_SIZE", formsemestre_id ) # vertical space i = e["identite"] l["nomprenom"] = [ Paragraph(sco_pdf.SU(i["nomprenom"]), cell_style), Paragraph(sco_pdf.SU(i["code_nip"]), cell_style), Paragraph( sco_pdf.SU( "Né le %s" % i["date_naissance"] + (" à %s" % i["lieu_naissance"] if i["lieu_naissance"] else "") + (" (%s)" % i["dept_naissance"] if i["dept_naissance"] else "") ), cell_style, ), ] if anonymous: # Mode anonyme: affiche INE ou sinon NIP, ou id l["nomprenom"] = ( e["identite"]["code_ine"] or e["identite"]["code_nip"] or e["identite"]["etudid"] ) if with_parcours_decisions: for i in e[ "parcours_decisions" ]: # or equivalently: l.update(e['parcours_decisions']) l[i] = e["parcours_decisions"][i] if e["validation_parcours"]: l["devenir"] = "Diplôme obtenu" if dpv["has_prev"]: l["prev_decision"] = _descr_decision_sem_abbrev( None, e["prev_decision_sem"] ) if e["validation_parcours"] or not only_diplome: lines.append(l) return lines, titles, columns_ids def formsemestre_pvjury(formsemestre_id, format="html", publish=True): """Page récapitulant les décisions de jury""" # Bretelle provisoire pour BUT 9.3.0 # XXX TODO formsemestre = FormSemestre.query.get_or_404(formsemestre_id) is_apc = formsemestre.formation.is_apc() if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0: from app.but import jury_but_recap return jury_but_recap.formsemestre_saisie_jury_but( formsemestre, read_only=True, mode="recap" ) # /XXX footer = html_sco_header.sco_footer() dpv = dict_pvjury(formsemestre_id, with_prev=True) if not dpv: if format == "html": return ( html_sco_header.sco_header() + "
(dernière modif le %s)
""" % dpv["date"], ] H.append( 'Utiliser cette page pour éditer des versions provisoires des PV. Il est recommandé d'archiver les versions définitives: voir cette page
""" % formsemestre_id, ] F = [ """Voir aussi si besoin les réglages sur la page "Paramétrage" (accessible à l'administrateur du département).
""", html_sco_header.sco_footer(), ] descr = descrform_pvjury(sem) if etudid: descr.append(("etudid", {"input_type": "hidden"})) if groups_infos: menu_choix_groupe = ( """ """ ) else: menu_choix_groupe = "" # un seul etudiant à editer tf = TrivialFormulator( request.base_url, scu.get_request_args(), descr, cancelbutton="Annuler", method="get", submitlabel="Générer document", name="tf", formid="group_selector", html_foot_markup=menu_choix_groupe, ) if tf[0] == 0: return "\n".join(H) + "\n" + tf[1] + "\n".join(F) elif tf[0] == -1: return flask.redirect( "formsemestre_pvjury?formsemestre_id=%s" % (formsemestre_id) ) else: # submit dpv = dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True) if tf[2]["showTitle"]: tf[2]["showTitle"] = True else: tf[2]["showTitle"] = False if tf[2]["anonymous"]: tf[2]["anonymous"] = True else: tf[2]["anonymous"] = False try: PDFLOCK.acquire() pdfdoc = sco_pvpdf.pvjury_pdf( dpv, numeroArrete=tf[2]["numeroArrete"], VDICode=tf[2]["VDICode"], date_commission=tf[2]["date_commission"], date_jury=tf[2]["date_jury"], showTitle=tf[2]["showTitle"], pv_title=tf[2]["pv_title"], with_paragraph_nom=tf[2]["with_paragraph_nom"], anonymous=tf[2]["anonymous"], ) finally: PDFLOCK.release() sem = sco_formsemestre.get_formsemestre(formsemestre_id) dt = time.strftime("%Y-%m-%d") if groups_infos: groups_filename = "-" + groups_infos.groups_filename else: groups_filename = "" filename = "PV-%s%s-%s.pdf" % (sem["titre_num"], groups_filename, dt) return scu.sendPDFFile(pdfdoc, filename) def descrform_pvjury(sem): """Définition de formulaire pour PV jury PDF""" F = sco_formations.formation_list(formation_id=sem["formation_id"])[0] return [ ( "date_commission", { "input_type": "text", "size": 50, "title": "Date de la commission", "explanation": "(format libre)", }, ), ( "date_jury", { "input_type": "text", "size": 50, "title": "Date du Jury", "explanation": "(si le jury a eu lieu)", }, ), ( "numeroArrete", { "input_type": "text", "size": 50, "title": "Numéro de l'arrêté du président", "explanation": "le président de l'Université prend chaque année un arrêté formant les jurys", }, ), ( "VDICode", { "input_type": "text", "size": 15, "title": "VDI et Code", "explanation": "VDI et code du diplôme Apogée (format libre, n'est pas vérifié par ScoDoc)", }, ), ( "pv_title", { "input_type": "text", "size": 64, "title": "Titre du PV", "explanation": "par défaut, titre officiel de la formation", "default": F["titre_officiel"], }, ), ( "showTitle", { "input_type": "checkbox", "title": "Indiquer en plus le titre du semestre sur le PV", "explanation": '(le titre est "%s")' % sem["titre"], "labels": [""], "allowed_values": ("1",), }, ), ( "with_paragraph_nom", { "input_type": "boolcheckbox", "title": "Avec date naissance et code", "explanation": "ajoute informations sous le nom", "default": True, }, ), ( "anonymous", { "input_type": "checkbox", "title": "PV anonyme", "explanation": "remplace nom par code étudiant (INE ou NIP)", "labels": [""], "allowed_values": ("1",), }, ), ("formsemestre_id", {"input_type": "hidden"}), ] def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): "Lettres avis jury en PDF" formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) if not group_ids: # tous les inscrits du semestre group_ids = [sco_groups.get_default_group(formsemestre_id)] groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids, formsemestre_id=formsemestre_id ) etudids = [m["etudid"] for m in groups_infos.members] H = [ html_sco_header.html_sem_header( "Édition des lettres individuelles", javascripts=sco_groups_view.JAVASCRIPTS, cssstyles=sco_groups_view.CSSSTYLES, init_qtip=True, ), f"""Utiliser cette page pour éditer des versions provisoires des PV. Il est recommandé d'archiver les versions définitives: voir cette page
""", ] F = html_sco_header.sco_footer() descr = descrform_lettres_individuelles() menu_choix_groupe = ( """ """ ) tf = TrivialFormulator( request.base_url, scu.get_request_args(), descr, cancelbutton="Annuler", method="POST", submitlabel="Générer document", name="tf", formid="group_selector", html_foot_markup=menu_choix_groupe, ) if tf[0] == 0: return "\n".join(H) + "\n" + tf[1] + F elif tf[0] == -1: return flask.redirect( url_for( "notes.formsemestre_pvjury", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, ) ) else: # submit sf = tf[2]["signature"] signature = sf.read() # image of signature try: PDFLOCK.acquire() pdfdoc = sco_pvpdf.pdf_lettres_individuelles( formsemestre_id, etudids=etudids, date_jury=tf[2]["date_jury"], date_commission=tf[2]["date_commission"], signature=signature, ) finally: PDFLOCK.release() if not pdfdoc: flash("Aucun étudiant n'a de décision de jury !") return flask.redirect( url_for( "notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, ) ) groups_filename = "-" + groups_infos.groups_filename filename = f"""lettres-{formsemestre.titre_num()}{groups_filename}-{time.strftime("%Y-%m-%d")}.pdf""" return scu.sendPDFFile(pdfdoc, filename) def descrform_lettres_individuelles(): return [ ( "date_commission", { "input_type": "text", "size": 50, "title": "Date de la commission", "explanation": "(format libre)", }, ), ( "date_jury", { "input_type": "text", "size": 50, "title": "Date du Jury", "explanation": "(si le jury a eu lieu)", }, ), ( "signature", { "input_type": "file", "size": 30, "explanation": "optionnel: image scannée de la signature", }, ), ("formsemestre_id", {"input_type": "hidden"}), ]