ScoDoc-Lille/app/scodoc/sco_bulletins.py

1326 lines
49 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Génération des bulletins de notes
"""
import collections
import email
import time
import numpy as np
from flask import g, request, Response
from flask import flash, render_template, url_for
from flask_json import json_response
from flask_login import current_user
from app import db, email
from app import log
from app.scodoc.sco_utils import json_error
from app.but import bulletin_but
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models import (
ApcParcours,
Evaluation,
Formation,
FormSemestre,
Identite,
ModuleImplInscription,
ScoDocSiteConfig,
)
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError, ScoTemporaryError
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_assiduites
from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_bulletins_xml
from app.scodoc import codes_cursus
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc import sco_pv_dict
from app.scodoc import sco_users
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType, fmt_note
import app.scodoc.notesdb as ndb
def get_formsemestre_bulletin_etud_json(
formsemestre: FormSemestre,
etud: Identite,
force_publishing=False,
version="long",
) -> Response:
"""Le JSON du bulletin d'un étudiant, quel que soit le type de formation."""
if formsemestre.formation.is_apc():
bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
if not etud.id in bulletins_sem.res.identdict:
return json_error(404, "get_formsemestre_bulletin_etud_json: invalid etud")
return json_response(
data_=bulletins_sem.bulletin_etud(
etud,
force_publishing=force_publishing,
version=version,
)
)
return formsemestre_bulletinetud(
etud,
formsemestre_id=formsemestre.id,
fmt="json",
version=version,
xml_with_decisions=True,
force_publishing=force_publishing,
)
# -------------
def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict:
"""Construit dictionnaire avec valeurs pour substitution des textes
(preferences bul_pdf_*)
"""
C = formsemestre.get_infos_dict()
C["responsable"] = formsemestre.responsables_str()
C["anneesem"] = C["annee"] # backward compat
C.update(etud)
# copie preferences
for name in sco_preferences.get_base_preferences().prefs_name:
C[name] = sco_preferences.get_preference(name, formsemestre.id)
# ajoute groupes et group_0, group_1, ...
sco_groups.etud_add_group_infos(etud, formsemestre.id)
C["groupes"] = etud["groupes"]
n = 0
for partition_id in etud["partitions"]:
C["group_%d" % n] = etud["partitions"][partition_id]["group_name"]
n += 1
# ajoute date courante
t = time.localtime()
C["date_dmy"] = time.strftime(scu.DATE_FMT, t)
C["date_iso"] = time.strftime("%Y-%m-%d", t)
return C
def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
"""Collecte informations pour bulletin de notes
Retourne un dictionnaire (avec valeur par défaut chaine vide).
Le contenu du dictionnaire dépend des options (rangs, ...)
et de la version choisie (short, long, selectedevals).
Cette fonction est utilisée pour les bulletins CLASSIQUES (DUT, ...)
en HTML et PDF, mais pas ceux en XML.
"""
from app.scodoc import sco_assiduites
if version not in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !")
prefs = sco_preferences.SemPreferences(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if not nt.get_etud_etat(etudid):
raise ScoValueError("Étudiant non inscrit à ce semestre")
I = collections.defaultdict(str)
I["etudid"] = etudid
I["formsemestre_id"] = formsemestre_id
I["sem"] = formsemestre.get_infos_dict()
I["server_name"] = request.url_root
# Formation et parcours
if I["sem"]["formation_id"]:
formation_dict = Formation.query.get_or_404(I["sem"]["formation_id"]).to_dict()
else: # what's the fuck ?
formation_dict = {
"acronyme": "?",
"code_specialite": "",
"dept_id": 1,
"formation_code": "?",
"formation_id": -1,
"id": -1,
"referentiel_competence_id": None,
"titre": "?",
"titre_officiel": "?",
"type_parcours": 0,
"version": 0,
}
I["formation"] = formation_dict
I["parcours"] = codes_cursus.get_cursus_from_code(I["formation"]["type_parcours"])
# Infos sur l'etudiant
I["etud"] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
I["descr_situation"] = I["etud"]["inscriptionstr"]
if I["etud"]["inscription_formsemestre_id"]:
I[
"descr_situation_html"
] = f"""<a href="{url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=I["etud"]["inscription_formsemestre_id"],
)}">{I["descr_situation"]}</a>"""
else:
I["descr_situation_html"] = I["descr_situation"]
# Groupes:
partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False)
partitions_etud_groups = {} # { partition_id : { etudid : group } }
for partition in partitions:
pid = partition["partition_id"]
partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid)
# --- Absences
_, I["nbabsjust"], I["nbabs"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
# --- Decision Jury
infos, dpv = etud_descr_situation_semestre(
etudid,
formsemestre,
fmt="html",
show_date_inscr=prefs["bul_show_date_inscr"],
show_decisions=prefs["bul_show_decision"],
show_uevalid=prefs["bul_show_uevalid"],
show_mention=prefs["bul_show_mention"],
)
I.update(infos)
I["etud_etat_html"] = _get_etud_etat_html(
formsemestre.etuds_inscriptions[etudid].etat
)
I["etud_etat"] = nt.get_etud_etat(etudid)
I["filigranne"] = sco_bulletins_pdf.get_filigranne(
I["etud_etat"], prefs, decision_sem=I["decision_sem"]
)
I["demission"] = ""
if I["etud_etat"] == scu.DEMISSION:
I["demission"] = "(Démission)"
elif I["etud_etat"] == codes_cursus.DEF:
I["demission"] = "(Défaillant)"
# --- Notes
ues = nt.get_ues_stat_dict()
modimpls = nt.get_modimpls_dict()
moy_gen = nt.get_etud_moy_gen(etudid)
I["nb_inscrits"] = len(nt.etud_moy_gen_ranks)
I["moy_gen"] = scu.fmt_note(moy_gen)
I["moy_min"] = scu.fmt_note(nt.moy_min)
I["moy_max"] = scu.fmt_note(nt.moy_max)
I["mention"] = ""
if dpv:
decision_sem = dpv["decisions"][0]["decision_sem"]
if decision_sem and codes_cursus.code_semestre_validant(decision_sem["code"]):
I["mention"] = scu.get_mention(moy_gen)
if dpv and dpv["decisions"][0]:
I["sum_ects"] = dpv["decisions"][0]["sum_ects"]
I["sum_ects_capitalises"] = dpv["decisions"][0]["sum_ects_capitalises"]
else:
I["sum_ects"] = 0
I["sum_ects_capitalises"] = 0
I["moy_moy"] = scu.fmt_note(nt.moy_moy) # moyenne des moyennes generales
if (not isinstance(moy_gen, str)) and (not isinstance(nt.moy_moy, str)):
I["moy_gen_bargraph_html"] = "&nbsp;" + htmlutils.horizontal_bargraph(
moy_gen * 5, nt.moy_moy * 5
)
else:
I["moy_gen_bargraph_html"] = ""
if prefs["bul_show_rangs"]:
rang = str(nt.get_etud_rang(etudid))
else:
rang = ""
rang_gr, ninscrits_gr, gr_name = get_etud_rangs_groups(
etudid, partitions, partitions_etud_groups, nt
)
if nt.get_moduleimpls_attente():
# n'affiche pas le rang sur le bulletin s'il y a des
# notes en attente dans ce semestre
rang = scu.RANG_ATTENTE_STR
rang_gr = collections.defaultdict(lambda: scu.RANG_ATTENTE_STR)
inscriptions_counts = nt.get_inscriptions_counts()
I["rang"] = rang
I["rang_gr"] = rang_gr
I["gr_name"] = gr_name
I["ninscrits_gr"] = ninscrits_gr
I["nbetuds"] = len(nt.etud_moy_gen_ranks)
I["nb_demissions"] = inscriptions_counts[scu.DEMISSION]
I["nb_defaillants"] = inscriptions_counts[scu.DEF]
if prefs["bul_show_rangs"]:
I["rang_nt"] = "%s / %d" % (
rang,
inscriptions_counts[scu.INSCRIT],
)
I["rang_txt"] = "Rang " + I["rang_nt"]
else:
I["rang_nt"], I["rang_txt"] = "", ""
I["note_max"] = 20.0 # notes toujours sur 20
I["bonus_sport_culture"] = nt.bonus[etudid] if nt.bonus is not None else 0.0
# Liste les UE / modules /evals
I["ues"] = []
I["matieres_modules"] = {}
I["matieres_modules_capitalized"] = {}
for ue in ues:
u = ue.copy()
ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"])
if (
ModuleImplInscription.nb_inscriptions_dans_ue(
formsemestre_id, etudid, ue["ue_id"]
)
== 0
) and not ue_status["is_capitalized"]:
# saute les UE où l'on est pas inscrit et n'avons pas de capitalisation
continue
u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...}
if ue["type"] != codes_cursus.UE_SPORT:
u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"])
else:
if nt.bonus is not None:
x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True)
else:
x = ""
if isinstance(x, str):
if nt.bonus_ues is None:
u["cur_moy_ue_txt"] = "pas de bonus"
else:
u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs"
else:
u["cur_moy_ue_txt"] = f"bonus de {fmt_note(x)} points"
if nt.bonus_ues is not None:
u["cur_moy_ue_txt"] += " (+ues)"
u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"])
if ue_status["coef_ue"] is not None:
u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"])
else:
u["coef_ue_txt"] = "-"
if (
dpv
and dpv["decisions"][0]["decisions_ue"]
and ue["ue_id"] in dpv["decisions"][0]["decisions_ue"]
):
u["ects"] = dpv["decisions"][0]["decisions_ue"][ue["ue_id"]]["ects"]
if ue["type"] == codes_cursus.UE_ELECTIVE:
u["ects"] = (
"%g+" % u["ects"]
) # ajoute un "+" pour indiquer ECTS d'une UE élective
else:
if ue_status["is_capitalized"]:
u["ects"] = ue_status["ue"].get("ects", "-")
else:
u["ects"] = "-"
modules, ue_attente = _ue_mod_bulletin(
etudid, formsemestre_id, ue["ue_id"], modimpls, nt, version
)
#
u["modules"] = modules # detail des modules de l'UE (dans le semestre courant)
# auparavant on filtrait les modules sans notes
# si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules)
u["modules_capitalized"] = (
[]
) # modules de l'UE capitalisée (liste vide si pas capitalisée)
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
u["ue_descr_txt"] = (
f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
)
u["ue_descr_html"] = (
f"""<a href="{ url_for( 'notes.formsemestre_bulletinetud',
scodoc_dept=g.scodoc_dept, formsemestre_id=sem_origin.id, etudid=etudid)}"
title="{sem_origin.titre_annee()}" class="bull_link"
>{u["ue_descr_txt"]}</a>
"""
if sem_origin
else ""
)
if ue_status["moy"] != "NA":
# détail des modules de l'UE capitalisée
formsemestre_cap = db.session.get(
FormSemestre, ue_status["formsemestre_id"]
)
nt_cap: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_cap
)
u["modules_capitalized"], _ = _ue_mod_bulletin(
etudid,
formsemestre_id,
ue_status["capitalized_ue_id"],
nt_cap.get_modimpls_dict(),
nt_cap,
version,
)
I["matieres_modules_capitalized"].update(
_sort_mod_by_matiere(u["modules_capitalized"], nt_cap, etudid)
)
else:
if prefs["bul_show_ue_rangs"] and ue["type"] != codes_cursus.UE_SPORT:
if ue_attente or nt.ue_rangs[ue["ue_id"]][0] is None:
u["ue_descr_txt"] = "%s/%s" % (
scu.RANG_ATTENTE_STR,
nt.ue_rangs[ue["ue_id"]][1],
)
else:
u["ue_descr_txt"] = "%s/%s" % (
nt.ue_rangs[ue["ue_id"]][0][etudid],
nt.ue_rangs[ue["ue_id"]][1],
)
u["ue_descr_html"] = u["ue_descr_txt"]
else:
u["ue_descr_txt"] = u["ue_descr_html"] = ""
if ue_status["is_capitalized"] or modules:
I["ues"].append(u) # ne montre pas les UE si non inscrit
# Accès par matieres
# En #sco92, pas d'information
I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid))
#
C = make_context_dict(formsemestre, I["etud"])
C.update(I)
#
# log( 'C = \n%s\n' % pprint.pformat(C) ) # tres pratique pour voir toutes les infos dispo
return C
def _get_etud_etat_html(etat: str) -> str:
"""chaine html représentant l'état (backward compat sco7)"""
if etat == scu.INSCRIT:
return ""
elif etat == scu.DEMISSION:
return ' <font color="red">(DEMISSIONNAIRE)</font> '
elif etat == scu.DEF:
return ' <font color="red">(DEFAILLANT)</font> '
else:
return f' <font color="red">({etat})</font> '
def _sort_mod_by_matiere(modlist, nt, etudid):
matmod = {} # { matiere_id : [] }
for mod in modlist:
matiere_id = mod["module"]["matiere_id"]
if matiere_id not in matmod:
moy = nt.get_etud_mat_moy(matiere_id, etudid)
matmod[matiere_id] = {
"titre": mod["mat"]["titre"],
"modules": mod,
"moy": moy,
"moy_txt": scu.fmt_note(moy),
}
return matmod
def _ue_mod_bulletin(
etudid, formsemestre_id, ue_id, modimpls, nt: NotesTableCompat, version
):
"""Infos sur les modules (et évaluations) dans une UE
(ajoute les informations aux modimpls)
Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit).
"""
bul_show_mod_rangs = sco_preferences.get_preference(
"bul_show_mod_rangs", formsemestre_id
)
bul_show_abs_modules = sco_preferences.get_preference(
"bul_show_abs_modules", formsemestre_id
)
if bul_show_abs_modules:
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
debut_sem = ndb.DateDMYtoISO(sem["date_debut"])
fin_sem = ndb.DateDMYtoISO(sem["date_fin"])
ue_modimpls = [mod for mod in modimpls if mod["module"]["ue_id"] == ue_id]
mods = [] # result
ue_attente = False # true si une eval en attente dans cette UE
for modimpl in ue_modimpls:
modimpl_results = nt.modimpls_results.get(modimpl["moduleimpl_id"])
mod_attente = False
mod = modimpl.copy()
mod_moy = nt.get_etud_mod_moy(
modimpl["moduleimpl_id"], etudid
) # peut etre 'NI'
is_malus = mod["module"]["module_type"] == ModuleType.MALUS
if bul_show_abs_modules:
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count(etudid, sem)
mod_abs = [nbabs, nbabsjust]
mod["mod_abs_txt"] = scu.fmt_abs(mod_abs)
else:
mod["mod_abs_txt"] = ""
mod["mod_moy_txt"] = scu.fmt_note(mod_moy)
if mod["mod_moy_txt"][:2] == "NA":
mod["mod_moy_txt"] = "-"
if is_malus:
if isinstance(mod_moy, str):
mod["mod_moy_txt"] = "-"
mod["mod_coef_txt"] = "-"
elif mod_moy > 0:
mod["mod_moy_txt"] = scu.fmt_note(mod_moy)
mod["mod_coef_txt"] = "Malus"
elif mod_moy < 0:
mod["mod_moy_txt"] = scu.fmt_note(-mod_moy)
mod["mod_coef_txt"] = "Bonus"
else:
mod["mod_moy_txt"] = "-"
mod["mod_coef_txt"] = "-"
else:
mod["mod_coef_txt"] = scu.fmt_coef(modimpl["module"]["coefficient"])
if mod["mod_moy_txt"] != "NI": # ne montre pas les modules 'non inscrit'
mods.append(mod)
if is_malus: # n'affiche pas les statistiques sur les modules malus
mod["stats"] = {
"moy": "",
"max": "",
"min": "",
"nb_notes": "",
"nb_missing": "",
"nb_valid_evals": "",
}
else:
mod["stats"] = nt.get_mod_stats(modimpl["moduleimpl_id"])
mod["mod_descr_txt"] = "Module %s, coef. %s (%s)" % (
modimpl["module"]["titre"],
scu.fmt_coef(modimpl["module"]["coefficient"]),
sco_users.user_info(modimpl["responsable_id"])["nomcomplet"],
)
link_mod = f"""<a class="bull_link" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl["moduleimpl_id"]
)
}" title="{mod["mod_descr_txt"]}">"""
if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id):
mod["code"] = modimpl["module"]["code"]
mod["code_html"] = link_mod + (mod["code"] or "") + "</a>"
else:
mod["code"] = mod["code_html"] = ""
mod["name"] = (
modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or ""
)
mod["name_html"] = link_mod + mod["name"] + "</a>"
mod_descr = "Module %s, coef. %s (%s)" % (
modimpl["module"]["titre"],
scu.fmt_coef(modimpl["module"]["coefficient"]),
sco_users.user_info(modimpl["responsable_id"])["nomcomplet"],
)
link_mod = (
'<a class="bull_link" href="moduleimpl_status?moduleimpl_id=%s" title="%s">'
% (modimpl["moduleimpl_id"], mod_descr)
)
if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id):
mod["code_txt"] = modimpl["module"]["code"] or ""
mod["code_html"] = link_mod + mod["code_txt"] + "</a>"
else:
mod["code_txt"] = ""
mod["code_html"] = ""
# Evaluations: notes de chaque eval
evaluations_completes = nt.get_modimpl_evaluations_completes(
modimpl["moduleimpl_id"]
)
# On liste séparément les éval. complètes ou non
mod["evaluations"] = []
mod["evaluations_incompletes"] = []
complete_eval_ids = {e.id for e in evaluations_completes}
all_evals: list[Evaluation] = Evaluation.query.filter_by(
moduleimpl_id=modimpl["moduleimpl_id"]
).order_by(Evaluation.numero, Evaluation.date_debut)
# (plus ancienne d'abord)
for e in all_evals:
if e.is_blocked():
continue # ignore évaluations bloquées
if not e.visibulletin and version != "long":
continue
is_complete = e.id in complete_eval_ids
e_dict = e.to_dict_bul()
# Note à l'évaluation:
val = modimpl_results.evals_notes[e.id].get(etudid, "NP")
# Affiche "bonus" quand les points de malus sont négatifs
if is_malus:
if val == "NP":
e_dict["name"] = "Points de bonus/malus sur cette UE"
elif val > 0:
e_dict["name"] = "Points de malus sur cette UE"
else:
e_dict["name"] = "Points de bonus sur cette UE"
else:
e_dict[
"name"
] = f"""{e.description or ""} {
e.descr_date()
if e.date_debut and not is_complete
else ""}"""
e_dict["target_html"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
fmt="html",
tf_submitted=1,
)
e_dict[
"name_html"
] = f"""<a class="bull_link" href="{
e_dict['target_html']}">{e_dict['name']}</a>"""
if is_complete: # évaluation complète
# val est NP si etud demissionnaire
if val == "NP":
e_dict["note_txt"] = "nd"
e_dict["note_html"] = '<span class="note_nd">nd</span>'
e_dict["coef_txt"] = scu.fmt_coef(e["coefficient"])
else:
# (-0.15) s'affiche "bonus de 0.15"
if is_malus:
val = abs(val)
e_dict["note_txt"] = e_dict["note_html"] = scu.fmt_note(
val, note_max=e.note_max
)
else: # évaluation incomplète: pas de note
e_dict["note_txt"] = e_dict["note_html"] = ""
if is_malus:
e_dict["coef_txt"] = ""
else:
e_dict["coef_txt"] = scu.fmt_coef(e.coefficient)
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
e_dict["coef_txt"] = "rat."
elif e.evaluation_type == Evaluation.EVALUATION_SESSION2:
e_dict["coef_txt"] = "Ses. 2"
if modimpl_results.evaluations_etat[e.id].nb_attente:
mod_attente = True # une eval en attente dans ce module
if ((not is_malus) or (val != "NP")) and (
(
e.evaluation_type == Evaluation.EVALUATION_NORMALE
or not np.isnan(val)
)
):
# ne liste pas les eval malus sans notes
# ni les rattrapages, sessions 2 et bonus si pas de note
if e.id in complete_eval_ids:
mod["evaluations"].append(e_dict)
else:
mod["evaluations_incompletes"].append(e_dict)
# Classement
if (
bul_show_mod_rangs
and (nt.mod_rangs is not None)
and mod["mod_moy_txt"] != "-"
and not is_malus
):
rg = nt.mod_rangs[modimpl["moduleimpl_id"]]
if rg[0] is None:
mod["mod_rang_txt"] = ""
else:
if mod_attente: # nt.get_moduleimpls_attente():
mod["mod_rang"] = scu.RANG_ATTENTE_STR
else:
mod["mod_rang"] = rg[0][etudid]
mod["mod_eff"] = rg[1] # effectif dans ce module
mod["mod_rang_txt"] = "%s/%s" % (mod["mod_rang"], mod["mod_eff"])
else:
mod["mod_rang_txt"] = ""
if mod_attente:
ue_attente = True
return mods, ue_attente
def get_etud_rangs_groups(
etudid: int, partitions, partitions_etud_groups, nt: NotesTableCompat
):
"""Ramene rang et nb inscrits dans chaque partition"""
rang_gr, ninscrits_gr, gr_name = {}, {}, {}
for partition in partitions:
if partition["partition_name"] != None:
partition_id = partition["partition_id"]
if etudid in partitions_etud_groups[partition_id]:
group = partitions_etud_groups[partition_id][etudid]
(
rang_gr[partition_id],
ninscrits_gr[partition_id],
) = nt.get_etud_rang_group(etudid, group["group_id"])
gr_name[partition_id] = group["group_name"]
else: # etudiant non present dans cette partition
rang_gr[partition_id], ninscrits_gr[partition_id] = "", ""
gr_name[partition_id] = ""
return rang_gr, ninscrits_gr, gr_name
def etud_descr_situation_semestre(
etudid,
formsemestre: FormSemestre,
ne="",
fmt="html", # currently unused
show_decisions=True,
show_uevalid=True,
show_date_inscr=True,
show_mention=False,
):
"""Dict décrivant la situation de l'étudiant dans ce semestre.
Si fmt == 'html', peut inclure du balisage html (actuellement inutilisé)
situation : chaine résumant en français la situation de l'étudiant.
Par ex. "Inscrit le 31/12/1999. Décision jury: Validé. ..."
date_inscription : (vide si show_date_inscr est faux)
date_demission : (vide si pas demission ou si show_date_inscr est faux)
descr_inscription : "Inscrit" ou "Pas inscrit[e]"
descr_demission : "Démission le 01/02/2000" ou vide si pas de démission
descr_defaillance : "Défaillant" ou vide si non défaillant.
decision_jury : "Validé", "Ajourné", ... (code semestre)
descr_decision_jury : "Décision jury: Validé" (une phrase)
decision_sem :
decisions_ue : noms (acronymes) des UE validées, séparées par des virgules.
descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid
descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention
parcours_titre, parcours_code, refcomp_specialite, refcomp_specialite_long
"""
# Fonction utilisée par tous les bulletins (APC ou classiques)
infos = collections.defaultdict(str)
# --- Situation et décisions jury
date_inscr, date_dem, date_def = _dates_insc_dem_def(etudid, formsemestre.id)
if show_date_inscr:
if not date_inscr:
infos["date_inscription"] = ""
infos["descr_inscription"] = f"Pas inscrit{ne}"
else:
infos["date_inscription"] = date_inscr
infos["descr_inscription"] = f"Inscrit{ne} le {date_inscr}"
else:
infos["date_inscription"] = ""
infos["descr_inscription"] = ""
infos["descr_defaillance"] = ""
# Parcours BUT
infos["parcours_titre"] = ""
infos["parcours_code"] = ""
infos["refcomp_specialite"] = ""
infos["refcomp_specialite_long"] = ""
if formsemestre.formation.is_apc():
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
try:
parcour_id = res.etuds_parcour_id[etudid]
except KeyError as exc:
log("sco_bulletins: ScoTemporaryError 240222")
raise ScoTemporaryError() from exc
parcour: ApcParcours = (
db.session.get(ApcParcours, parcour_id) if parcour_id is not None else None
)
if parcour:
infos["parcours_titre"] = parcour.libelle or ""
infos["parcours_code"] = parcour.code or ""
refcomp = parcour.referentiel
if refcomp:
infos["refcomp_specialite"] = refcomp.specialite
infos["refcomp_specialite_long"] = refcomp.specialite_long
# Décision: valeurs par defaut vides:
infos["decision_jury"] = infos["descr_decision_jury"] = ""
infos["decision_sem"] = ""
infos["decisions_ue"] = infos["descr_decisions_ue"] = ""
infos["descr_decisions_niveaux"] = infos["descr_decisions_rcue"] = ""
infos["descr_decision_annee"] = ""
if date_dem:
infos["descr_demission"] = f"Démission le {date_dem}."
infos["date_demission"] = date_dem
infos["decision_jury"] = infos["descr_decision_jury"] = "Démission"
infos["situation"] = ". ".join(
[x for x in [infos["descr_inscription"], infos["descr_demission"]] if x]
)
return infos, None # ne donne pas les dec. de jury pour les demissionnaires
if date_def:
infos["descr_defaillance"] = f"Défaillant{ne}"
infos["date_defaillance"] = date_def
infos["descr_decision_jury"] = f"Défaillant{ne}"
dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
if dpv:
infos["decision_sem"] = dpv["decisions"][0]["decision_sem"]
if not show_decisions:
return infos, dpv
# Décisions de jury:
pv = dpv["decisions"][0]
descr_dec = ""
if pv["decision_sem_descr"]:
infos["decision_jury"] = pv["decision_sem_descr"]
infos["descr_decision_jury"] = "Décision jury: " + pv["decision_sem_descr"]
descr_dec = infos["descr_decision_jury"]
else:
infos["descr_decision_jury"] = ""
infos["decision_jury"] = ""
if pv["decisions_ue_descr"] and show_uevalid:
infos["decisions_ue"] = pv["decisions_ue_descr"]
infos["descr_decisions_ue"] = " UE acquises: " + pv["decisions_ue_descr"]
else:
infos["decisions_ue"] = ""
infos["descr_decisions_ue"] = ""
infos["mention"] = pv["mention"]
if pv["mention"] and show_mention:
descr_mention = f"Mention {pv['mention']}"
else:
descr_mention = ""
# Décisions APC / BUT
if pv.get("decision_annee", {}) and sco_preferences.get_preference(
"bul_but_code_annuel", formsemestre.id
):
infos["descr_decision_annee"] = "Décision année: " + pv.get(
"decision_annee", {}
).get("code", "")
else:
infos["descr_decision_annee"] = ""
infos["descr_decisions_rcue"] = pv.get("descr_decisions_rcue", "")
infos["descr_decisions_niveaux"] = pv.get("descr_decisions_niveaux", "")
descr_autorisations = ""
if not pv["validation_parcours"]: # parcours non terminé
if pv["autorisations_descr"]:
descr_autorisations = (
f"Autorisé à s'inscrire en {pv['autorisations_descr']}."
)
else:
descr_dec += " Diplôme obtenu."
_format_situation_fields(
infos,
[
"descr_inscription",
"descr_defaillance",
"descr_decisions_ue",
"descr_decision_annee",
],
[descr_dec, descr_mention, descr_autorisations],
)
return infos, dpv
def _dates_insc_dem_def(etudid, formsemestre_id) -> tuple:
"Cherche les dates d'inscription, démission et défaillance de l'étudiant"
cnx = ndb.GetDBConnexion()
events = sco_etud.scolar_events_list(
cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
)
date_inscr = None
date_dem = None
date_def = None
for event in events:
event_type = event["event_type"]
if event_type == "INSCRIPTION":
if date_inscr:
# plusieurs inscriptions ???
# date_inscr += ', ' + event['event_date'] + ' (!)'
# il y a eu une erreur qui a laissé un event 'inscription'
# on l'efface:
log(
f"etud_descr_situation_semestre: removing duplicate INSCRIPTION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_inscr = event["event_date"]
elif event_type == "DEMISSION":
# assert date_dem == None, 'plusieurs démissions !'
if date_dem: # cela ne peut pas arriver sauf bug (signale a Evry 2013?)
log(
f"etud_descr_situation_semestre: removing duplicate DEMISSION event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_dem = event["event_date"]
elif event_type == "DEFAILLANCE":
if date_def:
log(
f"etud_descr_situation_semestre: removing duplicate DEFAILLANCE event for etudid={etudid} !"
)
sco_etud.scolar_events_delete(cnx, event["event_id"])
else:
date_def = event["event_date"]
return date_inscr, date_dem, date_def
def _format_situation_fields(
infos, field_names: list[str], extra_values: list[str]
) -> None:
"""Réuni les champs pour former le paragraphe "situation", et ajoute la pontuation aux champs."""
infos["situation"] = ". ".join(
x
for x in [infos.get(field_name, "") for field_name in field_names]
+ [field for field in extra_values if field]
if x
)
for field_name in field_names:
field = infos.get(field_name, "")
if field and not field.endswith("."):
infos[field_name] += "."
# ------ Page bulletin
def formsemestre_bulletinetud(
etud: Identite = None,
formsemestre_id=None,
fmt=None,
version="long",
xml_with_decisions=False,
force_publishing=False, # force publication meme si semestre non publie sur "portail"
prefer_mail_perso=False,
):
"""Page bulletin de notes pour
- HTML des formations classiques (non BUT)
- le format "oldjson" (les "json" sont générés à part, voir get_formsemestre_bulletin_etud_json)
- les formats PDF, XML et mail pdf (toutes formations)
Note: le format XML n'est plus maintenu et pour les BUT ne contient pas
toutes les informations. Privilégier le format JSON.
Paramètres:
- version: pour les formations classqiues, versions short/selectedevals/long
- xml_with_decisions: inclue ou non les
- force_publishing: renvoie le bulletin même si semestre non publie sur "portail"
- prefer_mail_perso: pour pdfmail, utilise adresse mail perso en priorité.
"""
fmt = fmt or "html"
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not formsemestre:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
bulletin = do_formsemestre_bulletinetud(
formsemestre,
etud,
fmt=fmt,
version=version,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
prefer_mail_perso=prefer_mail_perso,
)[0]
if fmt not in {"html", "pdfmail"}:
filename = scu.bul_filename(formsemestre, etud)
mime, suffix = scu.get_mime_suffix(fmt)
return scu.send_file(bulletin, filename, mime=mime, suffix=suffix)
elif fmt == "pdfmail":
return ""
H = [
_formsemestre_bulletinetud_header_html(etud, formsemestre, fmt, version),
bulletin,
render_template(
"bul_foot.j2",
appreciations=None, # déjà affichées
css_class="bul_classic_foot",
etud=etud,
formsemestre=formsemestre,
inscription_courante=etud.inscription_courante(),
inscription_str=etud.inscription_descr()["inscription_str"],
),
html_sco_header.sco_footer(),
]
return "".join(H)
def can_send_bulletin_by_mail(formsemestre_id):
"""True if current user is allowed to send a bulletin (pdf) by mail"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
return (
sco_preferences.get_preference("bul_mail_allowed_for_all", formsemestre_id)
or current_user.has_permission(Permission.EditFormSemestre)
or current_user.id in sem["responsables"]
) and not ScoDocSiteConfig.is_bul_pdf_disabled()
def do_formsemestre_bulletinetud(
formsemestre: FormSemestre,
etud: Identite,
version="long", # short, long, selectedevals
fmt=None,
xml_with_decisions: bool = False,
force_publishing: bool = False,
prefer_mail_perso: bool = False,
with_img_signatures_pdf: bool = True,
):
"""Génère le bulletin au format demandé.
Utilisé pour:
- HTML des formations classiques (non BUT)
- le format "oldjson" (les json sont générés à part, voir get_formsemestre_bulletin_etud_json)
- les formats PDF, XML et mail pdf (toutes formations)
Options:
- xml_with_decisions: force décisions dans XML
- force_publishing: force publication meme si semestre non publié sur "portail"
- prefer_mail_perso: mails envoyés sur adresse perso si non vide
- with_img_signatures_pdf: si faux, ne met pas les signatures dans le footer PDF.
Résultat: (bul, filigranne)
où bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json)
et filigranne est un message à placer en "filigranne" (eg "Provisoire").
"""
from app.but import bulletin_but_court
fmt = fmt or "html"
if fmt == "xml":
bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud(
formsemestre.id,
etud.id,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
version=version,
)
return bul, ""
elif fmt == "json": # utilisé pour classic et "oldjson"
bul = sco_bulletins_json.make_json_formsemestre_bulletinetud(
formsemestre.id,
etud.id,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
version=version,
)
return bul, ""
if version.endswith("_mat"):
version = version[:-4] # enlève le "_mat"
if formsemestre.formation.is_apc():
bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
bul_dict = bulletins_sem.bulletin_etud_complet(etud, version=version)
else:
bul_dict = formsemestre_bulletinetud_dict(formsemestre.id, etud.id)
if fmt == "html":
htm, _ = sco_bulletins_generator.make_formsemestre_bulletin_etud(
bul_dict, etud=etud, formsemestre=formsemestre, version=version, fmt="html"
)
return htm, bul_dict["filigranne"]
if fmt == "pdf" or fmt == "pdfpart":
bul, filename = sco_bulletins_generator.make_formsemestre_bulletin_etud(
bul_dict,
etud=etud,
formsemestre=formsemestre,
version=version,
fmt="pdf",
stand_alone=(fmt != "pdfpart"),
with_img_signatures_pdf=with_img_signatures_pdf,
)
if fmt == "pdf":
return (
scu.sendPDFFile(bul, filename),
bul_dict["filigranne"],
) # unused ret. value
else:
return bul, bul_dict["filigranne"]
elif fmt == "pdfmail":
# format pdfmail: envoie le pdf par mail a l'etud, et affiche le html
# check permission
if not can_send_bulletin_by_mail(formsemestre.id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
if version == "butcourt":
pdfdata = bulletin_but_court.bulletin_but_court_pdf_frag(
etud, formsemestre, stand_alone=True
)
filename = scu.bul_filename(formsemestre, etud, prefix="bul-court")
else:
pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletin_etud(
bul_dict,
etud=etud,
formsemestre=formsemestre,
version=version,
fmt="pdf",
)
if prefer_mail_perso:
recipient_addr = (
etud.get_first_email("emailperso") or etud.get_first_email()
)
else:
recipient_addr = etud.get_first_email() or etud.get_first_email(
"emailperso"
)
if not recipient_addr:
flash(f"{etud.nomprenom} n'a pas d'adresse e-mail !")
return False, bul_dict["filigranne"]
else:
mail_bulletin(formsemestre.id, bul_dict, pdfdata, filename, recipient_addr)
flash(f"mail envoyé à {recipient_addr}")
return True, bul_dict["filigranne"]
raise ValueError(f"do_formsemestre_bulletinetud: invalid format ({fmt})")
def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
"""Send bulletin by email to etud
If bul_mail_list_abs pref is true, put list of absences in mail body (text).
"""
etud = infos["etud"]
webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre_id)
dept = scu.unescape_html(
sco_preferences.get_preference("DeptName", formsemestre_id)
)
copy_addr = sco_preferences.get_preference("email_copy_bulletins", formsemestre_id)
intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id)
if intro_mail:
try:
hea = intro_mail % {
"nomprenom": etud["nomprenom"],
"dept": dept,
"webmaster": webmaster,
}
except KeyError as e:
raise ScoValueError(
"format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences"
) from e
else:
hea = ""
if sco_preferences.get_preference("bul_mail_list_abs"):
from app.views.assiduites import generate_bul_list
etud_identite: Identite = Identite.get_etud(etud["etudid"])
form_semestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
hea += "\n\n"
hea += generate_bul_list(etud_identite, form_semestre)
subject = f"""Relevé de notes de {etud["nomprenom"]}"""
recipients = [recipient_addr]
sender = email.get_from_addr()
if copy_addr:
bcc = copy_addr.strip().split(",")
else:
bcc = ""
# Attach pdf
log(f"""mail bulletin a {recipient_addr}""")
email.send_email(
subject,
sender,
recipients,
bcc=bcc,
text_body=hea,
attachments=[
{"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata}
],
)
def make_menu_autres_operations(
formsemestre: FormSemestre, etud: Identite, endpoint: str, version: str
) -> str:
etud_email = etud.get_first_email() or ""
etud_perso = etud.get_first_email("emailperso") or ""
menu_items = [
{
"title": "Réglages bulletins",
"endpoint": "notes.formsemestre_edit_options",
"args": {
"formsemestre_id": formsemestre.id,
# "target_url": url_for(
# "notes.formsemestre_bulletinetud",
# scodoc_dept=g.scodoc_dept,
# formsemestre_id=formsemestre_id,
# etudid=etudid,
# ),
},
"enabled": formsemestre.can_be_edited_by(current_user),
},
{
"title": 'Version papier (pdf, format "%s")'
% sco_bulletins_generator.bulletin_get_class_name_displayed(
formsemestre.id
),
"endpoint": endpoint,
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
"version": version,
"fmt": "pdf",
},
},
{
"title": f"Envoi par mail à {etud_email}",
"endpoint": endpoint,
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
"version": version,
"fmt": "pdfmail",
},
# possible slt si on a un mail...
"enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id),
},
{
"title": f"Envoi par mail à {etud_perso} (adr. personnelle)",
"endpoint": endpoint,
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
"version": version,
"fmt": "pdfmail",
"prefer_mail_perso": 1,
},
# possible slt si on a un mail...
"enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id),
},
{
"title": "Version json",
"endpoint": endpoint,
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
"version": version,
"fmt": "json",
},
},
{
"title": "Version XML",
"endpoint": endpoint,
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
"version": version,
"fmt": "xml",
},
},
{
"title": "Ajouter une appréciation",
"endpoint": "notes.appreciation_add_form",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": (
formsemestre.can_be_edited_by(current_user)
or current_user.has_permission(Permission.EtudInscrit)
),
},
{
"title": "Enregistrer un semestre effectué ailleurs",
"endpoint": "notes.formsemestre_ext_create_form",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": current_user.has_permission(Permission.EditFormSemestre),
},
{
"title": "Gérer les validations d'UEs antérieures",
"endpoint": "notes.formsemestre_validate_previous_ue",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Enregistrer note d'une UE externe",
"endpoint": "notes.external_ue_create_form",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": formsemestre.can_edit_jury()
and not formsemestre.formation.is_apc(),
},
{
"title": "Entrer décisions jury",
"endpoint": "notes.formsemestre_validation_etud_form",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Éditer PV jury",
"endpoint": "notes.formsemestre_pvjury_pdf",
"args": {
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": True,
},
]
return htmlutils.make_menu("Autres opérations", menu_items, alone=True)
def _formsemestre_bulletinetud_header_html(
etud,
formsemestre: FormSemestre,
fmt=None,
version=None,
):
H = [
html_sco_header.sco_header(
page_title=f"Bulletin de {etud.nomprenom}",
javascripts=[
"js/bulletin.js",
"libjs/d3.v3.min.js",
"js/radar_bulletin.js",
],
cssstyles=["css/radar_bulletin.css"],
),
render_template(
"bul_head.j2",
etud=etud,
fmt=fmt,
formsemestre=formsemestre,
menu_autres_operations=make_menu_autres_operations(
etud=etud,
formsemestre=formsemestre,
endpoint="notes.formsemestre_bulletinetud",
version=version,
),
scu=scu,
time=time,
version=version,
),
]
return "\n".join(H)