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 :
+
+
pour une note de 20 : bonus de + 0,5
+
pour une note de 15 : bonus de + 0,25
+
note de 10 : Ni bonus, ni malus (+0)
+
note de 5, malus : - 0,25
+
note de 0,malus : - 0,5
+
+ """
+
+ 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:
+
+
Bonus = (S - 10)/20
+
+ """
+
+ # 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:
+
+
Bonus = (S - 10)/20
+
+ """
+ name = "bonus_iut_stmalo"
+ displayed_name = "IUT de Saint-Malo"
+
+
+class BonusTarbes(BonusSportAdditif):
+ """Calcul bonus optionnels (sport, culture), règle IUT de Tarbes.
+
+
+
Les étudiants opeuvent suivre un ou plusieurs activités optionnelles notées.
+ La meilleure des notes obtenue est prise en compte, si elle est supérieure à 10/20.
+
+
Le trentième des points au dessus de 10 est ajouté à la moyenne des UE.
+
+
Exemple: un étudiant ayant 16/20 bénéficiera d'un bonus de (16-10)/30 = 0,2 points
+ sur chaque UE.
+
+
+ """
+
+ 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 = "" + tag.split()[0] + ">"
- L = [b + (x or "") + c for x in L]
+ start = "<" + tag + ">"
+ end = "" + tag.split()[0] + ">"
+ 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: