diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 0a95621e..99738336 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -228,6 +228,10 @@ class BonusSportAdditif(BonusSport): else: # necessaire pour éviter bonus négatifs ! bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr) + self.bonus_additif(bonus_moy_arr) + + def bonus_additif(self, bonus_moy_arr: np.array): + "Set bonus_ues et bonus_moy_gen" # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) if self.formsemestre.formation.is_apc(): # Bonus sur les UE et None sur moyenne générale @@ -306,6 +310,47 @@ class BonusDirect(BonusSportAdditif): proportion_point = 1.0 +class BonusAisneStQuentin(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Aisne St Quentin + +
Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université de St Quentin non rattachés à une unité d'enseignement. +
++ Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant (en BUT, s'ajoute à la moyenne de chaque UE). +
+ """ + + name = "bonus_iutstq" + displayed_name = "IUT de Saint-Quentin" + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return + # Calcule moyenne pondérée des notes de sport: + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 + bonus_moy_arr[bonus_moy_arr >= 18.1] = 0.5 + bonus_moy_arr[bonus_moy_arr >= 16.1] = 0.4 + bonus_moy_arr[bonus_moy_arr >= 14.1] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.1] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10] = 0.1 + + self.bonus_additif(bonus_moy_arr) + + class BonusAmiens(BonusSportAdditif): """Bonus IUT Amiens pour les modules optionnels (sport, culture, ...). @@ -705,6 +750,7 @@ class BonusRoanne(BonusSportAdditif): seuil_moy_gen = 0.0 bonus_max = 0.6 # plafonnement à 0.6 points classic_use_bonus_ues = True # sur les UE, même en DUT et LP + proportion_point = 1 class BonusStDenis(BonusSportAdditif): @@ -773,21 +819,19 @@ class BonusVilleAvray(BonusSport): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return # Calcule moyenne pondérée des notes de sport: bonus_moy_arr = np.sum( sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) - bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 bonus_moy_arr[bonus_moy_arr >= 16.0] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - # Bonus moyenne générale, et 0 sur les UE - self.bonus_moy_gen = pd.Series(bonus_moy_arr, index=self.etuds_idx, dtype=float) - if self.bonus_max is not None: - # Seuil: bonus (sur moy. gen.) limité à bonus_max points - self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_max) - - # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + self.bonus_additif(bonus_moy_arr) class BonusIUTV(BonusSportAdditif): diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 5caa3d39..db42616c 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,8 +30,10 @@ import numpy as np import pandas as pd +from flask import flash -def compute_sem_moys_apc( + +def compute_sem_moys_apc_using_coefs( etud_moy_ue_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame ) -> pd.Series: """Calcule les moyennes générales indicatives de tous les étudiants @@ -48,6 +50,28 @@ def compute_sem_moys_apc( return moy_gen +def compute_sem_moys_apc_using_ects( + etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None +) -> pd.Series: + """Calcule les moyennes générales indicatives de tous les étudiants + = moyenne des moyennes d'UE, pondérée par leurs ECTS. + + etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid + ects: liste de floats ou None, 1 par UE + + Result: panda Series, index etudid, valeur float (moyenne générale) + """ + try: + 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 !""") + moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index) + else: + raise + return moy_gen + + 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. diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 1d01f4f4..20e63cba 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -14,7 +14,7 @@ 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 +from app.models import ScoDocSiteConfig, formsemestre from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT @@ -73,7 +73,7 @@ class ResultatsSemestreBUT(NotesTableCompat): ) # Les coefficients d'UE ne sont pas utilisés en APC self.etud_coef_ue_df = pd.DataFrame( - 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns + 0.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) # --- Modules de MALUS sur les UEs @@ -103,8 +103,13 @@ class ResultatsSemestreBUT(NotesTableCompat): # Moyenne générale indicative: # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # donc la moyenne indicative) - self.etud_moy_gen = moy_sem.compute_sem_moys_apc( - self.etud_moy_ue, self.modimpl_coefs_df + # self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs( + # self.etud_moy_ue, self.modimpl_coefs_df + # ) + self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects( + self.etud_moy_ue, + [ue.ects for ue in self.ues if ue.type != UE_SPORT], + formation_id=self.formsemestre.formation_id, ) # --- UE capitalisées self.apply_capitalisation() diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 5f652ec5..8fa106f5 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -9,18 +9,22 @@ from functools import cached_property import numpy as np import pandas as pd +from flask import g, flash, url_for + from app import log from app.comp.aux_stats import StatsMoyenne from app.comp import moy_sem 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, Identite, ModuleImpl -from app.models import FormSemestreUECoef +from app.models import FormSemestre, FormSemestreUECoef +from app.models import Identite +from app.models import ModuleImpl, ModuleImplInscription from app.models.ues import UniteEns from app.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT, DEF +from app.scodoc.sco_exceptions import ScoValueError # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -191,7 +195,7 @@ class ResultatsSemestre(ResultatsCache): if ue_cap["is_capitalized"]: recompute_mg = True coef = ue_cap["coef_ue"] - if not np.isnan(ue_cap["moy"]): + if not np.isnan(ue_cap["moy"]) and coef: sum_notes_ue += ue_cap["moy"] * coef sum_coefs_ue += coef @@ -206,12 +210,18 @@ class ResultatsSemestre(ResultatsCache): 0.0, min(self.etud_moy_gen[etudid], 20.0) ) - def _get_etud_ue_cap(self, etudid, ue): - """""" + def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: + """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. + Résultat: + Si pas capitalisée: None + Si capitalisée: un dict, avec les colonnes de validation. + """ capitalisations = self.validations.ue_capitalisees.loc[etudid] if isinstance(capitalisations, pd.DataFrame): ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] - if isinstance(ue_cap, pd.DataFrame) and not ue_cap.empty: + if ue_cap.empty: + return None + if isinstance(ue_cap, pd.DataFrame): # si plusieurs fois capitalisée, prend le max cap_idx = ue_cap["moy_ue"].values.argmax() ue_cap = ue_cap.iloc[cap_idx] @@ -219,8 +229,9 @@ class ResultatsSemestre(ResultatsCache): if capitalisations["ue_code"] == ue.ue_code: ue_cap = capitalisations else: - ue_cap = None - return ue_cap + return None + # converti la Series en dict, afin que les np.int64 reviennent en int + return ue_cap.to_dict() def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. @@ -248,22 +259,45 @@ class ResultatsSemestre(ResultatsCache): cur_moy_ue = self.etud_moy_ue[ue_id][etudid] moy_ue = cur_moy_ue is_capitalized = False # si l'UE prise en compte est une UE capitalisée - was_capitalized = ( - False # s'il y a precedemment une UE capitalisée (pas forcement meilleure) - ) + # s'il y a precedemment une UE capitalisée (pas forcement meilleure): + was_capitalized = False if etudid in self.validations.ue_capitalisees.index: ue_cap = self._get_etud_ue_cap(etudid, ue) - if ( - ue_cap is not None - and not ue_cap.empty - and not np.isnan(ue_cap["moy_ue"]) - ): + if ue_cap and not np.isnan(ue_cap["moy_ue"]): was_capitalized = True if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): moy_ue = ue_cap["moy_ue"] is_capitalized = True - coef_ue = self.etud_coef_ue_df[ue_id][etudid] + # Coef l'UE dans le semestre courant: + if self.is_apc: + # utilise les ECTS comme coef. + coef_ue = ue.ects + else: + # formations classiques + coef_ue = self.etud_coef_ue_df[ue_id][etudid] + if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante + if self.is_apc: + # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS + ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) + coef_ue = ue_capitalized.ects + if coef_ue is None: + orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) + raise ScoValueError( + f"""L'UE capitalisée {ue_capitalized.acronyme} + du semestre {orig_sem.titre_annee()} + n'a pas d'indication d'ECTS. + Corrigez ou faite corriger le programme + via cette page. + """ + ) + else: + # Coefs de l'UE capitalisée en formation classique: + # va chercher le coef dans le semestre d'origine + coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) return { "is_capitalized": is_capitalized, @@ -385,21 +419,31 @@ class NotesTableCompat(ResultatsSemestre): """Stats (moy/min/max) sur la moyenne générale""" return StatsMoyenne(self.etud_moy_gen) - def get_ues_stat_dict(self, filter_sport=False): # was get_ues() + def get_ues_stat_dict( + self, filter_sport=False, check_apc_ects=True + ) -> list[dict]: # was get_ues() """Liste des UEs, ordonnée par numero. Si filter_sport, retire les UE de type SPORT. Résultat: liste de dicts { champs UE U stats moyenne UE } """ - ues = [] - for ue in self.formsemestre.query_ues(with_sport=not filter_sport): + ues = self.formsemestre.query_ues(with_sport=not filter_sport) + ues_dict = [] + for ue in ues: d = ue.to_dict() if ue.type != UE_SPORT: moys = self.etud_moy_ue[ue.id] else: moys = None d.update(StatsMoyenne(moys).to_dict()) - ues.append(d) - return ues + ues_dict.append(d) + if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"): + g.checked_apc_ects = True + if None in [ue.ects for ue in ues if ue.type != UE_SPORT]: + flash( + """Calcul moyenne générale impossible: ECTS des UE manquants !""", + category="danger", + ) + return ues_dict def get_modimpls_dict(self, ue_id=None) -> list[dict]: """Liste des modules pour une UE (ou toutes si ue_id==None), @@ -518,11 +562,15 @@ class NotesTableCompat(ResultatsSemestre): return "" return ins.etat - def get_etud_mat_moy(self, matiere_id, etudid): + def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str: """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" if not self.moyennes_matieres: return "nd" - return self.moyennes_matieres[matiere_id][etudid] + return ( + self.moyennes_matieres[matiere_id].get(etudid, "-") + if matiere_id in self.moyennes_matieres + else "-" + ) def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl diff --git a/app/decorators.py b/app/decorators.py index 8ebf5dea..220ece56 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -193,7 +193,7 @@ def scodoc7func(func): # necessary for db ids and boolean values try: v = int(v) - except ValueError: + except (ValueError, TypeError): pass pos_arg_values.append(v) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values) diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index 2be78713..c8998327 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -30,17 +30,15 @@ Formulaires configuration logos Contrib @jmp, dec 21 """ -import re from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed -from wtforms import SelectField, SubmitField, FormField, validators, FieldList +from wtforms import SubmitField, FormField, validators, FieldList +from wtforms import ValidationError from wtforms.fields.simple import StringField, HiddenField -from app import AccessDenied from app.models import Departement -from app.models import ScoDocSiteConfig from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_utils as scu from app.scodoc.sco_config_actions import ( @@ -49,10 +47,11 @@ from app.scodoc.sco_config_actions import ( LogoInsert, ) -from flask_login import current_user +from app.scodoc import sco_utils as scu from app.scodoc.sco_logos import find_logo + JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -111,6 +110,15 @@ def dept_key_to_id(dept_key): return dept_key +def logo_name_validator(message=None): + def validate_logo_name(form, field): + name = field.data if field.data else "" + if not scu.is_valid_filename(name): + raise ValidationError(message) + + return validate_logo_name + + class AddLogoForm(FlaskForm): """Formulaire permettant l'ajout d'un logo (dans un département)""" @@ -118,11 +126,7 @@ class AddLogoForm(FlaskForm): name = StringField( label="Nom", validators=[ - validators.regexp( - r"^[a-zA-Z0-9-_]*$", - re.IGNORECASE, - "Ne doit comporter que lettres, chiffres, _ ou -", - ), + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), validators.Length( max=20, message="Un nom ne doit pas dépasser 20 caractères" ), @@ -373,11 +377,11 @@ def config_logos(): if action: action.execute() flash(action.message) - return redirect( - url_for( - "scodoc.configure_logos", - ) - ) + return redirect(url_for("scodoc.configure_logos")) + else: + if not form.validate(): + scu.flash_errors(form) + return render_template( "config_logos.html", scodoc_dept=None, diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 700dec26..0aa74ef4 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -2,6 +2,7 @@ """ScoDoc models: moduleimpls """ import pandas as pd +import flask_sqlalchemy from app import db from app.comp import df_cache @@ -129,14 +130,36 @@ class ModuleImplInscription(db.Model): ) @classmethod - def nb_inscriptions_dans_ue( + def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int - ) -> int: - """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + ) -> flask_sqlalchemy.BaseQuery: + """moduleimpls de l'UE auxquels l'étudiant est inscrit""" return ModuleImplInscription.query.filter( ModuleImplInscription.etudid == etudid, ModuleImplInscription.moduleimpl_id == ModuleImpl.id, ModuleImpl.formsemestre_id == formsemestre_id, ModuleImpl.module_id == Module.id, Module.ue_id == ue_id, - ).count() + ) + + @classmethod + def nb_inscriptions_dans_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> int: + """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + return cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id).count() + + @classmethod + def sum_coefs_modimpl_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> float: + """Somme des coefficients des modules auxquels l'étudiant est inscrit + dans l'UE du semestre indiqué. + N'utilise que les coefficients, donc inadapté aux formations APC. + """ + return sum( + [ + inscr.modimpl.module.coefficient + for inscr in cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id) + ] + ) diff --git a/app/models/ues.py b/app/models/ues.py index 09469fb0..2bed88a3 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -54,13 +54,15 @@ class UniteEns(db.Model): 'EXTERNE' if self.is_external else ''})>""" def to_dict(self): - """as a dict, with the same conversions as in ScoDoc7""" + """as a dict, with the same conversions as in ScoDoc7 + (except ECTS: keep None) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) # ScoDoc7 output_formators e["ue_id"] = self.id e["numero"] = e["numero"] if e["numero"] else 0 - e["ects"] = e["ects"] if e["ects"] else 0.0 + e["ects"] = e["ects"] e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["code_apogee"] = e["code_apogee"] or "" # pas de None return e diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 23a8b834..653cdb80 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -30,7 +30,7 @@ import html -from flask import g +from flask import render_template from flask import request from flask_login import current_user @@ -280,6 +280,9 @@ def sco_header( if not no_side_bar: H.append(html_sidebar.sidebar()) H.append("""utilise les coefficients d'UE pour calculer la moyenne générale.
diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 987dc962..c83e1cc4 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -585,15 +585,17 @@ def formsemestre_recap_parcours_table( else: H.append('