diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py
index f57e22543..da8cce5d7 100644
--- a/app/comp/bonus_spo.py
+++ b/app/comp/bonus_spo.py
@@ -228,7 +228,7 @@ class BonusSportAdditif(BonusSport):
axis=1,
)
# Seuil: bonus dans [min, max] (défaut [0,20])
- bonus_max = self.bonus_max or 0.0
+ bonus_max = self.bonus_max or 20.0
np.clip(bonus_moy_arr, self.bonus_min, bonus_max, out=bonus_moy_arr)
self.bonus_additif(bonus_moy_arr)
@@ -418,17 +418,46 @@ class BonusAmiens(BonusSportAdditif):
class BonusBethune(BonusSportMultiplicatif):
- """Calcul bonus modules optionnels (sport), règle IUT de Béthune.
-
- Les points au dessus de la moyenne de 10 apportent un bonus pour le semestre.
- Ce bonus est égal au nombre de points divisé par 200 et multiplié par la
- moyenne générale du semestre de l'étudiant.
+ """
+ Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune.
+
+ Pour le BUT :
+ La note de sport est sur 20, et on calcule une bonification (en %)
+ qui va s'appliquer à la moyenne de chaque UE du semestre en appliquant
+ la formule : bonification (en %) = max(note-10, 0)*(1/500).
+
+ La bonification ne s'applique que si la note est supérieure à 10.
+
+ (Une note de 10 donne donc 0% de bonif,
+ 1 point au dessus de 10 augmente la moyenne des UE de 0.2%)
+
+
+ Pour le DUT/LP :
+ La note de sport est sur 20, et on calcule une bonification (en %)
+ qui va s'appliquer à la moyenne générale du semestre en appliquant
+ la formule : bonification (en %) = max(note-10, 0)*(1/200).
+
+ La bonification ne s'applique que si la note est supérieure à 10.
+
+ (Une note de 10 donne donc 0% de bonif,
+ 1 point au dessus de 10 augmente la moyenne des UE de 0.5%)
+
"""
name = "bonus_iutbethune"
displayed_name = "IUT de Béthune"
- seuil_moy_gen = 10.0
- amplitude = 0.005
+ seuil_moy_gen = 10.0 # points comptés au dessus de 10.
+
+ def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
+ """calcul du bonus"""
+ if self.formsemestre.formation.is_apc():
+ self.amplitude = 0.002
+ else:
+ self.amplitude = 0.005
+
+ return super().compute_bonus(
+ sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan
+ )
class BonusBezier(BonusSportAdditif):
@@ -502,10 +531,11 @@ class BonusCachan1(BonusSportAdditif):
DUT/LP : la meilleure note d'option, si elle est supérieure à 10,
- bonifie les moyennes d'UE (sauf l'UE41 dont le code est UE41_E) à raison
+ bonifie les moyennes d'UE (uniquement UE13_E pour le semestre 1, UE23_E
+ pour le semestre 2, UE33_E pour le semestre 3 et UE43_E pour le semestre
+ 4) à raison
de bonus = (option - 10)/10.
-
BUT : la meilleure note d'option, si elle est supérieure à 10, bonifie
les moyennes d'UE à raison de bonus = (option - 10) * 3%.
@@ -516,6 +546,7 @@ class BonusCachan1(BonusSportAdditif):
seuil_moy_gen = 10.0 # tous les points sont comptés
proportion_point = 0.03
classic_use_bonus_ues = True
+ ues_bonifiables_cachan = {"UE13_E", "UE23_E", "UE33_E", "UE43_E"}
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant le type de formation"""
@@ -540,7 +571,7 @@ class BonusCachan1(BonusSportAdditif):
dtype=float,
)
else: # --- DUT
- # pareil mais proportion différente et exclusion d'une UE
+ # pareil mais proportion différente et application à certaines UEs
proportion_point = 0.1
bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen,
@@ -553,10 +584,10 @@ class BonusCachan1(BonusSportAdditif):
columns=ues_idx,
dtype=float,
)
- # Pas de bonus sur la ou les ue de code "UE41_E"
- ue_exclues = [ue for ue in ues if ue.ue_code == "UE41_E"]
- for ue in ue_exclues:
- self.bonus_ues[ue.id] = 0.0
+ # Applique bonus seulement sur certaines UE de code connu:
+ for ue in ues:
+ if ue.ue_code not in self.ues_bonifiables_cachan:
+ self.bonus_ues[ue.id] = 0.0 # annule
class BonusCalais(BonusSportAdditif):
@@ -982,7 +1013,7 @@ class BonusTarbes(BonusSportAdditif):
"""
name = "bonus_tarbes"
- displayed_name = "IUT de Tazrbes"
+ displayed_name = "IUT de Tarbes"
seuil_moy_gen = 10.0
proportion_point = 1 / 30.0
classic_use_bonus_ues = True
@@ -1041,6 +1072,29 @@ class BonusTours(BonusDirect):
)
+class BonusIUTvannes(BonusSportAdditif):
+ """Calcul bonus modules optionels (sport, culture), règle IUT Vannes
+
+
Ne concerne actuellement que les DUT et LP
+
Les étudiants de l'IUT peuvent suivre des enseignements optionnels
+ de l'U.B.S. (sports, musique, deuxième langue, culture, etc) non
+ rattachés à une unité d'enseignement.
+
+ Les points au-dessus de 10 sur 20 obtenus dans chacune des matières
+ optionnelles sont cumulés.
+
+ 3% de ces points cumulés s'ajoutent à la moyenne générale du semestre
+ déjà obtenue par l'étudiant.
+
+ """
+
+ name = "bonus_iutvannes"
+ displayed_name = "IUT de Vannes"
+ seuil_moy_gen = 10.0
+ proportion_point = 0.03 # 3%
+ classic_use_bonus_ues = False # seulement sur moy gen.
+
+
class BonusVilleAvray(BonusSport):
"""Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray.
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index eea357e8f..13829a392 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -92,6 +92,8 @@ class ModuleImplResults:
ou NaN si les évaluations (dans lesquelles l'étudiant a des notes)
ne donnent pas de coef vers cette UE.
"""
+ self.evals_etudids_sans_note = {}
+ """dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.load_notes()
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
@@ -142,12 +144,13 @@ class ModuleImplResults:
# ou évaluation déclarée "à prise en compte immédiate"
# Les évaluations de rattrapage et 2eme session sont toujours incomplètes
# car on calcule leur moyenne à part.
+ etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (evaluation.evaluation_type == scu.EVALUATION_NORMALE) and (
- evaluation.publish_incomplete
- or (not (inscrits_module - set(eval_df.index)))
+ evaluation.publish_incomplete or (not etudids_sans_note)
)
self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete
+ self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
# NULL en base => ABS (= -999)
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
@@ -193,7 +196,9 @@ class ModuleImplResults:
return eval_df
def _etudids(self):
- """L'index du dataframe est la liste de tous les étudiants inscrits au semestre"""
+ """L'index du dataframe est la liste de tous les étudiants inscrits au semestre
+ (incluant les DEM et DEF)
+ """
return [
inscr.etudid
for inscr in ModuleImpl.query.get(
diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py
index 61b5fd15c..6fa3cbaca 100644
--- a/app/comp/moy_sem.py
+++ b/app/comp/moy_sem.py
@@ -100,8 +100,9 @@ def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
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
+ rangs_str = pd.Series("", index=notes.index, dtype=str) # le rang est une chaîne
+ # le rang numérique pour tris:
+ rangs_int = pd.Series(0, index=notes.index, dtype=int)
N = len(notes)
nb_ex = 0 # nb d'ex-aequo consécutifs en cours
notes_i = notes.iat
@@ -128,4 +129,5 @@ def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series):
rangs_int[etudid] = i + 1
srang = "%d" % (i + 1)
rangs_str[etudid] = srang
+ assert rangs_int.dtype == int
return rangs_str, rangs_int
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 1f19fbfd9..7bc199ead 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -14,18 +14,19 @@ import pandas as pd
from flask import g, url_for
+from app.auth.models import User
from app.comp.res_cache import ResultatsCache
from app.comp import res_sem
from app.comp.moy_mod import ModuleImplResults
-from app.models import FormSemestre, FormSemestreUECoef
+from app.models import FormSemestre, FormSemestreUECoef, formsemestre
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
+from app.scodoc import sco_evaluation_db
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
-from app.scodoc import sco_users
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
@@ -387,7 +388,9 @@ class ResultatsSemestre(ResultatsCache):
# --- TABLEAU RECAP
- def get_table_recap(self, convert_values=False):
+ def get_table_recap(
+ self, convert_values=False, include_evaluations=False, modejury=False
+ ):
"""Result: tuple avec
- rows: liste de dicts { column_id : value }
- titles: { column_id : title }
@@ -413,7 +416,6 @@ class ResultatsSemestre(ResultatsCache):
- les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_
__order : clé de tri
"""
-
if convert_values:
fmt_note = scu.fmt_note
else:
@@ -429,6 +431,7 @@ class ResultatsSemestre(ResultatsCache):
titles = {}
# les titres en footer: les mêmes, mais avec des bulles et liens:
titles_bot = {}
+ dict_nom_res = {} # cache uid : nomcomplet
def add_cell(
row: dict,
@@ -457,6 +460,11 @@ class ResultatsSemestre(ResultatsCache):
idx = 0 # index de la colonne
etud = Identite.query.get(etudid)
row = {"etudid": etudid}
+ # --- Codes (seront cachés, mais exportés en excel)
+ idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
+ idx = add_cell(
+ row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
+ )
# --- Rang
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
@@ -532,22 +540,28 @@ class ResultatsSemestre(ResultatsCache):
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
+ if modejury:
+ # pas d'autre colonnes de résultats
+ continue
# Bonus (sport) dans cette UE ?
# Le bonus sport appliqué sur cette UE
if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
val = self.bonus_ues[ue.id][etud.id] or ""
- val_fmt = fmt_note(val)
+ val_fmt = val_fmt_html = fmt_note(val)
if val:
- val_fmt = f'{val_fmt}'
+ val_fmt_html = f'{val_fmt}'
idx = add_cell(
row,
f"bonus_ue_{ue.id}",
f"Bonus {ue.acronyme}",
- val_fmt,
+ val_fmt_html,
"col_ue_bonus",
idx,
)
+ row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
+ idx_malus = idx # place pour colonne malus à gauche des modules
+ idx += 1
for modimpl in self.modimpls_in_ue(ue.id, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
@@ -573,63 +587,87 @@ class ResultatsSemestre(ResultatsCache):
col_id = (
f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
)
- val_fmt = fmt_note(val)
+ val_fmt = val_fmt_html = fmt_note(val)
if modimpl.module.module_type == scu.ModuleType.MALUS:
- val_fmt = (
+ val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
)
idx = add_cell(
row,
col_id,
modimpl.module.code,
- val_fmt,
+ val_fmt_html,
# class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx,
)
+ row[f"_{col_id}_xls"] = val_fmt
+ if modimpl.module.module_type == scu.ModuleType.MALUS:
+ titles[f"_{col_id}_col_order"] = idx_malus
titles_bot[f"_{col_id}_target"] = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
+ nom_resp = dict_nom_res.get(modimpl.responsable_id)
+ if nom_resp is None:
+ user = User.query.get(modimpl.responsable_id)
+ nom_resp = user.get_nomcomplet() if user else ""
+ dict_nom_res[modimpl.responsable_id] = nom_resp
titles_bot[
f"_{col_id}_target_attrs"
- ] = f"""
- title="{modimpl.module.titre}
- ({sco_users.user_info(modimpl.responsable_id)['nomcomplet']})" """
+ ] = f""" title="{modimpl.module.titre} ({nom_resp})" """
modimpl_ids.add(modimpl.id)
- ue_valid_txt = f"{nb_ues_validables}/{len(ues_sans_bonus)}"
+ ue_valid_txt = (
+ ue_valid_txt_html
+ ) = f"{nb_ues_validables}/{len(ues_sans_bonus)}"
if nb_ues_warning:
- ue_valid_txt += " " + scu.EMO_WARNING
+ ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
row,
"ues_validables",
"UEs",
- ue_valid_txt,
+ ue_valid_txt_html,
"col_ue col_ues_validables",
29, # juste avant moy. gen.
)
+ row["_ues_validables_xls"] = ue_valid_txt
if nb_ues_warning:
row["_ues_validables_class"] += " moy_ue_warning"
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
+ if modejury:
+ idx = add_cell(
+ row,
+ "jury_link",
+ "",
+ f"""saisir décision""",
+ "col_jury_link",
+ 1000,
+ )
rows.append(row)
self._recap_add_partitions(rows, titles)
self._recap_add_admissions(rows, titles)
+
# tri par rang croissant
rows.sort(key=lambda e: e["_rang_order"])
# INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
+ if include_evaluations:
+ self._recap_add_evaluations(rows, titles, bottom_infos)
# Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles:
c_class = f"_{col_id}_class"
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
for row in rows:
- row[c_class] += " col_empty"
+ row[c_class] = row.get(c_class, "") + " col_empty"
titles[c_class] += " col_empty"
for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty"
@@ -641,7 +679,9 @@ class ResultatsSemestre(ResultatsCache):
row["moy_gen"] = row.get("moy_gen", "")
row["_moy_gen_class"] = "col_moy_gen"
# titre de la ligne:
- row["prenom"] = row["nom_short"] = bottom_line.capitalize()
+ row["prenom"] = row["nom_short"] = (
+ row.get("_title", "") or bottom_line.capitalize()
+ )
row["_tr_class"] = bottom_line.lower() + (
(" " + row["_tr_class"]) if "_tr_class" in row else ""
)
@@ -656,53 +696,58 @@ class ResultatsSemestre(ResultatsCache):
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS"""
- row_min, row_max, row_moy, row_coef, row_ects = (
- {"_tr_class": "bottom_info"},
+ row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
+ {"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
+ {"_tr_class": "bottom_info", "_title": "Code Apogée"},
)
# --- ECTS
for ue in ues:
- row_ects[f"moy_ue_{ue.id}"] = ue.ects
- row_ects[f"_moy_ue_{ue.id}_class"] = "col_ue"
+ colid = f"moy_ue_{ue.id}"
+ row_ects[colid] = ue.ects
+ row_ects[f"_{colid}_class"] = "col_ue"
# style cases vides pour borders verticales
- row_coef[f"moy_ue_{ue.id}"] = ""
- row_coef[f"_moy_ue_{ue.id}_class"] = "col_ue"
+ row_coef[colid] = ""
+ row_coef[f"_{colid}_class"] = "col_ue"
+ # row_apo[colid] = ue.code_apogee or ""
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
row_ects["_moy_gen_class"] = "col_moy_gen"
- # --- MIN, MAX, MOY
+ # --- MIN, MAX, MOY, APO
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
for ue in ues:
- col_id = f"moy_ue_{ue.id}"
- row_min[col_id] = fmt_note(self.etud_moy_ue[ue.id].min())
- row_max[col_id] = fmt_note(self.etud_moy_ue[ue.id].max())
- row_moy[col_id] = fmt_note(self.etud_moy_ue[ue.id].mean())
- row_min[f"_{col_id}_class"] = "col_ue"
- row_max[f"_{col_id}_class"] = "col_ue"
- row_moy[f"_{col_id}_class"] = "col_ue"
+ colid = f"moy_ue_{ue.id}"
+ row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
+ row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
+ row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
+ row_min[f"_{colid}_class"] = "col_ue"
+ row_max[f"_{colid}_class"] = "col_ue"
+ row_moy[f"_{colid}_class"] = "col_ue"
+ row_apo[colid] = ue.code_apogee or ""
for modimpl in self.formsemestre.modimpls_sorted:
if modimpl.id in modimpl_ids:
- col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
+ colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
if self.is_apc:
coef = self.modimpl_coefs_df[modimpl.id][ue.id]
else:
coef = modimpl.module.coefficient or 0
- row_coef[col_id] = fmt_note(coef)
+ row_coef[colid] = fmt_note(coef)
notes = self.modimpl_notes(modimpl.id, ue.id)
- row_min[col_id] = fmt_note(np.nanmin(notes))
- row_max[col_id] = fmt_note(np.nanmax(notes))
+ row_min[colid] = fmt_note(np.nanmin(notes))
+ row_max[colid] = fmt_note(np.nanmax(notes))
moy = np.nanmean(notes)
- row_moy[col_id] = fmt_note(moy)
+ row_moy[colid] = fmt_note(moy)
if np.isnan(moy):
# aucune note dans ce module
- row_moy[f"_{col_id}_class"] = "col_empty"
+ row_moy[f"_{colid}_class"] = "col_empty"
+ row_apo[colid] = modimpl.module.code_apogee or ""
return { # { key : row } avec key = min, max, moy, coef
"min": row_min,
@@ -710,6 +755,7 @@ class ResultatsSemestre(ResultatsCache):
"moy": row_moy,
"coef": row_coef,
"ects": row_ects,
+ "apo": row_apo,
}
def _recap_etud_groups_infos(self, etudid: int, row: dict, titles: dict):
@@ -803,3 +849,68 @@ class ResultatsSemestre(ResultatsCache):
row[f"{cid}"] = gr_name
row[f"_{cid}_class"] = klass
first_partition = False
+
+ def _recap_add_evaluations(
+ self, rows: list[dict], titles: dict, bottom_infos: dict
+ ):
+ """Ajoute les colonnes avec les notes aux évaluations
+ rows est une liste de dict avec une clé "etudid"
+ Les colonnes ont la classe css "evaluation"
+ """
+ # nouvelle ligne pour description évaluations:
+ bottom_infos["descr_evaluation"] = {
+ "_tr_class": "bottom_info",
+ "_title": "Description évaluation",
+ }
+ first_eval = True
+ index_col = 9000 # à droite
+ for modimpl in self.formsemestre.modimpls_sorted:
+ evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
+ eval_index = len(evals) - 1
+ inscrits = {i.etudid for i in modimpl.inscriptions}
+ first_eval_of_mod = True
+ for e in evals:
+ cid = f"eval_{e.id}"
+ titles[
+ cid
+ ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
+ klass = "evaluation"
+ if first_eval:
+ klass += " first"
+ elif first_eval_of_mod:
+ klass += " first_of_mod"
+ titles[f"_{cid}_class"] = klass
+ first_eval_of_mod = first_eval = False
+ titles[f"_{cid}_col_order"] = index_col
+ index_col += 1
+ eval_index -= 1
+ notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
+ e.evaluation_id
+ )
+ for row in rows:
+ etudid = row["etudid"]
+ if etudid in inscrits:
+ if etudid in notes_db:
+ val = notes_db[etudid]["value"]
+ else:
+ # Note manquante mais prise en compte immédiate: affiche ATT
+ val = scu.NOTES_ATTENTE
+ row[cid] = scu.fmt_note(val)
+ row[f"_{cid}_class"] = klass + {
+ "ABS": " abs",
+ "ATT": " att",
+ "EXC": " exc",
+ }.get(row[cid], "")
+ else:
+ row[cid] = "ni"
+ row[f"_{cid}_class"] = klass + " non_inscrit"
+
+ bottom_infos["coef"][cid] = e.coefficient
+ bottom_infos["min"][cid] = "0"
+ bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
+ bottom_infos["descr_evaluation"][cid] = e.description or ""
+ bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
+ "notes.evaluation_listenotes",
+ scodoc_dept=g.scodoc_dept,
+ evaluation_id=e.id,
+ )
diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py
index facbf85fe..9a5e11989 100644
--- a/app/forms/main/config_apo.py
+++ b/app/forms/main/config_apo.py
@@ -70,5 +70,16 @@ class CodesDecisionsForm(FlaskForm):
DEM = _build_code_field("DEM")
NAR = _build_code_field("NAR")
RAT = _build_code_field("RAT")
+ NOTES_FMT = StringField(
+ label="Format notes exportées",
+ description="""Format des notes. Par défaut %3.2f (deux chiffres après la virgule)""",
+ validators=[
+ validators.Length(
+ max=SHORT_STR_LEN,
+ message=f"Le format ne doit pas dépasser {SHORT_STR_LEN} caractères",
+ ),
+ validators.DataRequired("format requis"),
+ ],
+ )
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py
index db69ae35b..daea5de1b 100644
--- a/app/forms/main/config_logos.py
+++ b/app/forms/main/config_logos.py
@@ -151,7 +151,7 @@ class AddLogoForm(FlaskForm):
dept_id = dept_key_to_id(self.dept_key.data)
if dept_id == GLOBAL:
dept_id = None
- if find_logo(logoname=name.data, dept_id=dept_id) is not None:
+ if find_logo(logoname=name.data, dept_id=dept_id, strict=True) is not None:
raise validators.ValidationError("Un logo de même nom existe déjà")
def select_action(self):
@@ -160,6 +160,14 @@ class AddLogoForm(FlaskForm):
return LogoInsert.build_action(self.data)
return None
+ def errors(self):
+ if self.do_insert.data:
+ if self.name.errors:
+ return True
+ if self.upload.errors:
+ return True
+ return False
+
class LogoForm(FlaskForm):
"""Embed both presentation of a logo (cf. template file configuration.html)
@@ -211,6 +219,11 @@ class LogoForm(FlaskForm):
return LogoUpdate.build_action(self.data)
return None
+ def errors(self):
+ if self.upload.data and self.upload.errors:
+ return True
+ return False
+
class DeptForm(FlaskForm):
dept_key = HiddenField()
@@ -244,6 +257,14 @@ class DeptForm(FlaskForm):
return self
return self.index.get(logoname, None)
+ def errors(self):
+ if self.add_logo.errors():
+ return True
+ for logo_form in self.logos:
+ if logo_form.errors():
+ return True
+ return False
+
def _make_dept_id_name():
"""Cette section assure que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ)
diff --git a/app/models/config.py b/app/models/config.py
index 1271beeb9..53ac96e9b 100644
--- a/app/models/config.py
+++ b/app/models/config.py
@@ -36,6 +36,7 @@ CODES_SCODOC_TO_APO = {
DEM: "NAR",
NAR: "NAR",
RAT: "ATT",
+ "NOTES_FMT": "%3.2f",
}
@@ -157,32 +158,6 @@ class ScoDocSiteConfig(db.Model):
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
return [("", "")] + class_list
- @classmethod
- def get_bonus_sport_func(cls):
- """Fonction bonus_sport ScoDoc 7 XXX
- Transitoire pour les tests durant la transition #sco92
- """
- """returns bonus func with specified name.
- If name not specified, return the configured function.
- None if no bonus function configured.
- Raises ScoValueError if func_name not found in module bonus_sport.
- """
- from app.scodoc import bonus_sport
-
- c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
- if c is None:
- return None
- func_name = c.value
- if func_name == "": # pas de bonus défini
- return None
- try:
- return getattr(bonus_sport, func_name)
- except AttributeError:
- raise ScoValueError(
- f"""Fonction de calcul de l'UE bonus inexistante: "{func_name}".
- (contacter votre administrateur local)."""
- )
-
@classmethod
def get_code_apo(cls, code: str) -> str:
"""La représentation d'un code pour les exports Apogée.
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 6c342482c..3aacb66a9 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -16,6 +16,7 @@ from app import models
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
+from app.scodoc.sco_exceptions import ScoInvalidParamError
import app.scodoc.sco_utils as scu
@@ -354,7 +355,10 @@ def make_etud_args(
"""
args = None
if etudid:
- args = {"etudid": etudid}
+ try:
+ args = {"etudid": int(etudid)}
+ except ValueError as exc:
+ raise ScoInvalidParamError() from exc
elif code_nip:
args = {"code_nip": code_nip}
elif use_request: # use form from current request (Flask global)
diff --git a/app/models/events.py b/app/models/events.py
index 55b34d38d..9725f3c8a 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -2,9 +2,21 @@
"""Evenements et logs divers
"""
+import datetime
+import re
+
+from flask import g, url_for
+from flask_login import current_user
from app import db
+from app import email
+from app import log
+from app.auth.models import User
from app.models import SHORT_STR_LEN
+from app.models.formsemestre import FormSemestre
+from app.models.moduleimpls import ModuleImpl
+import app.scodoc.sco_utils as scu
+from app.scodoc import sco_preferences
class Scolog(db.Model):
@@ -24,13 +36,219 @@ class Scolog(db.Model):
class ScolarNews(db.Model):
"""Nouvelles pour page d'accueil"""
+ NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id)
+ NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id)
+ NEWS_FORM = "FORM" # modification formation (object=formation_id)
+ NEWS_SEM = "SEM" # creation semestre (object=None)
+ NEWS_ABS = "ABS" # saisie absence
+ NEWS_MISC = "MISC" # unused
+ NEWS_MAP = {
+ NEWS_INSCR: "inscription d'étudiants",
+ NEWS_NOTE: "saisie note",
+ NEWS_FORM: "modification formation",
+ NEWS_SEM: "création semestre",
+ NEWS_MISC: "opération", # unused
+ }
+ NEWS_TYPES = list(NEWS_MAP.keys())
+
__tablename__ = "scolar_news"
id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
- date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
- authenticated_user = db.Column(db.Text) # login, sans contrainte
+ date = db.Column(
+ db.DateTime(timezone=True), server_default=db.func.now(), index=True
+ )
+ authenticated_user = db.Column(db.Text, index=True) # login, sans contrainte
# type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
- type = db.Column(db.String(SHORT_STR_LEN))
- object = db.Column(db.Integer) # moduleimpl_id, formation_id, formsemestre_id
+ type = db.Column(db.String(SHORT_STR_LEN), index=True)
+ object = db.Column(
+ db.Integer, index=True
+ ) # moduleimpl_id, formation_id, formsemestre_id
text = db.Column(db.Text)
url = db.Column(db.Text)
+
+ def __repr__(self):
+ return (
+ f"<{self.__class__.__name__}(id={self.id}, date='{self.date.isoformat()}')>"
+ )
+
+ def __str__(self):
+ "'Chargement notes dans Stage (S3 FI) par Aurélie Dupont'"
+ formsemestre = self.get_news_formsemestre()
+ user = User.query.filter_by(user_name=self.authenticated_user).first()
+
+ sem_text = (
+ f"""({formsemestre.sem_modalite()})"""
+ if formsemestre
+ else ""
+ )
+ author = f"par {user.get_nomcomplet()}" if user else ""
+ return f"{self.text} {sem_text} {author}"
+
+ def formatted_date(self) -> str:
+ "06 Avr 14h23"
+ mois = scu.MONTH_NAMES_ABBREV[self.date.month - 1]
+ return f"{self.date.day} {mois} {self.date.hour:02d}h{self.date.minute:02d}"
+
+ def to_dict(self):
+ return {
+ "date": {
+ "display": self.date.strftime("%d/%m/%Y %H:%M"),
+ "timestamp": self.date.timestamp(),
+ },
+ "type": self.NEWS_MAP.get(self.type, "?"),
+ "authenticated_user": self.authenticated_user,
+ "text": self.text,
+ }
+
+ @classmethod
+ def last_news(cls, n=1, dept_id=None, filter_dept=True) -> list:
+ "The most recent n news. Returns list of ScolarNews instances."
+ query = cls.query
+ if filter_dept:
+ if dept_id is None:
+ dept_id = g.scodoc_dept_id
+ query = query.filter_by(dept_id=dept_id)
+
+ return query.order_by(cls.date.desc()).limit(n).all()
+
+ @classmethod
+ def add(cls, typ, obj=None, text="", url=None, max_frequency=0):
+ """Enregistre une nouvelle
+ Si max_frequency, ne génère pas 2 nouvelles "identiques"
+ à moins de max_frequency secondes d'intervalle.
+ Deux nouvelles sont considérées comme "identiques" si elles ont
+ même (obj, typ, user).
+ La nouvelle enregistrée est aussi envoyée par mail.
+ """
+ if max_frequency:
+ last_news = (
+ cls.query.filter_by(
+ dept_id=g.scodoc_dept_id,
+ authenticated_user=current_user.user_name,
+ type=typ,
+ object=obj,
+ )
+ .order_by(cls.date.desc())
+ .limit(1)
+ .first()
+ )
+ if last_news:
+ now = datetime.datetime.now(tz=last_news.date.tzinfo)
+ if (now - last_news.date) < datetime.timedelta(seconds=max_frequency):
+ # on n'enregistre pas
+ return
+
+ news = ScolarNews(
+ dept_id=g.scodoc_dept_id,
+ authenticated_user=current_user.user_name,
+ type=typ,
+ object=obj,
+ text=text,
+ url=url,
+ )
+ db.session.add(news)
+ db.session.commit()
+ log(f"news: {news}")
+ news.notify_by_mail()
+
+ def get_news_formsemestre(self) -> FormSemestre:
+ """formsemestre concerné par la nouvelle
+ None si inexistant
+ """
+ formsemestre_id = None
+ if self.type == self.NEWS_INSCR:
+ formsemestre_id = self.object
+ elif self.type == self.NEWS_NOTE:
+ moduleimpl_id = self.object
+ if moduleimpl_id:
+ modimpl = ModuleImpl.query.get(moduleimpl_id)
+ if modimpl is None:
+ return None # module does not exists anymore
+ formsemestre_id = modimpl.formsemestre_id
+
+ if not formsemestre_id:
+ return None
+ formsemestre = FormSemestre.query.get(formsemestre_id)
+ return formsemestre
+
+ def notify_by_mail(self):
+ """Notify by email"""
+ formsemestre = self.get_news_formsemestre()
+
+ prefs = sco_preferences.SemPreferences(
+ formsemestre_id=formsemestre.id if formsemestre else None
+ )
+ destinations = prefs["emails_notifications"] or ""
+ destinations = [x.strip() for x in destinations.split(",")]
+ destinations = [x for x in destinations if x]
+ if not destinations:
+ return
+ #
+ txt = self.text
+ if formsemestre:
+ txt += f"""\n\nSemestre {formsemestre.titre_mois()}\n\n"""
+ txt += f"""{formsemestre.sem_modalite()}
+ """
+ user = User.query.filter_by(user_name=self.authenticated_user).first()
+ if user:
+ txt += f"\n\nEffectué par: {user.get_nomcomplet()}\n"
+
+ txt = (
+ "\n"
+ + txt
+ + """\n
+ --- Ceci est un message de notification automatique issu de ScoDoc
+ --- vous recevez ce message car votre adresse est indiquée dans les paramètres de ScoDoc.
+ """
+ )
+
+ # Transforme les URL en URL absolues
+ base = scu.ScoURL()
+ txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
+
+ # Transforme les liens HTML en texte brut: 'texte' devient 'texte: url'
+ # (si on veut des messages non html)
+ txt = re.sub(r'(.*?)', r"\2: \1", txt)
+
+ subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
+ sender = prefs["email_from_addr"]
+
+ email.send_email(subject, sender, destinations, txt)
+
+ @classmethod
+ def scolar_news_summary_html(cls, n=5) -> str:
+ """News summary, formated in HTML"""
+ news_list = cls.last_news(n=n)
+ if not news_list:
+ return ""
+ H = [
+ f"""
importer de nouveaux étudiants (ne pas utiliser sauf cas particulier, utilisez plutôt le lien dans
- le tableau de bord semestre si vous souhaitez inscrire les
- étudiants importés à un semestre)
importer de nouveaux étudiants
+ (ne pas utiliser sauf cas particulier, utilisez plutôt le lien dans
+ le tableau de bord semestre si vous souhaitez inscrire les
+ étudiants importés à un semestre)
+
"""
)
#
if current_user.has_permission(Permission.ScoEditApo):
H.append(
- """
+ f"""