############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Génération bulletin BUT PDF synthétique en une page On génère du PDF avec reportLab en utilisant les classes ScoDoc BulletinGenerator et GenTable. """ import datetime from flask_login import current_user from reportlab.lib import styles from reportlab.lib.colors import black, white, Color from reportlab.lib.enums import TA_CENTER from reportlab.lib.units import mm from reportlab.platypus import Paragraph, Spacer, Table from app.but import cursus_but from app.models import ( BulAppreciations, FormSemestre, Identite, ScolarFormSemestreValidation, ) from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard from app.scodoc.sco_logos import Logo from app.scodoc.sco_pdf import PDFLOCK, SU from app.scodoc.sco_preferences import SemPreferences def make_bulletin_but_court_pdf( args: dict, stand_alone: bool = True, ) -> bytes: """génère le bulletin court BUT en pdf. Si stand_alone, génère un doc pdf complet (une page ici), sinon un morceau (fragment) à intégrer dans un autre document. args donne toutes les infos du contenu du bulletin: bul: dict = None, cursus: cursus_but.EtudCursusBUT = None, decision_ues: dict = None, ects_total: float = 0.0, etud: Identite = None, formsemestre: FormSemestre = None, filigranne="" logo: Logo = None, prefs: SemPreferences = None, title: str = "", ue_validation_by_niveau: dict[tuple[int, str], ScolarFormSemestreValidation] = None, ues_acronyms: list[str] = None, """ # A priori ce verrou n'est plus nécessaire avec Flask (multi-process) # mais... try: PDFLOCK.acquire() bul_generator = BulletinGeneratorBUTCourt(**args) bul_pdf = bul_generator.generate(fmt="pdf", stand_alone=stand_alone) finally: PDFLOCK.release() return bul_pdf class BulletinGeneratorBUTCourt(BulletinGeneratorStandard): """Ce générateur de bulletin BUT court est assez différent des autres bulletins. Ne génére que du PDF. Il reprend la mise en page et certains éléments (pied de page, signature). """ # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur: list_in_menu = False scale_table_in_page = True # pas de mise à l'échelle pleine page auto multi_pages = False # une page par bulletin small_fontsize = "8" color_blue_bg = Color(0, 153 / 255, 204 / 255) color_gray_bg = Color(0.86, 0.86, 0.86) def __init__( self, bul: dict = None, cursus: cursus_but.EtudCursusBUT = None, decision_ues: dict = None, ects_total: float = 0.0, etud: Identite = None, filigranne="", formsemestre: FormSemestre = None, logo: Logo = None, prefs: SemPreferences = None, title: str = "", ue_validation_by_niveau: dict[ tuple[int, str], ScolarFormSemestreValidation ] = None, ues_acronyms: list[str] = None, ): super().__init__(bul, authuser=current_user, filigranne=filigranne) self.bul = bul self.cursus = cursus self.decision_ues = decision_ues self.ects_total = ects_total self.etud = etud self.formsemestre = formsemestre self.logo = logo self.prefs = prefs self.title = title self.ue_validation_by_niveau = ue_validation_by_niveau self.ues_acronyms = ues_acronyms # sans UEs sport self.nb_ues = len(self.ues_acronyms) # Styles PDF self.style_base = styles.ParagraphStyle("style_base") self.style_base.fontName = "Helvetica" self.style_base.fontSize = 9 self.style_base.firstLineIndent = 0 # écrase style defaut des bulletins self.style_field = self.style_base # Le nom/prénom de l'étudiant: self.style_nom = styles.ParagraphStyle("style_nom", self.style_base) self.style_nom.fontSize = 11 self.style_nom.fontName = "Helvetica-Bold" self.style_cell = styles.ParagraphStyle("style_cell", self.style_base) self.style_cell.fontSize = 7 self.style_cell.leading = 7 self.style_cell_bold = styles.ParagraphStyle("style_cell_bold", self.style_cell) self.style_cell_bold.fontName = "Helvetica-Bold" self.style_head = styles.ParagraphStyle("style_head", self.style_cell_bold) self.style_head.fontSize = 9 self.style_niveaux = styles.ParagraphStyle("style_niveaux", self.style_cell) self.style_niveaux.alignment = TA_CENTER self.style_niveaux.leading = 9 self.style_niveaux.firstLineIndent = 0 self.style_niveaux.leftIndent = 1 self.style_niveaux.rightIndent = 1 self.style_niveaux.borderWidth = 0.5 self.style_niveaux.borderPadding = 2 self.style_niveaux.borderRadius = 2 self.style_niveaux_top = styles.ParagraphStyle( "style_niveaux_top", self.style_niveaux ) self.style_niveaux_top.fontName = "Helvetica-Bold" self.style_niveaux_top.fontSize = 8 self.style_niveaux_titre = styles.ParagraphStyle( "style_niveaux_titre", self.style_niveaux ) self.style_niveaux_titre.textColor = white self.style_niveaux_titre.backColor = self.color_blue_bg self.style_niveaux_titre.borderColor = self.color_blue_bg self.style_niveaux_code = styles.ParagraphStyle( "style_niveaux_code", self.style_niveaux ) self.style_niveaux_code.borderColor = black # self.style_jury = styles.ParagraphStyle("style_jury", self.style_base) self.style_jury.fontSize = 9 self.style_jury.leading = self.style_jury.fontSize * 1.4 # espace les lignes self.style_jury.backColor = self.color_gray_bg self.style_jury.borderColor = black self.style_jury.borderWidth = 1 self.style_jury.borderPadding = 2 self.style_jury.borderRadius = 2 self.style_appreciations = styles.ParagraphStyle( "style_appreciations", self.style_base ) self.style_appreciations.fontSize = 9 self.style_appreciations.leading = ( self.style_jury.fontSize * 1.4 ) # espace les lignes self.style_assiduite = self.style_cell self.style_signature = self.style_appreciations # Géométrie page self.width_page_avail = 185 * mm # largeur utilisable # Géométrie tableaux self.width_col_ue = 18 * mm self.width_col_ue_titres = 16.5 * mm # Modules self.width_col_code = self.width_col_ue # Niveaux self.width_col_niveaux_titre = 24 * mm self.width_col_niveaux_code = 14 * mm def bul_title_pdf(self, preference_field="bul_but_pdf_title") -> list: """Génère la partie "titre" du bulletin de notes. Renvoie une liste d'objets platypus """ # comme les bulletins standards, mais avec notre préférence return super().bul_title_pdf(preference_field=preference_field) def bul_part_below(self, fmt="pdf") -> list: """Génère les informations placées sous la table Dans le cas du bul. court BUT pdf, seulement les appréciations. fmt est ignoré ici. """ appreciations = BulAppreciations.get_appreciations_list( self.formsemestre.id, self.etud.id ) return ( [ Spacer(1, 3 * mm), self.bul_appreciations_pdf( appreciations, style=self.style_appreciations ), ] if appreciations else [] ) def bul_table(self, fmt=None) -> list: """Génère la table centrale du bulletin de notes Renvoie: une liste d'objets PLATYPUS (eg instance de Table). L'argument fmt est ici ignoré (toujours en PDF) """ style_table_2cols = [ ("ALIGN", (0, -1), (0, -1), "LEFT"), ("ALIGN", (-1, -1), (-1, -1), "RIGHT"), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 0), ("TOPPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0), ] # Ligne avec boite assiduité et table UEs table_abs_ues = Table( [ [ self.boite_identite() + [Spacer(1, 3 * mm), self.boite_assiduite()], self.table_ues(), ], ], style=style_table_2cols, ) table_abs_ues.hAlign = "RIGHT" # Ligne (en bas) avec table cursus et boite jury table_cursus_jury = Table( [ [ self.table_cursus_but(), [Spacer(1, 8 * mm), self.boite_decisions_jury()], ] ], colWidths=(self.width_page_avail - 84 * mm, 84 * mm), style=style_table_2cols, ) return [ table_abs_ues, Spacer(0, 3 * mm), self.table_ressources(), Spacer(0, 3 * mm), self.table_saes(), Spacer(0, 5 * mm), table_cursus_jury, ] def table_ues(self) -> Table: """Table avec les résultats d'UE du semestre courant""" bul = self.bul rows = [ [ f"Unités d'enseignement du semestre {self.formsemestre.semestre_id}", ], [""] + self.ues_acronyms, ["Moyenne"] + [bul["ues"][ue]["moyenne"]["value"] for ue in self.ues_acronyms], ["dont bonus"] + [ bul["ues"][ue]["bonus"] if bul["ues"][ue]["bonus"] != "00.00" else "" for ue in self.ues_acronyms ], ["et malus"] + [ bul["ues"][ue]["malus"] if bul["ues"][ue]["malus"] != "00.00" else "" for ue in self.ues_acronyms ], ["Rang"] + [ f'{bul["ues"][ue]["moyenne"]["rang"]} / {bul["ues"][ue]["moyenne"]["total"]}' for ue in self.ues_acronyms ], ] if self.prefs["bul_show_ects"]: rows += [ ["ECTS"] + [ f'{bul["ues"][ue]["ECTS"]["acquis"]:g} /{bul["ues"][ue]["ECTS"]["total"]:g}' for ue in self.ues_acronyms ] ] rows += [ ["Jury"] + [ self.decision_ues[ue]["code"] if ue in self.decision_ues else "" for ue in self.ues_acronyms ] ] blue_bg = Color(183 / 255.0, 235 / 255.0, 255 / 255.0) table_style = [ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("BOX", (0, 0), (-1, -1), 1.0, black), # ajoute cadre extérieur ("INNERGRID", (0, 0), (-1, -1), 0.25, black), ("LEADING", (0, 1), (-1, -1), 5), ("SPAN", (0, 0), (self.nb_ues, 0)), ("BACKGROUND", (0, 0), (self.nb_ues, 0), blue_bg), ] col_widths = [self.width_col_ue_titres] + [self.width_col_ue] * self.nb_ues rows_styled = [[Paragraph(SU(str(cell)), self.style_head) for cell in rows[0]]] rows_styled += [ [Paragraph(SU(str(cell)), self.style_cell_bold) for cell in rows[1]] ] rows_styled += [ [Paragraph(SU(str(cell)), self.style_cell) for cell in row] for row in rows[2:-1] ] rows_styled += [ [Paragraph(SU(str(cell)), self.style_cell_bold) for cell in rows[-1]] ] table = Table( rows_styled, colWidths=col_widths, style=table_style, ) table.hAlign = "RIGHT" return table def _table_modules(self, mod_type: str = "ressources", title: str = "") -> Table: "génère table des modules: resources ou SAEs" bul = self.bul rows = [ ["", "", "Unités d'enseignement"] + [""] * (self.nb_ues - 1), [title, ""] + self.ues_acronyms, ] for mod in self.bul[mod_type]: row = [mod, bul[mod_type][mod]["titre"]] row += [ bul["ues"][ue][mod_type][mod]["moyenne"] if mod in bul["ues"][ue][mod_type] else "" for ue in self.ues_acronyms ] rows.append(row) title_bg = ( Color(255 / 255, 192 / 255, 0) if mod_type == "ressources" else Color(176 / 255, 255 / 255, 99 / 255) ) table_style = [ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("BOX", (0, 0), (-1, -1), 1.0, black), # ajoute cadre extérieur ("INNERGRID", (0, 0), (-1, -1), 0.25, black), ("LEADING", (0, 1), (-1, -1), 5), # 1ère ligne titre ("SPAN", (0, 0), (1, 0)), ("SPAN", (2, 0), (self.nb_ues, 0)), # 2ème ligne titre ("SPAN", (0, 1), (1, 1)), ("BACKGROUND", (0, 1), (1, 1), title_bg), ] # Estime l'espace horizontal restant pour les titres de modules width_col_titre_module = ( self.width_page_avail - self.width_col_code - self.width_col_ue * self.nb_ues ) col_widths = [self.width_col_code, width_col_titre_module] + [ self.width_col_ue ] * self.nb_ues rows_styled = [ [Paragraph(SU(str(cell)), self.style_cell_bold) for cell in row] for row in rows[:2] ] rows_styled += [ [Paragraph(SU(str(cell)), self.style_cell) for cell in row] for row in rows[2:] ] table = Table( rows_styled, colWidths=col_widths, style=table_style, ) table.hAlign = "RIGHT" return table def table_ressources(self) -> Table: "La table des ressources" return self._table_modules("ressources", "Ressources") def table_saes(self) -> Table: "La table des SAEs" return self._table_modules( "saes", "Situations d'Apprentissage et d'Évaluation (SAÉ)" ) def boite_identite(self) -> list: "Les informations sur l'identité et l'inscription de l'étudiant" parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour return [ Paragraph( SU(f"""{self.etud.nomprenom}"""), style=self.style_nom, ), Paragraph( SU( f""" <b>{self.bul["demission"]}</b><br/> Formation: {self.formsemestre.titre_num()}<br/> {'Parcours ' + parcour.code + '<br/>' if parcour else ''} Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/> """ ), style=self.style_base, ), ] def boite_assiduite(self) -> Table: "Les informations sur l'assiduité" if not self.bul["options"]["show_abs"]: return Paragraph("") # empty color_bg = Color(245 / 255, 237 / 255, 200 / 255) rows = [ ["Absences", ""], [f'({self.bul["semestre"]["absences"]["metrique"]})', ""], ["Non justifiées", self.bul["semestre"]["absences"]["injustifie"]], ["Total", self.bul["semestre"]["absences"]["total"]], ] rows_styled = [ [Paragraph(SU(str(cell)), self.style_head) for cell in row] for row in rows[:1] ] rows_styled += [ [Paragraph(SU(str(cell)), self.style_assiduite) for cell in row] for row in rows[1:] ] table = Table( rows_styled, # [topLeft, topRight, bottomLeft bottomRight] cornerRadii=[2 * mm] * 4, style=[ ("BACKGROUND", (0, 0), (-1, -1), color_bg), ("SPAN", (0, 0), (1, 0)), ("SPAN", (0, 1), (1, 1)), ("VALIGN", (0, 0), (-1, -1), "TOP"), ], colWidths=(25 * mm, 10 * mm), ) table.hAlign = "LEFT" return table def table_cursus_but(self) -> Table: "La table avec niveaux et validations BUT1, BUT2, BUT3" rows = [ ["", "BUT 1", "BUT 2", "BUT 3"], ] for competence_id in self.cursus.to_dict(): row = [self.cursus.competences[competence_id].titre] for annee in ("BUT1", "BUT2", "BUT3"): validation = self.cursus.validation_par_competence_et_annee.get( competence_id, {} ).get(annee) has_niveau = self.cursus.competence_annee_has_niveau( competence_id, annee ) txt = "" if validation: txt = validation.code elif has_niveau: txt = "-" row.append(txt) rows.append(row) rows_styled = [ [Paragraph(SU(str(cell)), self.style_niveaux_top) for cell in rows[0]] ] + [ [Paragraph(SU(str(row[0])), self.style_niveaux_titre)] + [ Paragraph(SU(str(cell)), self.style_niveaux_code) if cell else "" for cell in row[1:] ] for row in rows[1:] ] table = Table( rows_styled, colWidths=[ self.width_col_niveaux_titre, self.width_col_niveaux_code, self.width_col_niveaux_code, self.width_col_niveaux_code, ], style=[ ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 4), # sert de séparateur entre les lignes: ("LINEABOVE", (0, 1), (-1, -1), 3, white), # séparateur colonne ("LINEBEFORE", (1, 1), (-1, -1), 5, white), ], ) table.hAlign = "LEFT" return table def boite_decisions_jury(self): """La boite en bas à droite avec jury""" txt = f"""ECTS acquis en BUT : <b>{self.ects_total:g}</b><br/>""" if self.bul["semestre"].get("decision_annee", None): txt += f""" Décision saisie le { datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y") }, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]} <b>{self.bul["semestre"]["decision_annee"]["code"]}</b>. <br/> """ if self.bul["semestre"].get("autorisation_inscription", None): txt += ( "<br/>Autorisé à s'inscrire en <b>" + ", ".join( [ f"S{aut['semestre_id']}" for aut in self.bul["semestre"]["autorisation_inscription"] ] ) + "</b>." ) return Paragraph(txt, style=self.style_jury)