diff --git a/app/__init__.py b/app/__init__.py index a976da7f7..a1862aaa7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,10 +10,11 @@ import traceback import logging from logging.handlers import SMTPHandler, WatchedFileHandler +from threading import Thread from flask import current_app, g, request from flask import Flask -from flask import abort, has_request_context, jsonify +from flask import abort, flash, has_request_context, jsonify from flask import render_template from flask.logging import default_handler from flask_sqlalchemy import SQLAlchemy @@ -27,6 +28,7 @@ import sqlalchemy from app.scodoc.sco_exceptions import ( AccessDenied, + ScoBugCatcher, ScoGenError, ScoValueError, APIInvalidParams, @@ -43,11 +45,13 @@ mail = Mail() bootstrap = Bootstrap() moment = Moment() -cache = Cache( # XXX TODO: configuration file +CACHE_TYPE = os.environ.get("CACHE_TYPE") +cache = Cache( config={ # see https://flask-caching.readthedocs.io/en/latest/index.html#configuring-flask-caching - "CACHE_TYPE": "RedisCache", - "CACHE_DEFAULT_TIMEOUT": 0, # by default, never expire + "CACHE_TYPE": CACHE_TYPE or "RedisCache", + # by default, never expire: + "CACHE_DEFAULT_TIMEOUT": os.environ.get("CACHE_DEFAULT_TIMEOUT") or 0, } ) @@ -60,7 +64,7 @@ def handle_access_denied(exc): return render_template("error_access_denied.html", exc=exc), 403 -def internal_server_error(e): +def internal_server_error(exc): """Bugs scodoc, erreurs 500""" # note that we set the 500 status explicitly return ( @@ -68,11 +72,35 @@ def internal_server_error(e): "error_500.html", SCOVERSION=sco_version.SCOVERSION, date=datetime.datetime.now().isoformat(), + exc=exc, + request_url=request.url, ), 500, ) +def handle_sco_bug(exc): + """Un bug, en général rare, sur lequel les dev cherchent des + informations pour le corriger. + """ + Thread( + target=_async_dump, args=(current_app._get_current_object(), request.url) + ).start() + + return internal_server_error(exc) + + +def _async_dump(app, request_url: str): + from app.scodoc.sco_dump_db import sco_dump_and_send_db + + with app.app_context(): + ndb.open_db_connection() + try: + sco_dump_and_send_db("ScoBugCatcher", request_url=request_url) + except ScoValueError: + pass + + def handle_invalid_usage(error): response = jsonify(error.to_dict()) response.status_code = error.status_code @@ -187,11 +215,12 @@ def create_app(config_class=DevConfig): moment.init_app(app) cache.init_app(app) sco_cache.CACHE = cache + if CACHE_TYPE: # non default + app.logger.info(f"CACHE_TYPE={CACHE_TYPE}") app.register_error_handler(ScoGenError, handle_sco_value_error) app.register_error_handler(ScoValueError, handle_sco_value_error) - app.register_error_handler(404, handle_sco_value_error) - + app.register_error_handler(ScoBugCatcher, handle_sco_bug) app.register_error_handler(AccessDenied, handle_access_denied) app.register_error_handler(500, internal_server_error) app.register_error_handler(503, postgresql_server_error) @@ -337,7 +366,7 @@ def user_db_init(): current_app.logger.info("Init User's db") # Create roles: - Role.insert_roles() + Role.reset_standard_roles_permissions() current_app.logger.info("created initial roles") # Ensure that admin exists admin_mail = current_app.config.get("SCODOC_ADMIN_MAIL") @@ -460,15 +489,12 @@ from app.models import Departement from app.scodoc import notesdb as ndb, sco_preferences from app.scodoc import sco_cache -# admin_role = Role.query.filter_by(name="SuperAdmin").first() -# if admin_role: -# admin = ( -# User.query.join(UserRole) -# .filter((UserRole.user_id == User.id) & (UserRole.role_id == admin_role.id)) -# .first() -# ) -# else: -# click.echo( -# "Warning: user database not initialized !\n (use: flask user-db-init)" -# ) -# admin = None + +def scodoc_flash_status_messages(): + """Should be called on each page: flash messages indicating specific ScoDoc status""" + email_test_mode_address = sco_preferences.get_preference("email_test_mode_address") + if email_test_mode_address: + flash( + f"Mode test: mails redirigés vers {email_test_mode_address}", + category="warning", + ) diff --git a/app/auth/models.py b/app/auth/models.py index 329bc3868..cfab21a9c 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -173,7 +173,7 @@ class User(UserMixin, db.Model): "id": self.id, "active": self.active, "status_txt": "actif" if self.active else "fermé", - "last_seen": self.last_seen.isoformat() + "Z", + "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else "", "nom": (self.nom or ""), # sco8 "prenom": (self.prenom or ""), # sco8 "roles_string": self.get_roles_string(), # eg "Ens_RT, Ens_Info" @@ -270,6 +270,8 @@ class User(UserMixin, db.Model): """Add a role to this user. :param role: Role to add. """ + if not isinstance(role, Role): + raise ScoValueError("add_role: rôle invalide") self.user_roles.append(UserRole(user=self, role=role, dept=dept)) def add_roles(self, roles, dept): @@ -281,7 +283,9 @@ class User(UserMixin, db.Model): def set_roles(self, roles, dept): "set roles in the given dept" - self.user_roles = [UserRole(user=self, role=r, dept=dept) for r in roles] + self.user_roles = [ + UserRole(user=self, role=r, dept=dept) for r in roles if isinstance(r, Role) + ] def get_roles(self): "iterator on my roles" @@ -292,7 +296,11 @@ class User(UserMixin, db.Model): """string repr. of user's roles (with depts) e.g. "Ens_RT, Ens_Info, Secr_CJ" """ - return ",".join(f"{r.role.name or ''}_{r.dept or ''}" for r in self.user_roles) + return ",".join( + f"{r.role.name or ''}_{r.dept or ''}" + for r in self.user_roles + if r is not None + ) def is_administrator(self): "True if i'm an active SuperAdmin" @@ -402,20 +410,30 @@ class Role(db.Model): return self.permissions & perm == perm @staticmethod - def insert_roles(): - """Create default roles""" + def reset_standard_roles_permissions(reset_permissions=True): + """Create default roles if missing, then, if reset_permissions, + reset their permissions to default values. + """ default_role = "Observateur" for role_name, permissions in SCO_ROLES_DEFAULTS.items(): role = Role.query.filter_by(name=role_name).first() if role is None: role = Role(name=role_name) - role.reset_permissions() - for perm in permissions: - role.add_permission(perm) - role.default = role.name == default_role - db.session.add(role) + role.default = role.name == default_role + db.session.add(role) + if reset_permissions: + role.reset_permissions() + for perm in permissions: + role.add_permission(perm) + db.session.add(role) + db.session.commit() + @staticmethod + def ensure_standard_roles(): + """Create default roles if missing""" + Role.reset_standard_roles_permissions(reset_permissions=False) + @staticmethod def get_named_role(name): """Returns existing role with given name, or None.""" diff --git a/app/auth/routes.py b/app/auth/routes.py index df3401515..24daa8ca0 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -19,7 +19,7 @@ from app.auth.forms import ( ResetPasswordForm, DeactivateUserForm, ) -from app.auth.models import Permission +from app.auth.models import Role from app.auth.models import User from app.auth.email import send_password_reset_email from app.decorators import admin_required @@ -121,3 +121,11 @@ def reset_password(token): flash(_("Votre mot de passe a été changé.")) return redirect(url_for("auth.login")) return render_template("auth/reset_password.html", form=form, user=user) + + +@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"]) +@admin_required +def reset_standard_roles_permissions(): + Role.reset_standard_roles_permissions() + flash("rôles standard réinitialisés !") + return redirect(url_for("scodoc.configuration")) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 87ec62a28..23fd8c587 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -7,16 +7,19 @@ """Génération bulletin BUT """ +import collections import datetime +import numpy as np from flask import url_for, g from app.comp.res_but import ResultatsSemestreBUT -from app.models import FormSemestre, Identite, formsemestre +from app.models import FormSemestre, Identite +from app.models.ues import UniteEns from app.scodoc import sco_bulletins, sco_utils as scu from app.scodoc import sco_bulletins_json from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_preferences -from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF from app.scodoc.sco_utils import fmt_note @@ -61,18 +64,15 @@ class BulletinBUT: # } return d - def etud_ue_results(self, etud, ue): + def etud_ue_results(self, etud: Identite, ue: UniteEns, decision_ue: dict) -> dict: "dict synthèse résultats UE" res = self.res + d = { "id": ue.id, "titre": ue.titre, "numero": ue.numero, "type": ue.type, - "ECTS": { - "acquis": 0, # XXX TODO voir jury #sco92 - "total": ue.ects, - }, "color": ue.color, "competence": None, # XXX TODO lien avec référentiel "moyenne": None, @@ -80,11 +80,16 @@ class BulletinBUT: "bonus": fmt_note(res.bonus_ues[ue.id][etud.id]) if res.bonus_ues is not None and ue.id in res.bonus_ues else fmt_note(0.0), - "malus": res.malus[ue.id][etud.id], + "malus": fmt_note(res.malus[ue.id][etud.id]), "capitalise": None, # "AAAA-MM-JJ" TODO #sco92 "ressources": self.etud_ue_mod_results(etud, ue, res.ressources), "saes": self.etud_ue_mod_results(etud, ue, res.saes), } + if self.prefs["bul_show_ects"]: + d["ECTS"] = { + "acquis": decision_ue.get("ects", 0.0), + "total": ue.ects or 0.0, # float même si non renseigné + } if ue.type != UE_SPORT: if self.prefs["bul_show_ue_rangs"]: rangs, effectif = res.ue_rangs[ue.id] @@ -111,9 +116,10 @@ class BulletinBUT: d["modules"] = self.etud_mods_results(etud, modimpls_spo) return d - def etud_mods_results(self, etud, modimpls) -> dict: + def etud_mods_results(self, etud, modimpls, version="long") -> dict: """dict synthèse résultats des modules indiqués, - avec évaluations de chacun.""" + avec évaluations de chacun (sauf si version == "short") + """ res = self.res d = {} # etud_idx = self.etud_index[etud.id] @@ -154,12 +160,14 @@ class BulletinBUT: "evaluations": [ self.etud_eval_results(etud, e) for e in modimpl.evaluations - if e.visibulletin + if (e.visibulletin or version == "long") and ( modimpl_results.evaluations_etat[e.id].is_complete or self.prefs["bul_show_all_evals"] ) - ], + ] + if version != "short" + else [], } return d @@ -168,14 +176,23 @@ class BulletinBUT: # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id] notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() + modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id] + try: + poids = { + ue.acronyme: modimpls_evals_poids[ue.id][e.id] + for ue in self.res.ues + if ue.type != UE_SPORT + } + except KeyError: + poids = collections.defaultdict(lambda: 0.0) d = { "id": e.id, "description": e.description, "date": e.jour.isoformat() if e.jour else None, "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None, "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None, - "coef": e.coefficient, - "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, + "coef": fmt_note(e.coefficient), + "poids": poids, "note": { "value": fmt_note( eval_notes[etud.id], @@ -205,7 +222,8 @@ class BulletinBUT: details = [ f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}" for ue in res.ues - if res.modimpls_in_ue(ue.id, etudid) + if ue.type != UE_SPORT + and res.modimpls_in_ue(ue.id, etudid) and ue.id in res.bonus_ues and bonus_vect[ue.id] > 0.0 ] @@ -217,14 +235,22 @@ class BulletinBUT: return f"Bonus de {fmt_note(bonus_vect.iloc[0])}" def bulletin_etud( - self, etud: Identite, formsemestre: FormSemestre, force_publishing=False + self, + etud: Identite, + formsemestre: FormSemestre, + force_publishing=False, + version="long", ) -> dict: """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML. + - version: + "long", "selectedevals": toutes les infos (notes des évaluations) + "short" : ne descend pas plus bas que les modules. + - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai (bulletins non publiés). """ res = self.res - etat_inscription = etud.etat_inscription(formsemestre.id) + etat_inscription = etud.inscription_etat(formsemestre.id) nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT] published = (not formsemestre.bul_hide_xml) or force_publishing d = { @@ -257,39 +283,55 @@ class BulletinBUT: "numero": formsemestre.semestre_id, "inscription": "", # inutilisé mais nécessaire pour le js de Seb. "groupes": [], # XXX TODO - "absences": { + } + if self.prefs["bul_show_abs"]: + semestre_infos["absences"] = { "injustifie": nbabs - nbabsjust, "total": nbabs, - }, - } + } + decisions_ues = self.res.get_etud_decision_ues(etud.id) or {} + if self.prefs["bul_show_ects"]: + ects_tot = sum([ue.ects or 0 for ue in res.ues]) if res.ues else 0.0 + ects_acquis = sum([d.get("ects", 0) for d in decisions_ues.values()]) + semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot} semestre_infos.update( sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) ) if etat_inscription == scu.INSCRIT: - semestre_infos.update( - { - "notes": { # moyenne des moyennes générales du semestre - "value": fmt_note(res.etud_moy_gen[etud.id]), - "min": fmt_note(res.etud_moy_gen.min()), - "moy": fmt_note(res.etud_moy_gen.mean()), - "max": fmt_note(res.etud_moy_gen.max()), - }, - "rang": { # classement wrt moyenne général, indicatif - "value": res.etud_moy_gen_ranks[etud.id], - "total": nb_inscrits, - }, - }, - ) + # moyenne des moyennes générales du semestre + semestre_infos["notes"] = { + "value": fmt_note(res.etud_moy_gen[etud.id]), + "min": fmt_note(res.etud_moy_gen.min()), + "moy": fmt_note(res.etud_moy_gen.mean()), + "max": fmt_note(res.etud_moy_gen.max()), + } + if self.prefs["bul_show_rangs"] and not np.isnan(res.etud_moy_gen[etud.id]): + # classement wrt moyenne général, indicatif + semestre_infos["rang"] = { + "value": res.etud_moy_gen_ranks[etud.id], + "total": nb_inscrits, + } + else: + semestre_infos["rang"] = { + "value": "-", + "total": nb_inscrits, + } d.update( { - "ressources": self.etud_mods_results(etud, res.ressources), - "saes": self.etud_mods_results(etud, res.saes), + "ressources": self.etud_mods_results( + etud, res.ressources, version=version + ), + "saes": self.etud_mods_results(etud, res.saes, version=version), "ues": { - ue.acronyme: self.etud_ue_results(etud, ue) + ue.acronyme: self.etud_ue_results( + etud, ue, decision_ue=decisions_ues.get(ue.id, {}) + ) for ue in res.ues - if self.res.modimpls_in_ue( - ue.id, etud.id - ) # si l'UE comporte des modules auxquels on est inscrit + # si l'UE comporte des modules auxquels on est inscrit: + if ( + (ue.type == UE_SPORT) + or self.res.modimpls_in_ue(ue.id, etud.id) + ) }, "semestre": semestre_infos, }, @@ -317,20 +359,45 @@ class BulletinBUT: return d - def bulletin_etud_complet(self, etud: Identite) -> dict: - """Bulletin dict complet avec toutes les infos pour les bulletins pdf""" - d = self.bulletin_etud(etud, self.res.formsemestre, force_publishing=True) + def bulletin_etud_complet(self, etud: Identite, version="long") -> dict: + """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf + Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict + """ + d = self.bulletin_etud( + etud, self.res.formsemestre, version=version, force_publishing=True + ) d["etudid"] = etud.id d["etud"] = d["etudiant"] d["etud"]["nomprenom"] = etud.nomprenom d.update(self.res.sem) + etud_etat = self.res.get_etud_etat(etud.id) d["filigranne"] = sco_bulletins_pdf.get_filigranne( - self.res.get_etud_etat(etud.id), + etud_etat, self.prefs, decision_sem=d["semestre"].get("decision_sem"), ) + if etud_etat == scu.DEMISSION: + d["demission"] = "(Démission)" + elif etud_etat == DEF: + d["demission"] = "(Défaillant)" + else: + d["demission"] = "" + # --- Absences d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id) + + # --- Decision Jury + infos, dpv = sco_bulletins.etud_descr_situation_semestre( + etud.id, + self.res.formsemestre.id, + format="html", + show_date_inscr=self.prefs["bul_show_date_inscr"], + show_decisions=self.prefs["bul_show_decision"], + show_uevalid=self.prefs["bul_show_uevalid"], + show_mention=self.prefs["bul_show_mention"], + ) + + d.update(infos) # --- Rangs d[ "rang_nt" @@ -341,5 +408,6 @@ class BulletinBUT: d.update( sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id) ) - # XXX TODO A COMPLETER ? + d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"])) + return d diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py index 5003e216e..8a87f9005 100644 --- a/app/but/bulletin_but_pdf.py +++ b/app/but/bulletin_but_pdf.py @@ -6,20 +6,13 @@ """Génération bulletin BUT au format PDF standard """ +from reportlab.platypus import Paragraph, Spacer -import datetime from app.scodoc.sco_pdf import blue, cm, mm -from flask import url_for, g -from app.models.formsemestre import FormSemestre - from app.scodoc import gen_tables -from app.scodoc import sco_utils as scu -from app.scodoc import sco_bulletins_json -from app.scodoc import sco_preferences from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_utils import fmt_note -from app.comp.res_but import ResultatsSemestreBUT from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard @@ -31,6 +24,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): """ list_in_menu = False # spécialisation du BulletinGeneratorStandard, ne pas présenter à l'utilisateur + scale_table_in_page = False # pas de mise à l'échelle pleine page auto + multi_pages = True # plusieurs pages par bulletins + small_fontsize = "8" def bul_table(self, format="html"): """Génère la table centrale du bulletin de notes @@ -38,31 +34,38 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): - en HTML: une chaine - en PDF: une liste d'objets PLATYPUS (eg instance de Table). """ - formsemestre_id = self.infos["formsemestre_id"] - ( - synth_col_keys, - synth_P, - synth_pdf_style, - synth_col_widths, - ) = self.but_table_synthese() - # - table_synthese = gen_tables.GenTable( - rows=synth_P, - columns_ids=synth_col_keys, - pdf_table_style=synth_pdf_style, - pdf_col_widths=[synth_col_widths[k] for k in synth_col_keys], - preferences=self.preferences, - html_class="notes_bulletin", - html_class_ignore_default=True, - html_with_td_classes=True, - ) - # Ici on ajoutera table des ressources, tables des UE - # TODO + tables_infos = [ + # ---- TABLE SYNTHESE UES + self.but_table_synthese_ues(), + ] + if self.version != "short": + tables_infos += [ + # ---- TABLE RESSOURCES + self.but_table_ressources(), + # ---- TABLE SAE + self.but_table_saes(), + ] + objects = [] + for i, (col_keys, rows, pdf_style, col_widths) in enumerate(tables_infos): + table = gen_tables.GenTable( + rows=rows, + columns_ids=col_keys, + pdf_table_style=pdf_style, + pdf_col_widths=[col_widths[k] for k in col_keys], + preferences=self.preferences, + html_class="notes_bulletin", + html_class_ignore_default=True, + html_with_td_classes=True, + ) + table_objects = table.gen(format=format) + objects += table_objects + # objects += [KeepInFrame(0, 0, table_objects, mode="shrink")] + if i != 2: + objects.append(Spacer(1, 6 * mm)) - # XXX à modifier pour générer plusieurs tables: - return table_synthese.gen(format=format) + return objects - def but_table_synthese(self): + def but_table_synthese_ues(self, title_bg=(182, 235, 255)): """La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes et leurs coefs. Renvoie: colkeys, P, pdf_style, colWidths @@ -76,41 +79,270 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): "moyenne": 2 * cm, "coef": 2 * cm, } - P = [] # elems pour générer table avec gen_table (liste de dicts) - col_keys = ["titre", "moyenne"] # noms des colonnes à afficher - for ue_acronym, ue in self.infos["ues"].items(): - # 1er ligne titre UE - moy_ue = ue.get("moyenne") - t = { - "titre": f"{ue_acronym} - {ue['titre']}", - "moyenne": moy_ue.get("value", "-") if moy_ue is not None else "-", - "_css_row_class": "note_bold", - "_pdf_row_markup": ["b"], - "_pdf_style": [], - } - P.append(t) - # 2eme ligne titre UE (bonus/malus/ects) - t = { - "titre": "", - "moyenne": f"""Bonus: {ue['bonus']} - Malus: { - ue["malus"]} - ECTS: {ue["ECTS"]["acquis"]} / {ue["ECTS"]["total"]}""", + title_bg = tuple(x / 255.0 for x in title_bg) + nota_bene = "La moyenne des ressources et SAÉs dans une UE dépend des poids donnés aux évaluations." + # elems pour générer table avec gen_table (liste de dicts) + rows = [ + # Ligne de titres + { + "titre": "Unités d'enseignement", + "moyenne": Paragraph("Note/20"), + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), "_css_row_class": "note_bold", "_pdf_row_markup": ["b"], "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + # ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + }, + { + "titre": nota_bene, + "_titre_pdf": Paragraph( + f"{nota_bene}" + ), + "_titre_colspan": 3, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), ( "LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, + blue, + ), + ], + }, + ] + col_keys = ["titre", "coef", "moyenne"] # noms des colonnes à afficher + for ue_acronym, ue in self.infos["ues"].items(): + self.ue_rows(rows, ue_acronym, ue, title_bg) + # 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, rows, pdf_style, col_widths + + def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple): + "Décrit une UE dans la table synthèse: titre, sous-titre et liste modules" + # 1er ligne titre UE + moy_ue = ue.get("moyenne") + t = { + "titre": f"{ue_acronym} - {ue['titre']}", + "moyenne": Paragraph( + f"""{moy_ue.get("value", "-") if moy_ue is not None else "-"}""" + ), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + self.PDF_LINECOLOR, + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ], + } + rows.append(t) + if ue["type"] == UE_SPORT: + self.ue_sport_rows(rows, ue, title_bg) + else: + self.ue_std_rows(rows, ue, title_bg) + + def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant une UE standard dans la table de synthèse" + # 2eme ligne titre UE (bonus/malus/ects) + if "ECTS" in ue: + ects_txt = f'ECTS: {ue["ECTS"]["acquis"]:.3g} / {ue["ECTS"]["total"]:.3g}' + else: + ects_txt = "" + t = { + "titre": f"""Bonus: {ue['bonus']} - Malus: { + ue["malus"]}""", + "coef": ects_txt, + "_coef_pdf": Paragraph(f"""{ects_txt}"""), + "_coef_colspan": 2, + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("LINEBELOW", (0, 0), (-1, 0), self.PDF_LINEWIDTH, self.PDF_LINECOLOR), + # cadre autour du bonus/malus, gris clair + ("BOX", (0, 0), (0, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)), + ], + } + rows.append(t) + + # Liste chaque ressource puis chaque SAE + for mod_type in ("ressources", "saes"): + for mod_code, mod in ue[mod_type].items(): + t = { + "titre": f"{mod_code} {self.infos[mod_type][mod_code]['titre']}", + "moyenne": Paragraph(f'{mod["moyenne"]}'), + "coef": mod["coef"], + "_coef_pdf": Paragraph( + f"{mod['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + rows.append(t) + + def ue_sport_rows(self, rows: list, ue: dict, title_bg: tuple): + "Lignes décrivant l'UE bonus dans la table de synthèse" + # UE BONUS + for mod_code, mod in ue["modules"].items(): + rows.append( + { + "titre": f"{mod_code} {mod['titre']}", + } + ) + self.evaluations_rows(rows, mod["evaluations"]) + + def but_table_ressources(self): + """La table de synthèse; pour chaque ressources, note et liste d'évaluations + Renvoie: colkeys, P, pdf_style, colWidths + """ + return self.bul_table_modules( + mod_type="ressources", title="Ressources", title_bg=(248, 200, 68) + ) + + def but_table_saes(self): + "table des SAEs" + return self.bul_table_modules( + mod_type="saes", + title="Situations d'apprentissage et d'évaluation", + title_bg=(198, 255, 171), + ) + + def bul_table_modules(self, mod_type=None, title="", title_bg=(248, 200, 68)): + """Table ressources ou SAEs + - colkeys: nom des colonnes de la table (clés) + - P : table (liste de dicts de chaines de caracteres) + - pdf_style : commandes table Platypus + - largeurs de colonnes pour PDF + """ + # UE à utiliser pour les poids (# colonne/UE) + ue_infos = self.infos["ues"] + ue_acros = list( + [k for k in ue_infos if ue_infos[k]["type"] != UE_SPORT] + ) # ['RT1.1', 'RT2.1', 'RT3.1'] + # Colonnes à afficher: + col_keys = ["titre"] + ue_acros + ["coef", "moyenne"] + # Largeurs des colonnes: + col_widths = { + "titre": None, + # "poids": None, + "moyenne": 2 * cm, + "coef": 2 * cm, + } + for ue_acro in ue_acros: + col_widths[ue_acro] = 12 * mm # largeur col. poids + + title_bg = tuple(x / 255.0 for x in title_bg) + # elems pour générer table avec gen_table (liste de dicts) + # Ligne de titres + t = { + "titre": title, + # "_titre_colspan": 1 + len(ue_acros), + "moyenne": "Note/20", + "coef": "Coef.", + "_coef_pdf": Paragraph("Coef."), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + blue, + ), + ], + } + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"{ue_acro}" + ) + rows = [t] + for mod_code, mod in self.infos[mod_type].items(): + # 1er ligne titre module + t = { + "titre": f"{mod_code} - {mod['titre']}", + "_titre_colspan": 2 + len(ue_acros), + "_css_row_class": "note_bold", + "_pdf_row_markup": ["b"], + "_pdf_style": [ + ( + "LINEABOVE", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, self.PDF_LINECOLOR, - ) + ), + ("BACKGROUND", (0, 0), (-1, 0), title_bg), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), ], } - P.append(t) + rows.append(t) + # Evaluations: + self.evaluations_rows(rows, mod["evaluations"], ue_acros) # 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 + return col_keys, rows, pdf_style, col_widths + + def evaluations_rows(self, rows, evaluations, ue_acros=()): + "lignes des évaluations" + for e in evaluations: + t = { + "titre": f"{e['description']}", + "moyenne": e["note"]["value"], + "_moyenne_pdf": Paragraph( + f"""{e["note"]["value"]}""" + ), + "coef": e["coef"], + "_coef_pdf": Paragraph( + f"{e['coef']}" + ), + "_pdf_style": [ + ( + "LINEBELOW", + (0, 0), + (-1, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ) + ], + } + col_idx = 1 # 1ere col. poids + for ue_acro in ue_acros: + t[ue_acro] = Paragraph( + f"""{e["poids"].get(ue_acro, "") or ""}""" + ) + t["_pdf_style"].append( + ( + "BOX", + (col_idx, 0), + (col_idx, 0), + self.PDF_LINEWIDTH, + (0.7, 0.7, 0.7), # gris clair + ), + ) + col_idx += 1 + rows.append(t) diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 73e06c4de..bab7b7287 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -72,7 +72,7 @@ def bulletin_but_xml_compat( etud: Identite = Identite.query.get_or_404(etudid) results = bulletin_but.ResultatsSemestreBUT(formsemestre) nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT] - # etat_inscription = etud.etat_inscription(formsemestre.id) + # etat_inscription = etud.inscription_etat(formsemestre.id) etat_inscription = results.formsemestre.etuds_inscriptions[etudid].etat if (not formsemestre.bul_hide_xml) or force_publishing: published = 1 diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index b27219413..ece1d3611 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -89,7 +89,7 @@ class BonusSport: for m in formsemestre.modimpls_sorted ] ) - if not len(modimpl_mask): + if len(modimpl_mask) == 0: modimpl_mask = np.s_[:] # il n'y a rien, on prend tout donc rien self.modimpls_spo = [ modimpl @@ -200,10 +200,11 @@ class BonusSportAdditif(BonusSport): """ seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte - seuil_comptage = ( - None # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen) - ) + # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen): + seuil_comptage = None proportion_point = 0.05 # multiplie les points au dessus du seuil + bonux_max = 20.0 # le bonus ne peut dépasser 20 points + bonus_min = 0.0 # et ne peut pas être négatif def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus @@ -220,19 +221,16 @@ class BonusSportAdditif(BonusSport): ) bonus_moy_arr = np.sum( np.where( - sem_modimpl_moys_inscrits > self.seuil_moy_gen, + (sem_modimpl_moys_inscrits >= self.seuil_moy_gen) + & (modimpl_coefs_etuds_no_nan > 0), (sem_modimpl_moys_inscrits - seuil_comptage) * self.proportion_point, 0.0, ), axis=1, ) - if self.bonus_max is not None: - # Seuil: bonus limité à bonus_max points (et >= 0) - bonus_moy_arr = np.clip( - bonus_moy_arr, 0.0, self.bonus_max, out=bonus_moy_arr - ) - else: # necessaire pour éviter bonus négatifs ! - bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr) + # Seuil: bonus dans [min, max] (défaut [0,20]) + bonus_max = self.bonus_max or 0.0 + np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr) self.bonus_additif(bonus_moy_arr) @@ -510,14 +508,14 @@ class BonusCachan1(BonusSportAdditif):
  • BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie - les moyennes d'UE à raison de bonus = (option - 10)*5%.
  • + les moyennes d'UE à raison de bonus = (option - 10) * 3%. """ name = "bonus_cachan1" displayed_name = "IUT de Cachan 1" seuil_moy_gen = 10.0 # tous les points sont comptés - proportion_point = 0.05 + proportion_point = 0.03 classic_use_bonus_ues = True def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): @@ -754,8 +752,25 @@ class BonusLille(BonusSportAdditif): ) +class BonusLimousin(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture) à l'IUT du Limousin + + Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles + sont cumulés. + + La moyenne de chacune des UE du semestre pair est augmentée de 5% du + cumul des points de bonus. + Le maximum de points bonus est de 0,5. + """ + + name = "bonus_limousin" + displayed_name = "IUT du Limousin" + proportion_point = 0.05 + bonus_max = 0.5 + + class BonusLyonProvisoire(BonusSportAdditif): - """Calcul bonus modules optionnels (sport, culture), règle IUT de Lyon (provisoire) + """Calcul bonus modules optionnels (sport, culture) à l'IUT de Lyon (provisoire) Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés et 1,8% de ces points cumulés @@ -769,8 +784,36 @@ class BonusLyonProvisoire(BonusSportAdditif): bonus_max = 0.5 +class BonusMantes(BonusSportAdditif): + """Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines. + +

    + Soit N la note attribuée, le bonus (ou malus) correspond à : + (N-10) x 0,05 + appliqué sur chaque UE du semestre sélectionné pour le BUT + ou appliqué sur la moyenne générale du semestre sélectionné pour le DUT. +

    +

    Exemples :

    + + """ + + name = "bonus_mantes" + displayed_name = "IUT de Mantes en Yvelines" + bonus_min = -0.5 # peut être NEGATIF ! + bonus_max = 0.5 + seuil_moy_gen = 0.0 # tous les points comptent + seuil_comptage = 10.0 # pivot à 10. + proportion_point = 0.05 + + class BonusMulhouse(BonusSportAdditif): - """Calcul bonus modules optionnels (sport, culture), règle IUT de Mulhouse + """Calcul bonus modules optionnels (sport, culture) à l'IUT de Mulhouse La moyenne de chacune des UE du semestre sera majorée à hauteur de 5% du cumul des points supérieurs à 10 obtenus en matières optionnelles, @@ -809,6 +852,19 @@ class BonusNantes(BonusSportAdditif): bonus_max = 0.5 # plafonnement à 0.5 points +class BonusPoitiers(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Poitiers. + + Les deux notes d'option supérieure à 10, bonifies les moyennes de chaque UE. + + bonus = (option1 - 10)*5% + (option2 - 10)*5% + """ + + name = "bonus_poitiers" + displayed_name = "IUT de Poitiers" + proportion_point = 0.05 + + class BonusRoanne(BonusSportAdditif): """IUT de Roanne. @@ -824,6 +880,27 @@ class BonusRoanne(BonusSportAdditif): proportion_point = 1 +class BonusStBrieuc(BonusSportAdditif): + """IUT de Saint Brieuc + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + + # Utilisé aussi par St Malo, voir plus bas + name = "bonus_iut_stbrieuc" + displayed_name = "IUT de Saint-Brieuc" + proportion_point = 1 / 20.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if self.formsemestre.semestre_id % 2 == 0: + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + class BonusStDenis(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Saint-Denis @@ -841,6 +918,64 @@ class BonusStDenis(BonusSportAdditif): bonus_max = 0.5 +class BonusStMalo(BonusStBrieuc): + # identique à St Brieux, sauf la doc + """IUT de Saint Malo + + Ne s'applique qu'aux semestres pairs (S2, S4, S6), et bonifie les moyennes d'UE: + + """ + name = "bonus_iut_stmalo" + displayed_name = "IUT de Saint-Malo" + + +class BonusTarbes(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes. + + + """ + + name = "bonus_tarbes" + displayed_name = "IUT de Tazrbes" + seuil_moy_gen = 10.0 + proportion_point = 1 / 30.0 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # Prend la note de chaque modimpl, sans considération d'UE + if len(sem_modimpl_moys_inscrits.shape) > 2: # apc + sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] + # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic + note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_idx = [ue.id for ue in ues] + + if self.formsemestre.formation.is_apc(): # --- BUT + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * self.proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + + class BonusTours(BonusDirect): """Calcul bonus sport & culture IUT Tours. diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index db42616c8..61b5fd15c 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,7 +30,8 @@ import numpy as np import pandas as pd -from flask import flash +from flask import flash, g, Markup, url_for +from app.models.formations import Formation def compute_sem_moys_apc_using_coefs( @@ -51,7 +52,7 @@ def compute_sem_moys_apc_using_coefs( def compute_sem_moys_apc_using_ects( - etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None + etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False ) -> pd.Series: """Calcule les moyennes générales indicatives de tous les étudiants = moyenne des moyennes d'UE, pondérée par leurs ECTS. @@ -59,13 +60,29 @@ def compute_sem_moys_apc_using_ects( etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid ects: liste de floats ou None, 1 par UE + Si skip_empty_ues: ne compte pas les UE non notées. + Sinon (par défaut), une UE non notée compte comme zéro. + Result: panda Series, index etudid, valeur float (moyenne générale) """ try: - moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) + if skip_empty_ues: + # annule les coefs des UE sans notes (NaN) + ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float)) + # ects est devenu nb_etuds x nb_ues + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1) + else: + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) except TypeError: if None in ects: - flash("""Calcul moyenne générale impossible: ECTS des UE manquants !""") + formation = Formation.query.get(formation_id) + flash( + Markup( + f"""Calcul moyenne générale impossible: ECTS des UE manquants !
    + (formation: {formation.get_titre_version()})""" + ) + ) moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index) else: raise @@ -76,8 +93,12 @@ def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur numérique) en tenant compte des ex-aequos. - Result: Series { etudid : rang:str } où rang est une chaine decrivant le rang. + Result: couple (tuple) + Series { etudid : rang:str } où rang est une chaine decrivant le rang, + Series { etudid : rang:int } le rang comme un nombre """ + if (notes is None) or (len(notes) == 0): + return (pd.Series([], dtype=object), pd.Series([], dtype=int)) notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant rangs_str = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne rangs_int = pd.Series(index=notes.index, dtype=int) # le rang numérique pour tris diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index efbe7cd34..6d80f0b7b 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -197,6 +197,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) etuds_moy_module = mod_results.compute_module_moy(evals_poids) modimpls_results[modimpl.id] = mod_results + modimpls_evals_poids[modimpl.id] = evals_poids modimpls_notes.append(etuds_moy_module) if len(modimpls_notes): cube = notes_sem_assemble_cube(modimpls_notes) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 7bd79463f..3ffed6700 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -14,8 +14,10 @@ from app import log from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport -from app.models import ScoDocSiteConfig, formsemestre +from app.models import ScoDocSiteConfig +from app.models.moduleimpls import ModuleImpl from app.models.ues import UniteEns +from app.scodoc import sco_preferences from app.scodoc.sco_codes_parcours import UE_SPORT @@ -112,6 +114,9 @@ class ResultatsSemestreBUT(NotesTableCompat): self.etud_moy_ue, [ue.ects for ue in self.ues if ue.type != UE_SPORT], formation_id=self.formsemestre.formation_id, + skip_empty_ues=sco_preferences.get_preference( + "but_moy_skip_empty_ues", self.formsemestre.id + ), ) # --- UE capitalisées self.apply_capitalisation() @@ -139,3 +144,16 @@ class ResultatsSemestreBUT(NotesTableCompat): (ne dépend pas des modules auxquels est inscrit l'étudiant, ). """ return self.modimpl_coefs_df.loc[ue.id].sum() + + def modimpls_in_ue(self, ue_id, etudid) -> list[ModuleImpl]: + """Liste des modimpl ayant des coefs non nuls vers cette UE + et auxquels l'étudiant est inscrit. + """ + # sert pour l'affichage ou non de l'UE sur le bulletin + coefs = self.modimpl_coefs_df # row UE, cols modimpl + return [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if (coefs[modimpl.id][ue_id] != 0) + and self.modimpl_inscr_df[modimpl.id][etudid] + ] diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index b36eaaf6c..b8f8ade02 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -15,7 +15,7 @@ from flask import g, url_for from app import db from app import log -from app.comp import moy_mat, moy_mod, moy_ue, inscr_mod +from app.comp import moy_mat, moy_mod, moy_sem, moy_ue, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig @@ -35,6 +35,7 @@ class ResultatsSemestreClassic(NotesTableCompat): "modimpl_coefs", "modimpl_idx", "sem_matrix", + "mod_rangs", ) def __init__(self, formsemestre): @@ -142,11 +143,32 @@ class ResultatsSemestreClassic(NotesTableCompat): if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id): self.compute_moyennes_matieres() + def compute_rangs(self): + """Calcul des rangs (classements) dans le semestre (moy. gen.), les UE + et les modules. + """ + # rangs moy gen et UEs sont calculées par la méthode commune à toutes les formations: + super().compute_rangs() + # les rangs des modules n'existent que dans les formations classiques: + self.mod_rangs = {} + for modimpl_result in self.modimpls_results.values(): + # ne prend que les rangs sous forme de chaines: + rangs = moy_sem.comp_ranks_series(modimpl_result.etuds_moy_module)[0] + self.mod_rangs[modimpl_result.moduleimpl_id] = ( + rangs, + modimpl_result.nb_inscrits_module, + ) + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM) """ - return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI") + try: + if self.modimpl_inscr_df[moduleimpl_id][etudid]: + return self.modimpls_results[moduleimpl_id].etuds_moy_module[etudid] + except KeyError: + pass + return "NI" def get_mod_stats(self, moduleimpl_id: int) -> dict: """Stats sur les notes obtenues dans un modimpl""" diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 8fa106f50..737347479 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -65,6 +65,9 @@ class ResultatsSemestre(ResultatsCache): self.moyennes_matieres = {} """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" + def __repr__(self): + return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" + def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" # voir ce qui est chargé / calculé ici et dans les sous-classes @@ -130,7 +133,7 @@ class ResultatsSemestre(ResultatsCache): - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules du parcours. XXX notion à implémenter, pour l'instant toutes les UE du semestre. - - En classique: toutes les UEs des modimpls auxquels l'étufdiant est inscrit sont + - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont susceptibles d'être validées. Les UE "bonus" (sport) ne sont jamais "validables". @@ -149,7 +152,9 @@ class ResultatsSemestre(ResultatsCache): return ues def modimpls_in_ue(self, ue_id, etudid) -> list[ModuleImpl]: - """Liste des modimpl de cette UE auxquels l'étudiant est inscrit""" + """Liste des modimpl de cette UE auxquels l'étudiant est inscrit. + Utile en formations classiques, surchargée pour le BUT. + """ # sert pour l'affichage ou non de l'UE sur le bulletin return [ modimpl @@ -175,7 +180,6 @@ class ResultatsSemestre(ResultatsCache): if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) ue_capitalisees = self.validations.ue_capitalisees - ue_by_code = {} for etudid in ue_capitalisees.index: recompute_mg = False # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) @@ -365,6 +369,7 @@ class NotesTableCompat(ResultatsSemestre): self.bonus_ues = None # virtuel self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues} self.mod_rangs = None # sera surchargé en Classic, mais pas en APC + """{ modimpl_id : (rangs, effectif) }""" self.moy_min = "NA" self.moy_max = "NA" self.moy_moy = "NA" diff --git a/app/email.py b/app/email.py index 226429df2..1fc7632b6 100644 --- a/app/email.py +++ b/app/email.py @@ -1,8 +1,17 @@ # -*- coding: UTF-8 -* +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + from threading import Thread -from flask import current_app + +from flask import current_app, g from flask_mail import Message + from app import mail +from app.scodoc import sco_preferences def send_async_email(app, msg): @@ -11,20 +20,66 @@ def send_async_email(app, msg): def send_email( - subject: str, sender: str, recipients: list, text_body: str, html_body="" + subject: str, + sender: str, + recipients: list, + text_body: str, + html_body="", + bcc=(), + attachments=(), ): """ - Send an email + Send an email. _All_ ScoDoc mails SHOULD be sent using this function. + If html_body is specified, build a multipart message with HTML content, else send a plain text email. + + attachements: list of dict { 'filename', 'mimetype', 'data' } """ - msg = Message(subject, sender=sender, recipients=recipients) + msg = Message(subject, sender=sender, recipients=recipients, bcc=bcc) msg.body = text_body msg.html = html_body + if attachments: + for attachment in attachments: + msg.attach( + attachment["filename"], attachment["mimetype"], attachment["data"] + ) + send_message(msg) -def send_message(msg): +def send_message(msg: Message): + """Send a message. + All ScoDoc emails MUST be sent by this function. + + In mail debug mode, addresses are discarded and all mails are sent to the + specified debugging address. + """ + if hasattr(g, "scodoc_dept"): + # on est dans un département, on peut accéder aux préférences + email_test_mode_address = sco_preferences.get_preference( + "email_test_mode_address" + ) + if email_test_mode_address: + # Mode spécial test: remplace les adresses de destination + orig_to = msg.recipients + orig_cc = msg.cc + orig_bcc = msg.bcc + msg.recipients = [email_test_mode_address] + msg.cc = None + msg.bcc = None + msg.subject = "[TEST SCODOC] " + msg.subject + msg.body = ( + f"""--- Message ScoDoc dérouté pour tests --- +Adresses d'origine: + to : {orig_to} + cc : {orig_cc} + bcc: {orig_bcc} +--- + \n\n""" + + msg.body + ) + Thread( target=send_async_email, args=(current_app._get_current_object(), msg) ).start() diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 18f13380d..65c0701fe 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,12 +4,14 @@ et données rattachées (adresses, annotations, ...) """ +import datetime from functools import cached_property from flask import abort, url_for from flask import g, request import sqlalchemy +from sqlalchemy import desc, text -from app import db +from app import db, log from app import models from app.scodoc import notesdb as ndb @@ -82,6 +84,11 @@ class Identite(db.Model): return scu.suppress_accents(s) return s + @property + def e(self): + "terminaison en français: 'ne', '', 'ou '(e)'" + return {"M": "", "F": "e"}.get(self.civilite, "(e)") + def nom_disp(self) -> str: "Nom à afficher" if self.nom_usuel: @@ -123,7 +130,7 @@ class Identite(db.Model): def get_first_email(self, field="email") -> str: "Le mail associé à la première adrese de l'étudiant, ou None" - return self.adresses[0].email or None if self.adresses.count() > 0 else None + return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None def to_dict_scodoc7(self): """Représentation dictionnaire, @@ -134,31 +141,41 @@ class Identite(db.Model): # ScoDoc7 output_formators: (backward compat) e["etudid"] = self.id e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) - e["ne"] = {"M": "", "F": "ne"}.get(self.civilite, "(e)") + e["ne"] = self.e return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): - """Infos exportées dans les bulletins""" + """Infos exportées dans les bulletins + L'étudiant, et sa première adresse. + """ from app.scodoc import sco_photos d = { "civilite": self.civilite, - "code_ine": self.code_ine, - "code_nip": self.code_nip, - "date_naissance": self.date_naissance.isoformat() + "code_ine": self.code_ine or "", + "code_nip": self.code_nip or "", + "date_naissance": self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance - else None, - "email": self.get_first_email(), + else "", + "email": self.get_first_email() or "", "emailperso": self.get_first_email("emailperso"), "etudid": self.id, "nom": self.nom_disp(), - "prenom": self.prenom, + "prenom": self.prenom or "", + "nomprenom": self.nomprenom or "", + "lieu_naissance": self.lieu_naissance or "", + "dept_naissance": self.dept_naissance or "", + "nationalite": self.nationalite or "", + "boursier": self.boursier or "", } if include_urls: d["fiche_url"] = url_for( "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id ) - d["photo_url"] = (sco_photos.get_etud_photo_url(self.id),) + d["photo_url"] = sco_photos.get_etud_photo_url(self.id) + adresse = self.adresses.first() + if adresse: + d.update(adresse.to_dict(convert_nulls_to_str=True)) return d def inscription_courante(self): @@ -172,6 +189,23 @@ class Identite(db.Model): ] return r[0] if r else None + def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]: + """Liste des inscriptions à des semestres _courants_ + (il est rare qu'il y en ai plus d'une, mais c'est possible). + Triées par date de début de semestre décroissante (le plus récent en premier). + """ + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + text("date_debut < now() and date_fin > now()"), + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + def inscription_courante_date(self, date_debut, date_fin): """La première inscription à un formsemestre incluant la période [date_debut, date_fin] @@ -183,8 +217,8 @@ class Identite(db.Model): ] return r[0] if r else None - def etat_inscription(self, formsemestre_id): - """etat de l'inscription de cet étudiant au semestre: + def inscription_etat(self, formsemestre_id): + """État de l'inscription de cet étudiant au semestre: False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF """ # voir si ce n'est pas trop lent: @@ -195,6 +229,110 @@ class Identite(db.Model): return ins.etat return False + def inscription_descr(self) -> dict: + """Description de l'état d'inscription""" + inscription_courante = self.inscription_courante() + if inscription_courante: + titre_sem = inscription_courante.formsemestre.titre_mois() + return { + "etat_in_cursem": inscription_courante.etat, + "inscription_courante": inscription_courante, + "inscription": titre_sem, + "inscription_str": "Inscrit en " + titre_sem, + "situation": self.descr_situation_etud(), + } + else: + if self.formsemestre_inscriptions: + # cherche l'inscription la plus récente: + fin_dernier_sem = max( + [ + inscr.formsemestre.date_debut + for inscr in self.formsemestre_inscriptions + ] + ) + if fin_dernier_sem > datetime.date.today(): + inscription = "futur" + situation = "futur élève" + else: + inscription = "ancien" + situation = "ancien élève" + else: + inscription = ("non inscrit",) + situation = inscription + return { + "etat_in_cursem": "?", + "inscription_courante": None, + "inscription": inscription, + "inscription_str": inscription, + "situation": situation, + } + + def descr_situation_etud(self) -> str: + """Chaîne décrivant la situation _actuelle_ de l'étudiant. + Exemple: + "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" + ou + "non inscrit" + """ + inscriptions_courantes = self.inscriptions_courantes() + if inscriptions_courantes: + inscr = inscriptions_courantes[0] + if inscr.etat == scu.INSCRIT: + situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" + # Cherche la date d'inscription dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="INSCRIPTION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" + ) + situation += " (inscription non enregistrée)" # ??? + else: + date_ins = events[0].event_date + situation += date_ins.strftime(" le %d/%m/%Y") + else: + situation = f"démission de {inscr.formsemestre.titre_mois()}" + # Cherche la date de demission dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEMISSION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (demission mais pas d'event)" + ) + date_dem = "???" # ??? + else: + date_dem = events[0].event_date + situation += date_dem.strftime(" le %d/%m/%Y") + else: + situation = "non inscrit" + self.e + + return situation + + def photo_html(self, title=None, size="small") -> str: + """HTML img tag for the photo, either in small size (h90) + or original size (size=="orig") + """ + from app.scodoc import sco_photos + + # sco_photo traite des dicts: + return sco_photos.etud_photo_html( + etud=dict( + etudid=self.id, + code_nip=self.code_nip, + nomprenom=self.nomprenom, + nom_disp=self.nom_disp(), + photo_filename=self.photo_filename, + ), + title=title, + size=size, + ) + def make_etud_args( etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True @@ -259,6 +397,14 @@ class Adresse(db.Model): ) description = db.Column(db.Text) + def to_dict(self, convert_nulls_to_str=False): + """Représentation dictionnaire,""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + if convert_nulls_to_str: + return {k: e[k] or "" for k in e} + return e + class Admission(db.Model): """Informations liées à l'admission d'un étudiant""" diff --git a/app/models/formations.py b/app/models/formations.py index edd57097d..c0f375ddc 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -59,6 +59,10 @@ class Formation(db.Model): """get l'instance de TypeParcours de cette formation""" return sco_codes_parcours.get_parcours_from_code(self.type_parcours) + def get_titre_version(self) -> str: + """Titre avec version""" + return f"{self.acronyme} {self.titre} v{self.version}" + def is_apc(self): "True si formation APC avec SAE (BUT)" return self.get_parcours().APC_SAE diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index c66df7820..060c859ff 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -12,7 +12,6 @@ from app import log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN -from app.models import UniteEns import app.scodoc.sco_utils as scu from app.models.ues import UniteEns @@ -23,6 +22,7 @@ from app.scodoc import sco_codes_parcours from app.scodoc import sco_preferences from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import MONTH_NAMES_ABBREV class FormSemestre(db.Model): @@ -161,10 +161,9 @@ class FormSemestre(db.Model): d["periode"] = 1 # typiquement, début en septembre: S1, S3... else: d["periode"] = 2 # typiquement, début en février: S2, S4... - d["titre_num"] = self.titre_num() d["titreannee"] = self.titre_annee() - d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}" - d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}" + d["mois_debut"] = self.mois_debut() + d["mois_fin"] = self.mois_fin() d["titremois"] = "%s %s (%s - %s)" % ( d["titre_num"], self.modalite or "", @@ -174,7 +173,6 @@ class FormSemestre(db.Model): d["session_id"] = self.session_id() d["etapes"] = self.etapes_apo_vdi() d["etapes_apo_str"] = self.etapes_apo_str() - d["responsables"] = [u.id for u in self.responsables] # liste des ids return d def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: @@ -294,6 +292,7 @@ class FormSemestre(db.Model): """chaîne "J. Dupond, X. Martin" ou "Jacques Dupond, Xavier Martin" """ + # was "nomcomplet" if not self.responsables: return "" if abbrev_prenom: @@ -301,10 +300,22 @@ class FormSemestre(db.Model): else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) + def est_responsable(self, user): + "True si l'user est l'un des responsables du semestre" + return user.id in [u.id for u in self.responsables] + def annee_scolaire_str(self): "2021 - 2022" return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) + def mois_debut(self) -> str: + "Oct 2021" + return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}" + + def mois_fin(self) -> str: + "Jul 2022" + return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}" + def session_id(self) -> str: """identifiant externe de semestre de formation Exemple: RT-DUT-FI-S1-ANNEE diff --git a/app/models/groups.py b/app/models/groups.py index 976d465be..f6452cf7c 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -63,6 +63,12 @@ class GroupDescr(db.Model): # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) + etuds = db.relationship( + "Identite", + secondary="group_membership", + lazy="dynamic", + ) + def __repr__(self): return ( f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">""" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 0aa74ef4b..292ec8ffd 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -6,7 +6,8 @@ import flask_sqlalchemy from app import db from app.comp import df_cache -from app.models import Identite, Module +from app.models.etudiants import Identite +from app.models.modules import Module import app.scodoc.notesdb as ndb from app.scodoc import sco_utils as scu @@ -133,7 +134,9 @@ class ModuleImplInscription(db.Model): def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int ) -> flask_sqlalchemy.BaseQuery: - """moduleimpls de l'UE auxquels l'étudiant est inscrit""" + """moduleimpls de l'UE auxquels l'étudiant est inscrit. + (Attention: inutile en APC, il faut considérer les coefficients) + """ return ModuleImplInscription.query.filter( ModuleImplInscription.etudid == etudid, ModuleImplInscription.moduleimpl_id == ModuleImpl.id, diff --git a/app/pe/pe_semestretag.py b/app/pe/pe_semestretag.py index f48e69c40..43ea48d73 100644 --- a/app/pe/pe_semestretag.py +++ b/app/pe/pe_semestretag.py @@ -194,12 +194,14 @@ class SemestreTag(pe_tagtable.TableTag): return tagdict # ----------------------------------------------------------------------------- - def comp_MoyennesTag(self, tag, force=False): - """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag (non défaillants) - à un tag donné, en prenant en compte + def comp_MoyennesTag(self, tag, force=False) -> list: + """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag + (non défaillants) à un tag donné, en prenant en compte tous les modimpl_id concerné par le tag, leur coeff et leur pondération. Force ou non le calcul de la moyenne lorsque des notes sont manquantes. - Renvoie les informations sous la forme d'une liste [ (moy, somme_coeff_normalise, etudid), ...] + + Renvoie les informations sous la forme d'une liste + [ (moy, somme_coeff_normalise, etudid), ...] """ lesMoyennes = [] for etudid in self.get_etudids(): diff --git a/app/pe/pe_tagtable.py b/app/pe/pe_tagtable.py index 0e5045cba..26cc8e242 100644 --- a/app/pe/pe_tagtable.py +++ b/app/pe/pe_tagtable.py @@ -38,6 +38,7 @@ Created on Thu Sep 8 09:36:33 2016 """ import datetime +import numpy as np from app.scodoc import notes_table @@ -287,48 +288,53 @@ class TableTag(object): # ********************************************* -def moyenne_ponderee_terme_a_terme(notes, coeffs=None, force=False): +def moyenne_ponderee_terme_a_terme(notes, coefs=None, force=False): """ Calcule la moyenne pondérée d'une liste de notes avec d'éventuels coeffs de pondération. Renvoie le résultat sous forme d'un tuple (moy, somme_coeff) - La liste de notes contient soit : 1) des valeurs numériques 2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) - ou "-c-" ue capitalisée, 3) None. + La liste de notes contient soit : + 1) des valeurs numériques + 2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) ou "-c-" ue capitalisée, + 3) None. + Le paramètre force indique si le calcul de la moyenne doit être forcée ou non, c'est à - dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est calculée sur les - notes disponibles) ; sinon renvoie (None, None). + dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est + calculée sur les notes disponibles) ; sinon renvoie (None, None). """ # Vérification des paramètres d'entrée if not isinstance(notes, list) or ( - coeffs != None and not isinstance(coeffs, list) and len(coeffs) != len(notes) + coefs != None and not isinstance(coefs, list) and len(coefs) != len(notes) ): raise ValueError("Erreur de paramètres dans moyenne_ponderee_terme_a_terme") # Récupération des valeurs des paramètres d'entrée - coeffs = [1] * len(notes) if coeffs == None else coeffs + coefs = [1] * len(notes) if coefs is None else coefs # S'il n'y a pas de notes if not notes: # Si notes = [] return (None, None) - notesValides = [ - (1 if isinstance(note, float) or isinstance(note, int) else 0) for note in notes - ] # Liste indiquant les notes valides - if force == True or ( - force == False and sum(notesValides) == len(notes) - ): # Si on force le calcul de la moyenne ou qu'on ne le force pas et qu'on a le bon nombre de notes - (moyenne, ponderation) = (0.0, 0.0) + # Liste indiquant les notes valides + notes_valides = [ + (isinstance(note, float) and not np.isnan(note)) or isinstance(note, int) + for note in notes + ] + # Si on force le calcul de la moyenne ou qu'on ne le force pas + # et qu'on a le bon nombre de notes + if force or sum(notes_valides) == len(notes): + moyenne, ponderation = 0.0, 0.0 for i in range(len(notes)): - if notesValides[i]: - moyenne += coeffs[i] * notes[i] - ponderation += coeffs[i] + if notes_valides[i]: + moyenne += coefs[i] * notes[i] + ponderation += coefs[i] return ( (moyenne / (ponderation * 1.0), ponderation) if ponderation != 0 else (None, 0) ) - else: # Si on ne force pas le calcul de la moyenne - return (None, None) + # Si on ne force pas le calcul de la moyenne + return (None, None) # ------------------------------------------------------------------------------------------- diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 5af1a5754..558b4bf8b 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -120,7 +120,6 @@ def pe_view_sem_recap( # template fourni via le formulaire Web if footer_tmpl_file: footer_latex = footer_tmpl_file.read().decode("utf-8") - footer_latex = footer_latex else: footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( formsemestre_id, champ="pe_avis_latex_footer" diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 1cf4ea924..c8acefaf5 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -293,6 +293,13 @@ class TF(object): % (val, field, descr["max_value"]) ) ok = 0 + if ok and (typ[:3] == "str") and "max_length" in descr: + if len(self.values[field]) > descr["max_length"]: + msg.append( + "Le champ '%s' est trop long (max %d caractères)" + % (field, descr["max_length"]) + ) + ok = 0 # allowed values if "allowed_values" in descr: diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py index 855545556..7f5531c6f 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -63,12 +63,15 @@ from app.scodoc.sco_pdf import SU from app import log -def mark_paras(L, tags): - """Put each (string) element of L between """ +def mark_paras(L, tags) -> list[str]: + """Put each (string) element of L between ..., + for each supplied tag. + Leave non string elements untouched. + """ for tag in tags: - b = "<" + tag + ">" - c = "" - L = [b + (x or "") + c for x in L] + start = "<" + tag + ">" + end = "" + L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L] return L @@ -233,7 +236,10 @@ class GenTable(object): colspan_count -= 1 # if colspan_count > 0: # continue # skip cells after a span - content = row.get(cid, "") or "" # nota: None converted to '' + if pdf_mode: + content = row.get(f"_{cid}_pdf", "") or row.get(cid, "") or "" + else: + content = row.get(cid, "") or "" # nota: None converted to '' colspan = row.get("_%s_colspan" % cid, 0) if colspan > 1: pdf_style_list.append( @@ -547,9 +553,16 @@ class GenTable(object): omit_hidden_lines=True, ) try: - Pt = [ - [Paragraph(SU(str(x)), CellStyle) for x in line] for line in data_list - ] + Pt = [] + for line in data_list: + Pt.append( + [ + Paragraph(SU(str(x)), CellStyle) + if (not isinstance(x, Paragraph)) + else x + for x in line + ] + ) except ValueError as exc: raise ScoPDFFormatError(str(exc)) from exc pdf_style_list += self.pdf_table_style @@ -748,7 +761,7 @@ if __name__ == "__main__": doc = io.BytesIO() document = sco_pdf.BaseDocTemplate(doc) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, ) ) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 653cdb80d..6d128fd85 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -35,7 +35,7 @@ from flask import request from flask_login import current_user import app.scodoc.sco_utils as scu -from app import log +from app import scodoc_flash_status_messages from app.scodoc import html_sidebar import sco_version @@ -153,13 +153,14 @@ def sco_header( "Main HTML page header for ScoDoc" from app.scodoc.sco_formsemestre_status import formsemestre_page_title + scodoc_flash_status_messages() + # Get head message from http request: if not head_message: if request.method == "POST": head_message = request.form.get("head_message", "") elif request.method == "GET": head_message = request.args.get("head_message", "") - params = { "page_title": page_title or sco_version.SCONAME, "no_side_bar": no_side_bar, @@ -249,6 +250,9 @@ def sco_header( '' ) H.append('') + # H.append( + # '' + # ) # JS additionels for js in javascripts: H.append("""\n""" % js) diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index f15e7d4c8..5f9670f50 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -35,6 +35,7 @@ import datetime from flask import g, url_for from flask_mail import Message +from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu @@ -55,27 +56,30 @@ def abs_notify(etudid, date): """ from app.scodoc import sco_abs - sem = retreive_current_formsemestre(etudid, date) - if not sem: + formsemestre = retreive_current_formsemestre(etudid, date) + if not formsemestre: return # non inscrit a la date, pas de notification - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) - do_abs_notify(sem, etudid, date, nbabs, nbabsjust) + nbabs, nbabsjust = sco_abs.get_abs_count_in_interval( + etudid, formsemestre.date_debut.isoformat(), formsemestre.date_fin.isoformat() + ) + do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust) -def do_abs_notify(sem, etudid, date, nbabs, nbabsjust): +def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust): """Given new counts of absences, check if notifications are requested and send them.""" # prefs fallback to global pref if sem is None: - if sem: - formsemestre_id = sem["formsemestre_id"] + if formsemestre: + formsemestre_id = formsemestre.id else: formsemestre_id = None - prefs = sco_preferences.SemPreferences(formsemestre_id=sem["formsemestre_id"]) + prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id) destinations = abs_notify_get_destinations( - sem, prefs, etudid, date, nbabs, nbabsjust + formsemestre, prefs, etudid, date, nbabs, nbabsjust ) - msg = abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust) + + msg = abs_notification_message(formsemestre, prefs, etudid, nbabs, nbabsjust) if not msg: return # abort @@ -131,19 +135,19 @@ def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id ) -def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): +def abs_notify_get_destinations( + formsemestre: FormSemestre, prefs, etudid, date, nbabs, nbabsjust +) -> set: """Returns set of destination emails to be notified""" - formsemestre_id = sem["formsemestre_id"] destinations = [] # list of email address to notify - if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id): - if sem and prefs["abs_notify_respsem"]: + if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre.id): + if prefs["abs_notify_respsem"]: # notifie chaque responsable du semestre - for responsable_id in sem["responsables"]: - u = sco_users.user_info(responsable_id) - if u["email"]: - destinations.append(u["email"]) + for responsable in formsemestre.responsables: + if responsable.email: + destinations.append(responsable.email) if prefs["abs_notify_chief"] and prefs["email_chefdpt"]: destinations.append(prefs["email_chefdpt"]) if prefs["abs_notify_email"]: @@ -156,7 +160,7 @@ def abs_notify_get_destinations(sem, prefs, etudid, date, nbabs, nbabsjust): # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date # nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas - if sem and prefs["abs_notify_respeval"]: + if prefs["abs_notify_respeval"]: mods = mod_with_evals_at_date(date, etudid) for mod in mods: u = sco_users.user_info(mod["responsable_id"]) @@ -232,7 +236,9 @@ def user_nbdays_since_last_notif(email_addr, etudid): return None -def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): +def abs_notification_message( + formsemestre: FormSemestre, prefs, etudid, nbabs, nbabsjust +): """Mime notification message based on template. returns a Message instance or None if sending should be canceled (empty template). @@ -242,13 +248,13 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] # Variables accessibles dans les balises du template: %(nom_variable)s : - values = sco_bulletins.make_context_dict(sem, etud) + values = sco_bulletins.make_context_dict(formsemestre, etud) values["nbabs"] = nbabs values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] @@ -264,9 +270,11 @@ def abs_notification_message(sem, prefs, etudid, nbabs, nbabsjust): return msg -def retreive_current_formsemestre(etudid, cur_date): +def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre: """Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée date est une chaine au format ISO (yyyy-mm-dd) + + Result: FormSemestre ou None si pas inscrit à la date indiquée """ req = """SELECT i.formsemestre_id FROM notes_formsemestre_inscription i, notes_formsemestre sem @@ -278,8 +286,8 @@ def retreive_current_formsemestre(etudid, cur_date): if not r: return None # s'il y a plusieurs semestres, prend le premier (rarissime et non significatif): - sem = sco_formsemestre.get_formsemestre(r[0]["formsemestre_id"]) - return sem + formsemestre = FormSemestre.query.get(r[0]["formsemestre_id"]) + return formsemestre def mod_with_evals_at_date(date_abs, etudid): diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 686d589d7..1c4ff8fe5 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -724,6 +724,7 @@ def CalAbs(etudid, sco_year=None): anneescolaire = int(scu.AnneeScolaire(sco_year)) datedebut = str(anneescolaire) + "-08-01" datefin = str(anneescolaire + 1) + "-07-31" + annee_courante = scu.AnneeScolaire() nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) events = [] @@ -776,7 +777,7 @@ def CalAbs(etudid, sco_year=None): """Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), """  Changer année: - - """ - % (etudid, formsemestre_id) - ) - H.append('
    ') - - # --- Pied de page - H.append(html_sco_header.sco_footer()) - return "".join(H) @@ -897,7 +850,7 @@ def do_formsemestre_bulletinetud( formsemestre: FormSemestre, etudid: int, version="long", # short, long, selectedevals - format="html", + format=None, nohtml=False, xml_with_decisions=False, # force décisions dans XML force_publishing=False, # force publication meme si semestre non publié sur "portail" @@ -908,6 +861,7 @@ def do_formsemestre_bulletinetud( 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"). """ + format = format or "html" if format == "xml": bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud( formsemestre.id, @@ -930,12 +884,12 @@ def do_formsemestre_bulletinetud( return bul, "" if formsemestre.formation.is_apc(): - etud = Identite.query.get(etudid) + etudiant = Identite.query.get(etudid) r = bulletin_but.BulletinBUT(formsemestre) - I = r.bulletin_etud_complet(etud) + I = r.bulletin_etud_complet(etudiant, version=version) else: I = formsemestre_bulletinetud_dict(formsemestre.id, etudid) - etud = I["etud"] + etud = I["etud"] if format == "html": htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud( @@ -978,7 +932,7 @@ def do_formsemestre_bulletinetud( if prefer_mail_perso: recipient_addr = etud.get("emailperso", "") or etud.get("email", "") else: - recipient_addr = etud["email_default"] + recipient_addr = etud.get("email", "") or etud.get("emailperso", "") if not recipient_addr: if nohtml: @@ -1027,7 +981,7 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): 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 = "" @@ -1043,81 +997,32 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): bcc = copy_addr.strip() else: bcc = "" - msg = Message(subject, sender=sender, recipients=recipients, bcc=[bcc]) - msg.body = hea # Attach pdf - msg.attach(filename, scu.PDF_MIMETYPE, pdfdata) log("mail bulletin a %s" % recipient_addr) - email.send_message(msg) + email.send_email( + subject, + sender, + recipients, + bcc=[bcc], + text_body=hea, + attachments=[ + {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} + ], + ) -def _formsemestre_bulletinetud_header_html( - etud, - etudid, - sem, - formsemestre_id=None, - format=None, - version=None, -): - H = [ - html_sco_header.sco_header( - page_title="Bulletin de %(nomprenom)s" % etud, - javascripts=[ - "js/bulletin.js", - "libjs/d3.v3.min.js", - "js/radar_bulletin.js", - ], - cssstyles=["css/radar_bulletin.css"], - ), - """ -
    -

    %s

    - """ - % ( - url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ), - etud["nomprenom"], - ), - """ -
    """ - % request.base_url, - f"""Bulletin {sem["titremois"]} -
    """ - % sem, - """""", - """""" % time.strftime("%d/%m/%Y à %Hh%M"), - """""") - # Menu - endpoint = "notes.formsemestre_bulletinetud" - - menuBul = [ +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, + "formsemestre_id": formsemestre.id, # "target_url": url_for( # "notes.formsemestre_bulletinetud", # scodoc_dept=g.scodoc_dept, @@ -1125,54 +1030,52 @@ def _formsemestre_bulletinetud_header_html( # etudid=etudid, # ), }, - "enabled": (current_user.id in sem["responsables"]) - or current_user.has_permission(Permission.ScoImplement), + "enabled": formsemestre.can_be_edited_by(current_user), }, { "title": 'Version papier (pdf, format "%s")' % sco_bulletins_generator.bulletin_get_class_name_displayed( - formsemestre_id + formsemestre.id ), "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdf", }, }, { - "title": "Envoi par mail à %s" % etud["email"], + "title": f"Envoi par mail à {etud_email}", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdfmail", }, # possible slt si on a un mail... - "enabled": etud["email"] and can_send_bulletin_by_mail(formsemestre_id), + "enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id), }, { - "title": "Envoi par mail à %s (adr. personnelle)" % etud["emailperso"], + "title": f"Envoi par mail à {etud_perso} (adr. personnelle)", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "pdfmail", "prefer_mail_perso": 1, }, # possible slt si on a un mail... - "enabled": etud["emailperso"] - and can_send_bulletin_by_mail(formsemestre_id), + "enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id), }, { "title": "Version json", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "json", }, @@ -1181,8 +1084,8 @@ def _formsemestre_bulletinetud_header_html( "title": "Version XML", "endpoint": endpoint, "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, "version": version, "format": "xml", }, @@ -1191,20 +1094,20 @@ def _formsemestre_bulletinetud_header_html( "title": "Ajouter une appréciation", "endpoint": "notes.appreciation_add_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": ( - (current_user.id in sem["responsables"]) - or (current_user.has_permission(Permission.ScoEtudInscrit)) + formsemestre.can_be_edited_by(current_user) + or current_user.has_permission(Permission.ScoEtudInscrit) ), }, { "title": "Enregistrer un semestre effectué ailleurs", "endpoint": "notes.formsemestre_ext_create_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": current_user.has_permission(Permission.ScoImplement), }, @@ -1212,71 +1115,72 @@ def _formsemestre_bulletinetud_header_html( "title": "Enregistrer une validation d'UE antérieure", "endpoint": "notes.formsemestre_validate_previous_ue", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { "title": "Enregistrer note d'une UE externe", "endpoint": "notes.external_ue_create_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { "title": "Entrer décisions jury", "endpoint": "notes.formsemestre_validation_etud_form", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre_id), + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), }, { - "title": "Editer PV jury", + "title": "Éditer PV jury", "endpoint": "notes.formsemestre_pvjury_pdf", "args": { - "formsemestre_id": formsemestre_id, - "etudid": etudid, + "formsemestre_id": formsemestre.id, + "etudid": etud.id, }, "enabled": True, }, ] + return htmlutils.make_menu("Autres opérations", menu_items, alone=True) - H.append("""""") - H.append( - '' - % ( - url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - format="pdf", + +def _formsemestre_bulletinetud_header_html( + etud, + formsemestre: FormSemestre, + format=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.html", + etud=etud, + format=format, + formsemestre=formsemestre, + menu_autres_operations=make_menu_autres_operations( + etud=etud, + formsemestre=formsemestre, + endpoint="notes.formsemestre_bulletinetud", version=version, ), - scu.ICON_PDF, - ) - ) - H.append("""
    établi le %s (notes sur 20) - """ - % formsemestre_id, - """""" % etudid, - """""" % format, - """
    """) - H.append(htmlutils.make_menu("Autres opérations", menuBul, alone=True)) - H.append("""
    %s
    """) - # - H.append( - """
    %s - """ - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]), - ) - ) - H.append( - """
    - """ - ) - - return "".join(H) + scu=scu, + time=time, + version=version, + ), + ] + return "\n".join(H) diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py index ceeb0aac2..c62eff057 100644 --- a/app/scodoc/sco_bulletins_generator.py +++ b/app/scodoc/sco_bulletins_generator.py @@ -49,7 +49,14 @@ import traceback import reportlab -from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak +from reportlab.platypus import ( + SimpleDocTemplate, + DocIf, + Paragraph, + Spacer, + Frame, + PageBreak, +) from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from flask import request @@ -71,6 +78,8 @@ class BulletinGenerator: supported_formats = [] # should list supported formats, eg [ 'html', 'pdf' ] description = "superclass for bulletins" # description for user interface list_in_menu = True # la classe doit-elle est montrée dans le menu de config ? + scale_table_in_page = True # rescale la table sur 1 page + multi_pages = False def __init__( self, @@ -117,7 +126,7 @@ class BulletinGenerator: def get_filename(self): """Build a filename to be proposed to the web client""" sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"]) - return scu.bul_filename(sem, self.infos["etud"], "pdf") + return scu.bul_filename_old(sem, self.infos["etud"], "pdf") def generate(self, format="", stand_alone=True): """Return bulletin in specified format""" @@ -153,29 +162,47 @@ class BulletinGenerator: from app.scodoc import sco_preferences formsemestre_id = self.infos["formsemestre_id"] - + marque_debut_bulletin = sco_pdf.DebutBulletin( + self.infos["etud"]["nomprenom"], + filigranne=self.infos["filigranne"], + footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""", + ) + story = [] # partie haute du bulletin - objects = self.bul_title_pdf() # pylint: disable=no-member - # table des notes - objects += self.bul_table(format="pdf") # pylint: disable=no-member - # infos sous la table - objects += self.bul_part_below(format="pdf") # pylint: disable=no-member - # signatures - objects += self.bul_signatures_pdf() # pylint: disable=no-member + story += self.bul_title_pdf() # pylint: disable=no-member + index_obj_debut = len(story) - # Réduit sur une page - objects = [KeepInFrame(0, 0, objects, mode="shrink")] + # table des notes + story += self.bul_table(format="pdf") # pylint: disable=no-member + # infos sous la table + story += self.bul_part_below(format="pdf") # pylint: disable=no-member + # signatures + story += self.bul_signatures_pdf() # pylint: disable=no-member + if self.scale_table_in_page: + # Réduit sur une page + story = [marque_debut_bulletin, KeepInFrame(0, 0, story, mode="shrink")] + else: + # Insere notre marqueur qui permet de générer les bookmarks et filigrannes: + story.insert(index_obj_debut, marque_debut_bulletin) # + # objects.append(sco_pdf.FinBulletin()) if not stand_alone: - objects.append(PageBreak()) # insert page break at end - return objects + if self.multi_pages: + # Bulletins sur plusieurs page, force début suivant sur page impaire + story.append( + DocIf("doc.page%2 == 1", [PageBreak(), PageBreak()], [PageBreak()]) + ) + else: + story.append(PageBreak()) # insert page break at end + + return story else: # Generation du document PDF sem = sco_formsemestre.get_formsemestre(formsemestre_id) report = io.BytesIO() # in-memory document, no disk file document = sco_pdf.BaseDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, author="%s %s (E. Viennet) [%s]" % (sco_version.SCONAME, sco_version.SCOVERSION, self.description), @@ -188,7 +215,7 @@ class BulletinGenerator: preferences=sco_preferences.SemPreferences(formsemestre_id), ) ) - document.build(objects) + document.build(story) data = report.getvalue() return data @@ -219,7 +246,7 @@ class BulletinGenerator: # --------------------------------------------------------------------------- def make_formsemestre_bulletinetud( infos, - version="long", # short, long, selectedevals + version=None, # short, long, selectedevals format="pdf", # html, pdf stand_alone=True, ): @@ -231,6 +258,7 @@ def make_formsemestre_bulletinetud( """ from app.scodoc import sco_preferences + version = version or "long" if not version in scu.BULLETINS_VERSIONS: raise ValueError("invalid version code !") @@ -238,10 +266,15 @@ def make_formsemestre_bulletinetud( bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id) gen_class = None - if infos.get("type") == "BUT" and format.startswith("pdf"): - gen_class = bulletin_get_class(bul_class_name + "BUT") - if gen_class is None: - gen_class = bulletin_get_class(bul_class_name) + for bul_class_name in ( + sco_preferences.get_preference("bul_class_name", formsemestre_id), + # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut + bulletin_default_class_name(), + ): + if infos.get("type") == "BUT" and format.startswith("pdf"): + gen_class = bulletin_get_class(bul_class_name + "BUT") + if gen_class is None: + gen_class = bulletin_get_class(bul_class_name) if gen_class is None: raise ValueError( @@ -297,7 +330,11 @@ def register_bulletin_class(klass): def bulletin_class_descriptions(): - return [x.description for x in BULLETIN_CLASSES.values()] + return [ + BULLETIN_CLASSES[class_name].description + for class_name in BULLETIN_CLASSES + if BULLETIN_CLASSES[class_name].list_in_menu + ] def bulletin_class_names() -> list[str]: diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index ee57b60e7..ee0ddae27 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -138,7 +138,7 @@ def formsemestre_bulletinetud_published_dict( if not published: return d # stop ! - etat_inscription = etud.etat_inscription(formsemestre.id) + etat_inscription = etud.inscription_etat(formsemestre.id) if etat_inscription != scu.INSCRIT: d.update(dict_decision_jury(etudid, formsemestre_id, with_decisions=True)) return d diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index 1df2ca666..501bd98ce 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -51,12 +51,11 @@ Chaque semestre peut si nécessaire utiliser un type de bulletin différent. """ import io +import pprint +import pydoc import re import time import traceback -from pydoc import html - -from reportlab.platypus.doctemplate import BaseDocTemplate from flask import g, request @@ -74,17 +73,17 @@ import app.scodoc.sco_utils as scu import sco_version -def pdfassemblebulletins( - formsemestre_id, - objects, - bul_title, +def assemble_bulletins_pdf( + formsemestre_id: int, + story: list, + bul_title: str, infos, - pagesbookmarks, + pagesbookmarks=None, filigranne=None, server_name="", ): - "generate PDF document from a list of PLATYPUS objects" - if not objects: + "Generate PDF document from a story (list of PLATYPUS objects)." + if not story: return "" # Paramètres de mise en page margins = ( @@ -93,11 +92,10 @@ def pdfassemblebulletins( sco_preferences.get_preference("right_margin", formsemestre_id), sco_preferences.get_preference("bottom_margin", formsemestre_id), ) - report = io.BytesIO() # in-memory document, no disk file - document = BaseDocTemplate(report) + document = sco_pdf.BulletinDocTemplate(report) document.addPageTemplates( - sco_pdf.ScolarsPageTemplate( + sco_pdf.ScoDocPageTemplate( document, author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), title="Bulletin %s" % bul_title, @@ -109,7 +107,7 @@ def pdfassemblebulletins( preferences=sco_preferences.SemPreferences(formsemestre_id), ) ) - document.build(objects) + document.multiBuild(story) data = report.getvalue() return data @@ -121,7 +119,8 @@ def replacement_function(match): if logo is not None: return r'' % (match.group(2), logo.filepath, match.group(4)) raise ScoValueError( - 'balise "%s": logo "%s" introuvable' % (html.escape(balise), html.escape(name)) + 'balise "%s": logo "%s" introuvable' + % (pydoc.html.escape(balise), pydoc.html.escape(name)) ) @@ -142,7 +141,11 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): cdict ) # note that None values are mapped to empty strings except: - log("process_field: invalid format=%s" % field) + log( + f"""process_field: invalid format. field={field!r} + values={pprint.pformat(cdict)} + """ + ) text = ( "format invalide !" + traceback.format_exc() @@ -174,7 +177,7 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): - "document pdf et filename" + "Document pdf avec tous les bulletins du semestre, et filename" from app.scodoc import sco_bulletins cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version) @@ -183,20 +186,14 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): fragments = [] # Make each bulletin formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - bookmarks = {} - filigrannes = {} - i = 1 for etud in formsemestre.get_inscrits(include_demdef=True, order=True): - frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud( + frag, _ = sco_bulletins.do_formsemestre_bulletinetud( formsemestre, etud.id, format="pdfpart", version=version, ) fragments += frag - filigrannes[i] = filigranne - bookmarks[i] = etud.sex_nom(no_accents=True) - i = i + 1 # infos = {"DeptName": sco_preferences.get_preference("DeptName", formsemestre_id)} if request: @@ -205,20 +202,18 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): server_name = "" try: sco_pdf.PDFLOCK.acquire() - pdfdoc = pdfassemblebulletins( + pdfdoc = assemble_bulletins_pdf( formsemestre_id, fragments, formsemestre.titre_mois(), infos, - bookmarks, - filigranne=filigrannes, server_name=server_name, ) finally: sco_pdf.PDFLOCK.release() # - dt = time.strftime("%Y-%m-%d") - filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), dt) + date_iso = time.strftime("%Y-%m-%d") + filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), date_iso) filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "") # fill cache sco_cache.SemBulletinsPDFCache.set( @@ -255,7 +250,7 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"): server_name = "" try: sco_pdf.PDFLOCK.acquire() - pdfdoc = pdfassemblebulletins( + pdfdoc = assemble_bulletins_pdf( None, fragments, etud["nomprenom"], diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py index fd84e7d94..e42a38847 100644 --- a/app/scodoc/sco_bulletins_standard.py +++ b/app/scodoc/sco_bulletins_standard.py @@ -46,10 +46,12 @@ de la forme %(XXX)s sont remplacées par la valeur de XXX, pour XXX dans: Balises img: actuellement interdites. """ +from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table +from reportlab.lib.units import cm, mm +from reportlab.lib.colors import Color, blue +from app.scodoc.sco_exceptions import ScoBugCatcher import app.scodoc.sco_utils as scu -from app.scodoc.sco_pdf import Color, Paragraph, Spacer, Table -from app.scodoc.sco_pdf import blue, cm, mm from app.scodoc.sco_pdf import SU from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission @@ -72,7 +74,7 @@ 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): + def bul_title_pdf(self) -> list: """Génère la partie "titre" du bulletin de notes. Renvoie une liste d'objets platypus """ @@ -114,11 +116,11 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): - en PDF: une liste d'objets platypus """ H = [] # html - Op = [] # objets platypus + story = [] # objets platypus # ----- ABSENCES if self.preferences["bul_show_abs"]: nbabs = self.infos["nbabs"] - Op.append(Spacer(1, 2 * mm)) + story.append(Spacer(1, 2 * mm)) if nbabs: H.append( """

    @@ -129,7 +131,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): """ % self.infos ) - Op.append( + story.append( Paragraph( SU( "%(nbabs)s absences (1/2 journées), dont %(nbabsjust)s justifiées." @@ -140,7 +142,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): ) else: H.append("""

    Pas d'absences signalées.

    """) - Op.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle)) + story.append(Paragraph(SU("Pas d'absences signalées."), self.CellStyle)) # ---- APPRECIATIONS # le dir. des etud peut ajouter des appreciations, @@ -167,10 +169,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): % self.infos ) H.append("") - # Appreciations sur PDF: + # Appréciations sur PDF: if self.infos.get("appreciations_list", False): - Op.append(Spacer(1, 3 * mm)) - Op.append( + story.append(Spacer(1, 3 * mm)) + story.append( Paragraph( SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])), self.CellStyle, @@ -179,7 +181,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # ----- DECISION JURY if self.preferences["bul_show_decision"]: - Op += sco_bulletins_pdf.process_field( + story += sco_bulletins_pdf.process_field( self.preferences["bul_pdf_caption"], self.infos, self.FieldStyle, @@ -195,7 +197,12 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # ----- if format == "pdf": - return Op + 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) @@ -374,10 +381,10 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): t = { "titre": "Moyenne générale:", "rang": I["rang_nt"], - "note": I["moy_gen"], - "min": I["moy_min"], - "max": I["moy_max"], - "moy": I["moy_moy"], + "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, @@ -410,7 +417,11 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # Chaque UE: for ue in I["ues"]: ue_type = None - coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else "" + try: + coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else "" + except TypeError as exc: + raise ScoBugCatcher(f"ue={ue!r}") from exc + ue_descr = ue["ue_descr_txt"] rowstyle = "" plusminus = minuslink # diff --git a/app/scodoc/sco_config.py b/app/scodoc/sco_config.py index dd69b9712..95b9dcc98 100644 --- a/app/scodoc/sco_config.py +++ b/app/scodoc/sco_config.py @@ -46,7 +46,9 @@ CONFIG.LOGO_HEADER_HEIGHT = 28 # # server_url: URL du serveur ScoDoc # scodoc_name: le nom du logiciel (ScoDoc actuellement, voir sco_version.py) -CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s sur %(server_url)s" +CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = ( + "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s" +) # ------------- Capitalisation des UEs ------------- diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py index 274b41d76..c8dcc9142 100644 --- a/app/scodoc/sco_dept.py +++ b/app/scodoc/sco_dept.py @@ -56,7 +56,7 @@ def index_html(showcodes=0, showsemtable=0): H.append(sco_news.scolar_news_summary_html()) # Avertissement de mise à jour: - H.append(sco_up_to_date.html_up_to_date_box()) + H.append("""
    """) # Liste de toutes les sessions: sems = sco_formsemestre.do_formsemestre_list() diff --git a/app/scodoc/sco_dump_db.py b/app/scodoc/sco_dump_db.py index 4b55b41f6..fd0b15c0d 100644 --- a/app/scodoc/sco_dump_db.py +++ b/app/scodoc/sco_dump_db.py @@ -51,14 +51,12 @@ import fcntl import subprocess import requests -from flask import flash +from flask import g, request from flask_login import current_user import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import log -from app.scodoc import html_sco_header -from app.scodoc import sco_preferences from app.scodoc import sco_users import sco_version from app.scodoc.sco_exceptions import ScoValueError @@ -66,10 +64,9 @@ from app.scodoc.sco_exceptions import ScoValueError SCO_DUMP_LOCK = "/tmp/scodump.lock" -def sco_dump_and_send_db(): +def sco_dump_and_send_db(message: str = "", request_url: str = ""): """Dump base de données et l'envoie anonymisée pour debug""" - H = [html_sco_header.sco_header(page_title="Assistance technique")] - # get currect (dept) DB name: + # get current (dept) DB name: cursor = ndb.SimpleQuery("SELECT current_database()", {}) db_name = cursor.fetchone()[0] ano_db_name = "ANO" + db_name @@ -95,28 +92,8 @@ def sco_dump_and_send_db(): _anonymize_db(ano_db_name) # Send - r = _send_db(ano_db_name) - if ( - r.status_code - == requests.codes.INSUFFICIENT_STORAGE # pylint: disable=no-member - ): - H.append( - """

    - Erreur: espace serveur trop plein. - Merci de contacter {0}

    """.format( - scu.SCO_DEV_MAIL - ) - ) - elif r.status_code == requests.codes.OK: # pylint: disable=no-member - H.append("""

    Opération effectuée.

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

    - Erreur: code {0} {1} - Merci de contacter {2}

    """.format( - r.status_code, r.reason, scu.SCO_DEV_MAIL - ) - ) + r = _send_db(ano_db_name, message, request_url) + code = r.status_code finally: # Drop anonymized database @@ -125,8 +102,8 @@ def sco_dump_and_send_db(): fcntl.flock(x, fcntl.LOCK_UN) log("sco_dump_and_send_db: done.") - flash("Données envoyées au serveur d'assistance") - return "\n".join(H) + html_sco_header.sco_footer() + + return code def _duplicate_db(db_name, ano_db_name): @@ -175,7 +152,7 @@ def _get_scodoc_serial(): return 0 -def _send_db(ano_db_name): +def _send_db(ano_db_name: str, message: str = "", request_url: str = ""): """Dump this (anonymized) database and send it to tech support""" log(f"dumping anonymized database {ano_db_name}") try: @@ -184,7 +161,9 @@ def _send_db(ano_db_name): ) except subprocess.CalledProcessError as e: log(f"sco_dump_and_send_db: exception in anonymisation: {e}") - raise ScoValueError(f"erreur lors de l'anonymisation de la base {ano_db_name}") + raise ScoValueError( + f"erreur lors de l'anonymisation de la base {ano_db_name}" + ) from e log("uploading anonymized dump...") files = {"file": (ano_db_name + ".dump", dump)} @@ -193,7 +172,9 @@ def _send_db(ano_db_name): scu.SCO_DUMP_UP_URL, files=files, data={ - "dept_name": sco_preferences.get_preference("DeptName"), + "dept_name": getattr(g, "scodoc_dept", "-"), + "message": message or "", + "request_url": request_url or request.url, "serial": _get_scodoc_serial(), "sco_user": str(current_user), "sent_by": sco_users.user_info(str(current_user))["nomcomplet"], diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index cec8b7c2a..c53094ef5 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -166,7 +166,7 @@ def html_edit_formation_apc( def html_ue_infos(ue): - """page d'information sur une UE""" + """Page d'information sur une UE""" from app.views import ScoData formsemestres = ( @@ -189,7 +189,6 @@ def html_ue_infos(ue): ) return render_template( "pn/ue_infos.html", - # "pn/tmp.html", titre=f"UE {ue.acronyme} {ue.titre}", ue=ue, formsemestres=formsemestres, diff --git a/app/scodoc/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py index fb40c2b0d..f691e350a 100644 --- a/app/scodoc/sco_edit_matiere.py +++ b/app/scodoc/sco_edit_matiere.py @@ -92,7 +92,7 @@ def do_matiere_create(args): sco_news.add( typ=sco_news.NEWS_FORM, object=ue["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() @@ -200,7 +200,7 @@ def do_matiere_delete(oid): sco_news.add( typ=sco_news.NEWS_FORM, object=ue["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 8f18a5b2c..ffe4d64fc 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -35,7 +35,7 @@ from flask_login import current_user from app import db from app import log -from app.models import APO_CODE_STR_LEN +from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN from app.models import Formation, UniteEns, ModuleImpl, Module from app.models.formations import Matiere import app.scodoc.notesdb as ndb @@ -141,7 +141,7 @@ def do_ue_create(args): sco_news.add( typ=sco_news.NEWS_FORM, object=args["formation_id"], - text="Modification de la formation {formation.acronyme}", + text=f"Modification de la formation {formation.acronyme}", max_frequency=3, ) formation.invalidate_cached_sems() @@ -347,7 +347,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "size": 4, "type": "float", "title": "ECTS", - "explanation": "nombre de crédits ECTS", + "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)", "allow_null": not is_apc, # ects requis en APC }, ), @@ -372,7 +372,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No { "size": 12, "title": "Code UE", - "explanation": "code interne (non vide). Toutes les UE partageant le même code (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). Voir liste ci-dessous.", + "max_length": SHORT_STR_LEN, + "explanation": """code interne (non vide). Toutes les UE partageant le même code + (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). + Voir liste ci-dessous.""", }, ), ( @@ -381,7 +384,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "title": "Code Apogée", "size": 25, "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", - "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, + "max_length": APO_CODE_STR_LEN, }, ), ( @@ -724,13 +727,16 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}  """ msg_refcomp = "changer" - H.append( - f""" -