ScoDoc-PE/app/scodoc/sco_bulletins_standard.py

698 lines
26 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 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
#
##############################################################################
"""Generation du bulletin note au format standard
Nouvelle version juillet 2011: changement de la présentation de la table.
Note sur le PDF:
Les templates utilisent les XML markup tags de ReportLab
(voir ReportLab user guide, page 70 et suivantes), dans lesquels les balises
de la forme %(XXX)s sont remplacées par la valeur de XXX, pour XXX dans:
- preferences du semestre (ou globales) (voir sco_preferences.py)
- champs de formsemestre: titre, date_debut, date_fin, responsable, anneesem
- champs de l'etudiant s(etud, décoré par getEtudInfo)
- demission ("DEMISSION" ou vide)
- situation ("Inscrit le XXX")
Balises img: actuellement interdites.
"""
from flask import url_for, g
from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
from reportlab.lib.units import cm, mm
from reportlab.lib.colors import Color, blue
import app.scodoc.sco_utils as scu
from app.scodoc.sco_pdf import SU, make_paras
from app.scodoc import sco_preferences
from app.scodoc.sco_permissions import Permission
from app.scodoc.codes_cursus import (
UE_COLORS,
UE_DEFAULT_COLOR,
UE_ELECTIVE,
UE_SPORT,
UE_STANDARD,
)
from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_groups
from app.scodoc import sco_evaluations
from app.scodoc import gen_tables
# Important: Le nom de la classe ne doit pas changer (bien le choisir),
# car il sera stocké en base de données (dans les préférences)
class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
description = "standard ScoDoc (version 2011)" # la description doit être courte: elle apparait dans le menu de paramètrage ScoDoc
supported_formats = ["html", "pdf"]
def bul_title_pdf(self) -> list:
"""Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus
"""
objects = sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_title"], self.infos, self.FieldStyle
)
objects.append(
Spacer(1, 5 * mm)
) # impose un espace vertical entre le titre et la table qui suit
return objects
def bul_table(self, format="html"):
"""Génère la table centrale du bulletin de notes
Renvoie:
- en HTML: une chaine
- en PDF: une liste d'objets PLATYPUS (eg instance de Table).
"""
formsemestre_id = self.infos["formsemestre_id"]
colkeys, P, pdf_style, colWidths = self.build_bulletin_table()
T = gen_tables.GenTable(
rows=P,
columns_ids=colkeys,
pdf_table_style=pdf_style,
pdf_col_widths=[colWidths[k] for k in colkeys],
preferences=sco_preferences.SemPreferences(formsemestre_id),
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
)
return T.gen(format=format)
def bul_part_below(self, format="html"):
"""Génère les informations placées sous la table de notes
(absences, appréciations, décisions de jury...)
Renvoie:
- en HTML: une chaine
- en PDF: une liste d'objets platypus
"""
H = [] # html
story = [] # objets platypus
# ----- ABSENCES
if self.preferences["bul_show_abs"]:
nbabs = self.infos["nbabs"]
story.append(Spacer(1, 2 * mm))
if nbabs:
H.append(
"""<p class="bul_abs">
<a href="../Absences/CalAbs?etudid=%(etudid)s" class="bull_link">
<b>Absences :</b> %(nbabs)s demi-journées, dont %(nbabsjust)s justifiées
(pendant ce semestre).
</a></p>
"""
% self.infos
)
story.append(
Paragraph(
SU(
"%(nbabs)s absences (1/2 journées), dont %(nbabsjust)s justifiées."
% self.infos
),
self.CellStyle,
)
)
else:
H.append("""<p class="bul_abs">Pas d'absences signalées.</p>""")
story += make_paras("Pas d'absences signalées.", self.CellStyle)
# ---- APPRECIATIONS
# le dir. des etud peut ajouter des appreciations,
# mais aussi le chef (perm. ScoEtudInscrit)
can_edit_app = (self.authuser.id in self.infos["responsables"]) or (
self.authuser.has_permission(Permission.ScoEtudInscrit)
)
H.append('<div class="bull_appreciations">')
for app in self.infos["appreciations_list"]:
if can_edit_app:
mlink = (
'<a class="stdlink" href="appreciation_add_form?id=%s">modifier</a> <a class="stdlink" href="appreciation_add_form?id=%s&suppress=1">supprimer</a>'
% (app["id"], app["id"])
)
else:
mlink = ""
H.append(
'<p><span class="bull_appreciations_date">%s</span>%s<span class="bull_appreciations_link">%s</span></p>'
% (app["date"], app["comment"], mlink)
)
if can_edit_app:
H.append(
'<p><a class="stdlink" href="appreciation_add_form?etudid=%(etudid)s&formsemestre_id=%(formsemestre_id)s">Ajouter une appréciation</a></p>'
% self.infos
)
H.append("</div>")
# Appréciations sur PDF:
if self.infos.get("appreciations_list", False):
story.append(Spacer(1, 3 * mm))
story.append(
Paragraph(
SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])),
self.CellStyle,
)
)
# ----- DECISION JURY
if self.preferences["bul_show_decision"]:
story += sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_caption"],
self.infos,
self.FieldStyle,
fmt="pdf",
)
field = sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_caption"],
self.infos,
self.FieldStyle,
fmt="html",
)
H.append('<div class="bul_decision">' + field + "</div>")
# -----
if format == "pdf":
if self.scale_table_in_page:
# le scaling (pour tenir sur une page) semble incompatible avec
# le KeepTogether()
return story
else:
return [KeepTogether(story)]
elif format == "html":
return "\n".join(H)
def bul_signatures_pdf(self):
"""Génère les signatures placées en bas du bulletin PDF
Renvoie une liste d'objets platypus
"""
show_left = self.preferences["bul_show_sig_left"]
show_right = self.preferences["bul_show_sig_right"]
if self.with_img_signatures_pdf and (show_left or show_right):
if show_left:
L = [
[
sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_sig_left"],
self.infos,
self.FieldStyle,
)
]
]
else:
L = [[""]]
if show_right:
L[0].append(
sco_bulletins_pdf.process_field(
self.preferences["bul_pdf_sig_right"],
self.infos,
self.FieldStyle,
)
)
else:
L[0].append("")
t = Table(L)
t._argW[0] = 10 * cm # fixe largeur colonne gauche
return [Spacer(1, 1.5 * cm), t] # espace vertical avant signatures
else:
return []
PDF_LINEWIDTH = 0.5
PDF_LINECOLOR = Color(0, 0, 0)
PDF_MODSEPCOLOR = Color(
170 / 255.0, 170 / 255.0, 170 / 255.0
) # lignes séparant les modules
PDF_UE_CUR_BG = Color(
210 / 255.0, 210 / 255.0, 210 / 255.0
) # fond UE courantes non prises en compte
PDF_LIGHT_GRAY = Color(0.75, 0.75, 0.75)
PDF_COLOR_CACHE = {} # (r,g,b) : pdf Color instance
def ue_color(self, ue_type=UE_STANDARD):
rgb_color = UE_COLORS.get(ue_type, UE_DEFAULT_COLOR)
color = self.PDF_COLOR_CACHE.get(rgb_color, None)
if not color:
color = Color(*rgb_color)
self.PDF_COLOR_CACHE[rgb_color] = color
return color
def ue_color_rgb(self, ue_type=UE_STANDARD):
rgb_color = UE_COLORS.get(ue_type, UE_DEFAULT_COLOR)
return "rgb(%d,%d,%d);" % (
rgb_color[0] * 255,
rgb_color[1] * 255,
rgb_color[2] * 255,
)
def build_bulletin_table(self):
"""Génère la table centrale du bulletin de notes classique (pas BUT)
Renvoie: col_keys, P, pdf_style, col_widths
- col_keys: nom des colonnes de la table (clés)
- table: liste de dicts de chaines de caractères
- pdf_style: commandes table Platypus
- col_widths: largeurs de colonnes pour PDF
"""
I = self.infos
P = [] # elems pour générer table avec gen_table (liste de dicts)
formsemestre_id = I["formsemestre_id"]
prefs = sco_preferences.SemPreferences(formsemestre_id)
# Colonnes à afficher:
with_col_abs = prefs["bul_show_abs_modules"]
with_col_minmax = (
prefs["bul_show_minmax"]
or prefs["bul_show_minmax_mod"]
or prefs["bul_show_minmax_eval"]
)
with_col_moypromo = prefs["bul_show_moypromo"]
with_col_rang = prefs["bul_show_rangs"]
with_col_coef = prefs["bul_show_coef"] or prefs["bul_show_ue_coef"]
with_col_ects = prefs["bul_show_ects"]
col_keys = ["titre", "module"] # noms des colonnes à afficher
if with_col_rang:
col_keys += ["rang"]
if with_col_minmax:
col_keys += ["min"]
if with_col_moypromo:
col_keys += ["moy"]
if with_col_minmax:
col_keys += ["max"]
col_keys += ["note"]
if with_col_coef:
col_keys += ["coef"]
if with_col_ects:
col_keys += ["ects"]
if with_col_abs:
col_keys += ["abs"]
colidx = {} # { nom_colonne : indice à partir de 0 } (pour styles platypus)
i = 0
for k in col_keys:
colidx[k] = i
i += 1
if prefs["bul_pdf_mod_colwidth"]:
bul_pdf_mod_colwidth = float(prefs["bul_pdf_mod_colwidth"]) * cm
else:
bul_pdf_mod_colwidth = None
col_widths = {
"titre": None,
"module": bul_pdf_mod_colwidth,
"min": 1.5 * cm,
"moy": 1.5 * cm,
"max": 1.5 * cm,
"rang": 2.2 * cm,
"note": 2 * cm,
"coef": 1.5 * cm,
"ects": 1.5 * cm,
"abs": 2.0 * cm,
}
# HTML specific
linktmpl = (
'<span onclick="toggle_vis_ue(this);" class="toggle_ue">%s</span>&nbsp;'
)
minuslink = linktmpl % scu.icontag("minus_img", border="0", alt="-")
pluslink = linktmpl % scu.icontag("plus_img", border="0", alt="+")
# 1er ligne titres
t = {
"min": "Promotion",
"moy": "",
"max": "",
"rang": "Rang",
"note": "Note/20",
"coef": "Coef.",
"ects": "ECTS",
"abs": "Abs.",
"_min_colspan": 2,
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [],
}
if with_col_moypromo and not with_col_minmax:
t["moy"] = "Promotion"
P.append(t)
# 2eme ligne titres si nécessaire
if with_col_minmax or with_col_abs:
t = {
"min": "min.",
"moy": "moy.",
"max": "max.",
"abs": "(Tot. / J.)",
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [],
}
P.append(t)
P[-1]["_pdf_style"].append(
("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR)
)
# Espacement sous la ligne moyenne générale:
P[-1]["_pdf_style"].append(("BOTTOMPADDING", (0, 1), (-1, 1), 8))
# Moyenne générale:
nbabs = I["nbabs"]
nbabsjust = I["nbabsjust"]
t = {
"titre": "Moyenne générale:",
"rang": I["rang_nt"],
"note": I.get("moy_gen", "-"),
"min": I.get("moy_min", "-"),
"max": I.get("moy_max", "-"),
"moy": I.get("moy_moy", "-"),
"abs": "%s / %s" % (nbabs, nbabsjust),
"_css_row_class": "notes_bulletin_row_gen",
"_titre_colspan": 2,
"_pdf_row_markup": ["b"], # bold. On peut ajouter 'font size="12"'
"_pdf_style": [
("LINEABOVE", (0, 1), (-1, 1), 1, self.PDF_LINECOLOR),
],
}
P.append(t)
# Rangs dans les partitions:
partitions, _ = sco_groups.get_formsemestre_groups(formsemestre_id)
for partition in partitions:
if partition["bul_show_rank"]:
partition_id = partition["partition_id"]
P.append(
{
"titre": "Rang dans %s %s: %s / %s inscrits"
% (
partition["partition_name"],
I["gr_name"][partition_id],
I["rang_gr"][partition_id],
I["ninscrits_gr"][partition_id],
),
"_titre_colspan": 3,
"_css_row_class": "notes_bulletin_row_rang",
}
)
# Chaque UE:
for ue in I["ues"]:
ue_type = None
coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else ""
ue_descr = ue["ue_descr_txt"]
rowstyle = ""
plusminus = minuslink #
if ue["ue_status"]["is_capitalized"]:
# UE capitalisée meilleure que UE courante:
if prefs["bul_show_ue_cap_details"]:
hidden = False
cssstyle = ""
plusminus = minuslink
else:
hidden = True
cssstyle = "sco_hide"
plusminus = pluslink
try:
ects_txt = str(int(ue["ects"]))
except (ValueError, KeyError, TypeError):
ects_txt = "-"
t = {
"titre": ue["acronyme"] + " " + (ue["titre"] or ""),
"_titre_html": plusminus
+ (ue["acronyme"] or "")
+ " "
+ (ue["titre"] or "")
+ ' <span class="bul_ue_descr">'
+ (ue["ue_descr_txt"] or "")
+ "</span>",
"_titre_help": ue["ue_descr_txt"] or "",
"_titre_colspan": 2,
"module": ue_descr,
"note": ue["moy_ue_txt"],
"coef": coef_ue,
"ects": ects_txt,
"_css_row_class": "notes_bulletin_row_ue",
"_tr_attrs": 'style="background-color: %s"' % self.ue_color_rgb(),
"_pdf_row_markup": ["b"],
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), self.ue_color()),
("LINEABOVE", (0, 0), (-1, 0), 1, self.PDF_LINECOLOR),
],
}
P.append(t)
# Notes de l'UE capitalisée obtenues antérieurement:
self._list_modules(
ue["modules_capitalized"],
matieres_modules=self.infos["matieres_modules_capitalized"],
ue_type=ue_type,
P=P,
prefs=prefs,
rowstyle=" bul_row_ue_cap %s" % cssstyle,
hidden=hidden,
)
ue_type = "cur"
ue_descr = ""
rowstyle = (
" bul_row_ue_cur" # style css pour indiquer UE non prise en compte
)
try:
ects_txt = str(int(ue["ects"]))
except:
ects_txt = "-"
titre = f"{ue['acronyme'] or ''} {ue['titre'] or ''}"
t = {
"titre": titre,
"_titre_html": minuslink + titre,
"_titre_colspan": 2,
"module": ue["titre"],
"rang": ue_descr,
"note": ue["cur_moy_ue_txt"],
"coef": coef_ue,
"ects": ects_txt,
"_css_row_class": "notes_bulletin_row_ue",
"_tr_attrs": 'style="background-color: %s"'
% self.ue_color_rgb(ue_type=ue["type"]),
"_pdf_row_markup": ["b"],
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), self.ue_color(ue_type=ue["type"])),
("LINEABOVE", (0, 0), (-1, 0), 1, self.PDF_LINECOLOR),
],
}
if ue_type == "cur":
t["module"] = "(en cours, non prise en compte)"
t["_css_row_class"] += " notes_bulletin_row_ue_cur"
t["_titre_help"] = "(en cours, non prise en compte)"
if prefs["bul_show_minmax"]:
t["min"] = scu.fmt_note(ue["min"])
t["max"] = scu.fmt_note(ue["max"])
if prefs["bul_show_moypromo"]:
t["moy"] = scu.fmt_note(ue["moy"]).replace("NA", "-")
# Cas particulier des UE sport (bonus)
if ue["type"] == UE_SPORT and not ue_descr:
del t["module"]
del t["coef"]
t["_pdf_style"].append(("SPAN", (colidx["note"], 0), (-1, 0)))
# t["_module_colspan"] = 3 # non car bug si aucune colonne additionnelle
# UE électives
if ue["type"] == UE_ELECTIVE:
t["module"] += " <i>(élective)</i>"
if ue["modules"]:
if (
ue_type != "cur" or prefs["bul_show_ue_cap_current"]
): # ne montre pas UE en cours et capitalisée, sauf si forcé
P.append(t)
self._list_modules(
ue["modules"],
matieres_modules=self.infos["matieres_modules"],
ue_type=ue_type,
P=P,
prefs=prefs,
rowstyle=rowstyle,
)
# Ligne somme ECTS
if with_col_ects:
t = {
"titre": "Crédits ECTS acquis:",
"ects": str(int(I["sum_ects"])), # incluant les UE capitalisees
"_css_row_class": "notes_bulletin_row_sum_ects",
"_pdf_row_markup": ["b"], # bold
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), self.PDF_LIGHT_GRAY),
("LINEABOVE", (0, 0), (-1, 0), 1, self.PDF_LINECOLOR),
],
}
P.append(t)
# Global pdf style commands:
pdf_style = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.4, blue), # ajoute cadre extérieur bleu:
]
#
return col_keys, P, pdf_style, col_widths
def _list_modules(
self,
ue_modules,
matieres_modules={},
ue_type=None,
P=None,
prefs=None,
rowstyle="",
hidden=False,
):
"""Liste dans la table les descriptions des modules et, si version != short, des évaluations."""
if ue_type == "cur": # UE courante non prise en compte (car capitalisee)
pdf_style_bg = [("BACKGROUND", (0, 0), (-1, 0), self.PDF_UE_CUR_BG)]
else:
pdf_style_bg = []
pdf_style = pdf_style_bg + [
("LINEABOVE", (0, 0), (-1, 0), 1, self.PDF_MODSEPCOLOR),
("SPAN", (0, 0), (1, 0)),
]
if ue_type == "cur": # UE courante non prise en compte (car capitalisee)
pdf_style.append(("BACKGROUND", (0, 0), (-1, 0), self.PDF_UE_CUR_BG))
last_matiere_id = None
for mod in ue_modules:
if mod["mod_moy_txt"] == "NI":
continue # saute les modules où on n'est pas inscrit
# Matière:
matiere_id = mod["module"]["matiere_id"]
if prefs["bul_show_matieres"] and matiere_id != last_matiere_id:
mat = matieres_modules[matiere_id]
P.append(
{
"titre": mat["titre"],
#'_titre_help' : matiere_id,
"_titre_colspan": 2,
"note": mat["moy_txt"],
"_css_row_class": "notes_bulletin_row_mat%s" % rowstyle,
"_pdf_style": pdf_style_bg
+ [("LINEABOVE", (0, 0), (-1, 0), 2, self.PDF_MODSEPCOLOR)],
"_pdf_row_markup": ['font color="darkblue"'],
}
)
last_matiere_id = matiere_id
#
t = {
"titre": mod["code_txt"] + " " + mod["name"],
"_titre_colspan": 2,
"rang": mod["mod_rang_txt"], # vide si pas option rang
"note": mod["mod_moy_txt"],
"coef": mod["mod_coef_txt"] if prefs["bul_show_coef"] else "",
# vide si pas option show abs module:
"abs": mod.get("mod_abs_txt", ""),
"_css_row_class": f"notes_bulletin_row_mod{rowstyle}",
"_titre_target": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=mod["moduleimpl_id"],
),
"_titre_help": mod["mod_descr_txt"],
"_hidden": hidden,
"_pdf_style": pdf_style,
}
if prefs["bul_show_minmax_mod"]:
t["min"] = scu.fmt_note(mod["stats"]["min"])
t["max"] = scu.fmt_note(mod["stats"]["max"])
if prefs["bul_show_moypromo"]:
t["moy"] = scu.fmt_note(mod["stats"]["moy"]).replace("NA", "-")
P.append(t)
if self.version != "short":
# --- notes de chaque eval:
nbeval = self._list_evals(
mod["evaluations"], P, rowstyle, pdf_style_bg, hidden, prefs=prefs
)
# evals futures ou incomplètes:
nbeval += self._list_evals(
mod["evaluations_incompletes"],
P,
rowstyle,
pdf_style_bg,
hidden,
incomplete=True,
prefs=prefs,
)
if nbeval: # boite autour des évaluations (en pdf)
P[-1]["_pdf_style"].append(
("BOX", (1, 1 - nbeval), (-1, 0), 0.2, self.PDF_LIGHT_GRAY)
)
def _list_evals(
self,
evals,
P,
rowstyle="",
pdf_style_bg=[],
hidden=False,
incomplete=False,
prefs={},
):
if incomplete: # style special pour evaluations incompletes:
rowstyle += " notes_bulletin_row_eval_incomplete"
pdf_row_markup = ['font color="red"']
else:
pdf_row_markup = []
# --- notes de chaque eval:
nbeval = 0
for e in evals:
if e["visibulletin"] or self.version == "long":
if nbeval == 0:
eval_style = " b_eval_first"
else:
eval_style = ""
t = {
"module": '<bullet indent="2mm">&bull;</bullet>&nbsp;' + e["name"],
"coef": ("<i>" + e["coef_txt"] + "</i>")
if prefs["bul_show_coef"]
else "",
"_hidden": hidden,
"_module_target": e["target_html"],
# '_module_help' : ,
"_css_row_class": "notes_bulletin_row_eval" + eval_style + rowstyle,
"_pdf_style": pdf_style_bg[:],
"_pdf_row_markup": pdf_row_markup,
}
if e["note_txt"]:
t["note"] = "<i>" + e["note_txt"] + "</i>"
else:
t["_module_colspan"] = 2
if prefs["bul_show_minmax_eval"] or prefs["bul_show_moypromo"]:
etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"])
if prefs["bul_show_minmax_eval"]:
t["min"] = scu.fmt_note(etat["mini"])
t["max"] = scu.fmt_note(etat["maxi"])
if prefs["bul_show_moypromo"]:
t["moy"] = scu.fmt_note(etat["moy"])
P.append(t)
nbeval += 1
return nbeval
# sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard)