diff --git a/app/__init__.py b/app/__init__.py index 0943a91f6..0ea6bb52c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -199,6 +199,10 @@ def create_app(config_class=DevConfig): app.register_blueprint(auth_bp, url_prefix="/auth") + from app.entreprises import bp as entreprises_bp + + app.register_blueprint(entreprises_bp, url_prefix="/ScoDoc/entreprises") + from app.views import scodoc_bp from app.views import scolar_bp from app.views import notes_bp diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 54d08400f..0eb28fe40 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -4,118 +4,41 @@ # See LICENSE ############################################################################## -from collections import defaultdict +"""Génération bulletin BUT +""" + import datetime from flask import url_for, g -import numpy as np -import pandas as pd -from app import db - -from app.comp import moy_ue, moy_sem, inscr_mod -from app.models import ModuleImpl from app.scodoc import sco_utils as scu -from app.scodoc.sco_cache import ResultatsSemestreBUTCache from app.scodoc import sco_bulletins_json from app.scodoc import sco_preferences -from app.scodoc.sco_utils import jsnan, fmt_note +from app.scodoc.sco_utils import fmt_note +from app.comp.res_but import ResultatsSemestreBUT -class ResultatsSemestreBUT: - """Structure légère pour stocker les résultats du semestre et - générer les bulletins. - __init__ : charge depuis le cache ou calcule +class BulletinBUT(ResultatsSemestreBUT): + """Génération du bulletin BUT. + Cette classe génère des dictionnaires avec toutes les informations + du bulletin, qui sont immédiatement traduisibles en JSON. """ - _cached_attrs = ( - "sem_cube", - "modimpl_inscr_df", - "modimpl_coefs_df", - "etud_moy_ue", - "modimpls_evals_poids", - "modimpls_evals_notes", - "etud_moy_gen", - "etud_moy_gen_ranks", - "modimpls_evaluations_complete", - ) - - def __init__(self, formsemestre): - self.formsemestre = formsemestre - self.ues = formsemestre.query_ues().all() - self.modimpls = formsemestre.modimpls.all() - self.etuds = self.formsemestre.get_inscrits(include_dem=False) - self.etud_index = {e.id: idx for idx, e in enumerate(self.etuds)} - self.saes = [ - m for m in self.modimpls if m.module.module_type == scu.ModuleType.SAE - ] - self.ressources = [ - m for m in self.modimpls if m.module.module_type == scu.ModuleType.RESSOURCE - ] - if not self.load_cached(): - self.compute() - self.store() - - def load_cached(self) -> bool: - "Load cached dataframes, returns False si pas en cache" - data = ResultatsSemestreBUTCache.get(self.formsemestre.id) - if not data: - return False - for attr in self._cached_attrs: - setattr(self, attr, data[attr]) - return True - - def store(self): - "Cache our dataframes" - ResultatsSemestreBUTCache.set( - self.formsemestre.id, - {attr: getattr(self, attr) for attr in self._cached_attrs}, - ) - - def compute(self): - "Charge les notes et inscriptions et calcule toutes les moyennes" - ( - self.sem_cube, - self.modimpls_evals_poids, - self.modimpls_evals_notes, - modimpls_evaluations, - self.modimpls_evaluations_complete, - ) = moy_ue.notes_sem_load_cube(self.formsemestre) - self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) - self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( - self.formsemestre, ues=self.ues, modimpls=self.modimpls - ) - # l'idx de la colonne du mod modimpl.id est - # modimpl_coefs_df.columns.get_loc(modimpl.id) - # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) - self.etud_moy_ue = moy_ue.compute_ue_moys( - self.sem_cube, - self.etuds, - self.modimpls, - self.ues, - self.modimpl_inscr_df, - self.modimpl_coefs_df, - ) - self.etud_moy_gen = moy_sem.compute_sem_moys( - self.etud_moy_ue, self.modimpl_coefs_df - ) - self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) - def etud_ue_mod_results(self, etud, ue, modimpls) -> dict: "dict synthèse résultats dans l'UE pour les modules indiqués" d = {} etud_idx = self.etud_index[etud.id] ue_idx = self.modimpl_coefs_df.index.get_loc(ue.id) etud_moy_module = self.sem_cube[etud_idx] # module x UE - for mi in modimpls: - coef = self.modimpl_coefs_df[mi.id][ue.id] + for modimpl in modimpls: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] if coef > 0: - d[mi.module.code] = { - "id": mi.id, + d[modimpl.module.code] = { + "id": modimpl.id, "coef": coef, "moyenne": fmt_note( - etud_moy_module[self.modimpl_coefs_df.columns.get_loc(mi.id)][ - ue_idx - ] + etud_moy_module[ + self.modimpl_coefs_df.columns.get_loc(modimpl.id) + ][ue_idx] ), } return d @@ -149,7 +72,7 @@ class ResultatsSemestreBUT: avec évaluations de chacun.""" d = {} # etud_idx = self.etud_index[etud.id] - for mi in modimpls: + for modimpl in modimpls: # mod_idx = self.modimpl_coefs_df.columns.get_loc(mi.id) # # moyennes indicatives (moyennes de moyennes d'UE) # try: @@ -163,14 +86,15 @@ class ResultatsSemestreBUT: # moy_indicative_mod = np.nanmean(self.sem_cube[etud_idx, mod_idx]) # except RuntimeWarning: # all nans in np.nanmean # pass - d[mi.module.code] = { - "id": mi.id, - "titre": mi.module.titre, - "code_apogee": mi.module.code_apogee, + modimpl_results = self.modimpls_results[modimpl.id] + d[modimpl.module.code] = { + "id": modimpl.id, + "titre": modimpl.module.titre, + "code_apogee": modimpl.module.code_apogee, "url": url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=mi.id, + moduleimpl_id=modimpl.id, ), "moyenne": { # # moyenne indicative de module: moyenne des UE, ignorant celles sans notes (nan) @@ -181,16 +105,17 @@ class ResultatsSemestreBUT: }, "evaluations": [ self.etud_eval_results(etud, e) - for eidx, e in enumerate(mi.evaluations) + for e in modimpl.evaluations if e.visibulletin - and self.modimpls_evaluations_complete[mi.id][eidx] + and modimpl_results.evaluations_etat[e.id].is_complete ], } return d def etud_eval_results(self, etud, e) -> dict: "dict resultats d'un étudiant à une évaluation" - eval_notes = self.modimpls_evals_notes[e.moduleimpl_id][e.id] # pd.Series + # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits + eval_notes = self.modimpls_results[e.moduleimpl_id].evals_notes[e.id] notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() d = { "id": e.id, @@ -202,7 +127,7 @@ class ResultatsSemestreBUT: "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, "note": { "value": fmt_note( - self.modimpls_evals_notes[e.moduleimpl_id][e.id][etud.id], + eval_notes[etud.id], note_max=e.note_max, ), "min": fmt_note(notes_ok.min()), @@ -233,7 +158,7 @@ class ResultatsSemestreBUT: }, "formsemestre_id": formsemestre.id, "etat_inscription": etat_inscription, - "options": bulletin_option_affichage(formsemestre), + "options": sco_preferences.bulletin_option_affichage(formsemestre.id), } semestre_infos = { "etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo], @@ -244,8 +169,8 @@ class ResultatsSemestreBUT: "numero": formsemestre.semestre_id, "groupes": [], # XXX TODO "absences": { # XXX TODO - "injustifie": 1, - "total": 33, + "injustifie": -1, + "total": -1, }, } semestre_infos.update( @@ -298,125 +223,3 @@ class ResultatsSemestreBUT: ) return d - - -def bulletin_option_affichage(formsemestre): - "dict avec les options d'affichages (préférences) pour ce semestre" - prefs = sco_preferences.SemPreferences(formsemestre.id) - fields = ( - "bul_show_abs", - "bul_show_abs_modules", - "bul_show_ects", - "bul_show_codemodules", - "bul_show_matieres", - "bul_show_rangs", - "bul_show_ue_rangs", - "bul_show_mod_rangs", - "bul_show_moypromo", - "bul_show_minmax", - "bul_show_minmax_mod", - "bul_show_minmax_eval", - "bul_show_coef", - "bul_show_ue_cap_details", - "bul_show_ue_cap_current", - "bul_show_temporary", - "bul_temporary_txt", - "bul_show_uevalid", - "bul_show_date_inscr", - ) - # on enlève le "bul_" de la clé: - return {field[4:]: prefs[field] for field in fields} - - -# Pour raccorder le code des anciens bulletins qui attendent une NoteTable -class APCNotesTableCompat: - """Implementation partielle de NotesTable pour les formations APC - Accès aux notes et rangs. - """ - - def __init__(self, formsemestre): - self.results = ResultatsSemestreBUT(formsemestre) - nb_etuds = len(self.results.etuds) - self.rangs = self.results.etud_moy_gen_ranks - self.moy_min = self.results.etud_moy_gen.min() - self.moy_max = self.results.etud_moy_gen.max() - self.moy_moy = self.results.etud_moy_gen.mean() - self.bonus = defaultdict(lambda: 0.0) # XXX - self.ue_rangs = { - u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.results.ues - } - self.mod_rangs = { - m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.results.modimpls - } - - def get_ues(self): - ues = [] - for ue in self.results.ues: - d = ue.to_dict() - d.update( - { - "max": self.results.etud_moy_ue[ue.id].max(), - "min": self.results.etud_moy_ue[ue.id].min(), - "moy": self.results.etud_moy_ue[ue.id].mean(), - "nb_moy": len(self.results.etud_moy_ue), - } - ) - ues.append(d) - return ues - - def get_modimpls(self): - return [m.to_dict() for m in self.results.modimpls] - - def get_etud_moy_gen(self, etudid): - return self.results.etud_moy_gen[etudid] - - def get_moduleimpls_attente(self): - return [] # XXX TODO - - def get_etud_rang(self, etudid): - return self.rangs[etudid] - - def get_etud_rang_group(self, etudid, group_id): - return (None, 0) # XXX unimplemented TODO - - def get_etud_ue_status(self, etudid, ue_id): - return { - "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid], - "is_capitalized": False, # XXX TODO - } - - def get_etud_mod_moy(self, moduleimpl_id, etudid): - mod_idx = self.results.modimpl_coefs_df.columns.get_loc(moduleimpl_id) - etud_idx = self.results.etud_index[etudid] - # moyenne sur les UE: - self.results.sem_cube[etud_idx, mod_idx].mean() - - def get_mod_stats(self, moduleimpl_id): - return { - "moy": "-", - "max": "-", - "min": "-", - "nb_notes": "-", - "nb_missing": "-", - "nb_valid_evals": "-", - } - - def get_evals_in_mod(self, moduleimpl_id): - mi = ModuleImpl.query.get(moduleimpl_id) - evals_results = [] - for e in mi.evaluations: - d = e.to_dict() - d["heure_debut"] = e.heure_debut # datetime.time - d["heure_fin"] = e.heure_fin - d["jour"] = e.jour # datetime - d["notes"] = { - etud.id: { - "etudid": etud.id, - "value": self.results.modimpls_evals_notes[e.moduleimpl_id][e.id][ - etud.id - ], - } - for etud in self.results.etuds - } - evals_results.append(d) - return evals_results diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 4307788dc..9743c2181 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -108,8 +108,8 @@ def bulletin_but_xml_compat( code_ine=etud.code_ine or "", nom=scu.quote_xml_attr(etud.nom), prenom=scu.quote_xml_attr(etud.prenom), - civilite=scu.quote_xml_attr(etud.civilite_str()), - sexe=scu.quote_xml_attr(etud.civilite_str()), # compat + civilite=scu.quote_xml_attr(etud.civilite_str), + sexe=scu.quote_xml_attr(etud.civilite_str), # compat photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)), email=scu.quote_xml_attr(etud.get_first_email() or ""), emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""), @@ -216,9 +216,9 @@ def bulletin_but_xml_compat( Element( "note", value=scu.fmt_note( - results.modimpls_evals_notes[e.moduleimpl_id][ - e.id - ][etud.id] + results.modimpls_results[ + e.moduleimpl_id + ].evals_notes[e.id][etud.id] ), ) ) @@ -314,13 +314,3 @@ def bulletin_but_xml_compat( return None else: return sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - - -""" -formsemestre_id=718 -etudid=12496 -from app.but.bulletin_but import * -mapp.set_sco_dept("RT") -sem = FormSemestre.query.get(formsemestre_id) -r = ResultatsSemestreBUT(sem) -""" diff --git a/app/comp/aux.py b/app/comp/aux.py new file mode 100644 index 000000000..92ee22c1b --- /dev/null +++ b/app/comp/aux.py @@ -0,0 +1,37 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +import numpy as np + +"""Quelques classes auxiliaires pour les calculs des notes +""" + + +class StatsMoyenne: + """Une moyenne d'un ensemble étudiants sur quelque chose + (moyenne générale d'un semestre, d'un module, d'un groupe...) + et les statistiques associées: min, max, moy, effectif + """ + + def __init__(self, vals): + """Calcul les statistiques. + Les valeurs NAN ou non numériques sont toujours enlevées. + """ + self.moy = np.nanmean(vals) + self.min = np.nanmin(vals) + self.max = np.nanmax(vals) + self.size = len(vals) + self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) + + def to_dict(self): + "Tous les attributs dans un dict" + return { + "min": self.min, + "max": self.max, + "moy": self.moy, + "size": self.size, + "nb_vals": self.nb_vals, + } diff --git a/app/comp/df_cache.py b/app/comp/df_cache.py index 2a85f4157..5b555fec4 100644 --- a/app/comp/df_cache.py +++ b/app/comp/df_cache.py @@ -43,7 +43,7 @@ class ModuleCoefsCache(sco_cache.ScoDocCache): class EvaluationsPoidsCache(sco_cache.ScoDocCache): """Cache for poids evals Clé: moduleimpl_id - Valeur: DataFrame (df_load_evaluations_poids) + Valeur: DataFrame (load_evaluations_poids) """ prefix = "EPC" diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 41000fd4b..39de5a819 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -27,21 +27,241 @@ """Fonctions de calcul des moyennes de modules (modules, ressources ou SAÉ) +Pour les formations classiques et le BUT + Rappel: pour éviter les confusions, on appelera *poids* les coefficients d'une évaluation dans un module, et *coefficients* ceux utilisés pour le calcul de la moyenne générale d'une UE. """ +from dataclasses import dataclass import numpy as np import pandas as pd -from pandas.core.frame import DataFrame from app import db -from app import models from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu -def df_load_evaluations_poids( +@dataclass +class EvaluationEtat: + """Classe pour stocker quelques infos sur les résultats d'une évaluation""" + + evaluation_id: int + nb_attente: int + is_complete: bool + + +class ModuleImplResults: + """Classe commune à toutes les formations (standard et APC). + Les notes des étudiants d'un moduleimpl. + Les poids des évals sont à part car on en a besoin sans les notes pour les + tableaux de bord. + Les attributs sont tous des objets simples cachables dans Redis; + les caches sont gérés par ResultatsSemestre. + """ + + def __init__(self, moduleimpl: ModuleImpl): + self.moduleimpl_id = moduleimpl.id + self.module_id = moduleimpl.module.id + self.etudids = None + "liste des étudiants inscrits au SEMESTRE" + self.nb_inscrits_module = None + "nombre d'inscrits (non DEM) au module" + self.evaluations_completes = [] + "séquence de booléens, indiquant les évals à prendre en compte." + self.evaluations_etat = {} + "{ evaluation_id: EvaluationEtat }" + # + self.evals_notes = None + """DataFrame, colonnes: EVALS, Lignes: etudid + valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, + NOTES_ABSENCE. + Les NaN désignent les notes manquantes (non saisies). + """ + self.etuds_moy_module = None + """DataFrame, colonnes UE, lignes etud + = la note de l'étudiant dans chaque UE pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef vers cette UE. + """ + self.load_notes() + + def load_notes(self): # ré-écriture de df_load_modimpl_notes + """Charge toutes les notes de toutes les évaluations du module. + Dataframe evals_notes + colonnes: le nom de la colonne est l'evaluation_id (int) + index (lignes): etudid (int) + + L'ensemble des étudiants est celui des inscrits au SEMESTRE. + + Les notes sont "brutes" (séries de floats) et peuvent prendre les valeurs: + note : float (valeur enregistrée brute, NON normalisée sur 20) + pas de note: NaN (rien en bd, ou étudiant non inscrit au module) + absent: NOTES_ABSENCE (NULL en bd) + excusé: NOTES_NEUTRALISE (voir sco_utils) + attente: NOTES_ATTENTE + + Évaluation "complete" (prise en compte dans les calculs) si: + - soit tous les étudiants inscrits au module ont des notes + - soit elle a été déclarée "à prise ne compte immédiate" (publish_incomplete) + + Évaluation "attente" (prise en compte dans les calculs, mais il y + manque des notes) ssi il y a des étudiants inscrits au semestre et au module + qui ont des notes ATT. + """ + moduleimpl = ModuleImpl.query.get(self.moduleimpl_id) + self.etudids = self._etudids() + + # --- Calcul nombre d'inscrits pour déterminer les évaluations "completes": + # on prend les inscrits au module ET au semestre (donc sans démissionnaires) + inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection( + self.etudids + ) + self.nb_inscrits_module = len(inscrits_module) + + # dataFrame vide, index = tous les inscrits au SEMESTRE + evals_notes = pd.DataFrame(index=self.etudids, dtype=float) + self.evaluations_completes = [] + for evaluation in moduleimpl.evaluations: + eval_df = self._load_evaluation_notes(evaluation) + # is_complete ssi tous les inscrits (non dem) au semestre ont une note + # ou évaluaton déclarée "à prise en compte immédiate" + is_complete = ( + len(set(eval_df.index).intersection(self.etudids)) + == self.nb_inscrits_module + ) or evaluation.publish_incomplete # immédiate + self.evaluations_completes.append(is_complete) + + # NULL en base => ABS (= -999) + eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) + # Ce merge ne garde que les étudiants inscrits au module + # et met à NULL les notes non présentes + # (notes non saisies ou etuds non inscrits au module): + evals_notes = evals_notes.merge( + eval_df, how="left", left_index=True, right_index=True + ) + # Notes en attente: (on prend dans evals_notes pour ne pas avoir les dem.) + nb_att = sum(evals_notes[str(evaluation.id)] == scu.NOTES_ATTENTE) + self.evaluations_etat[evaluation.id] = EvaluationEtat( + evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete + ) + + # Force columns names to integers (evaluation ids) + evals_notes.columns = pd.Int64Index( + [int(x) for x in evals_notes.columns], dtype="int" + ) + self.evals_notes = evals_notes + + def _load_evaluation_notes(self, evaluation: Evaluation) -> pd.DataFrame: + """Charge les notes de l'évaluation + Resultat: dataframe, index: etudid ayant une note, valeur: note brute. + """ + eval_df = pd.read_sql_query( + """SELECT n.etudid, n.value AS "%(evaluation_id)s" + FROM notes_notes n, notes_moduleimpl_inscription i + WHERE evaluation_id=%(evaluation_id)s + AND n.etudid = i.etudid + AND i.moduleimpl_id = %(moduleimpl_id)s + """, + db.engine, + params={ + "evaluation_id": evaluation.id, + "moduleimpl_id": evaluation.moduleimpl.id, + }, + index_col="etudid", + ) + eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)]) + return eval_df + + def _etudids(self): + """L'index du dataframe est la liste des étudiants inscrits au semestre, + sans les démissionnaires. + """ + return [ + e.etudid + for e in ModuleImpl.query.get(self.moduleimpl_id).formsemestre.get_inscrits( + include_dem=False + ) + ] + + def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array: + """Coefficients des évaluations, met à zéro ceux des évals incomplètes. + Résultat: 2d-array of floats, shape (nb_evals, 1) + """ + return ( + np.array( + [e.coefficient for e in moduleimpl.evaluations], + dtype=float, + ) + * self.evaluations_completes + ).reshape(-1, 1) + + def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array: + """Les notes des évaluations, + remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20. + Résultat: 2d array of floats, shape nb_etuds x nb_evaluations + """ + return np.where( + self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0 + ) / [e.note_max / 20.0 for e in moduleimpl.evaluations] + + +class ModuleImplResultsAPC(ModuleImplResults): + "Calcul des moyennes de modules à la mode BUT" + + def compute_module_moy( + self, + evals_poids_df: pd.DataFrame, + ) -> pd.DataFrame: + """Calcule les moyennes des étudiants dans ce module + + Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + + Résultat: DataFrame, colonnes UE, lignes etud + = la note de l'étudiant dans chaque UE pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef vers cette UE. + """ + moduleimpl = ModuleImpl.query.get(self.moduleimpl_id) + nb_etuds, nb_evals = self.evals_notes.shape + nb_ues = evals_poids_df.shape[1] + assert evals_poids_df.shape[0] == nb_evals # compat notes/poids + if nb_etuds == 0: + return pd.DataFrame(index=[], columns=evals_poids_df.columns) + evals_coefs = self.get_evaluations_coefs(moduleimpl) + evals_poids = evals_poids_df.values * evals_coefs + # -> evals_poids shape : (nb_evals, nb_ues) + assert evals_poids.shape == (nb_evals, nb_ues) + evals_notes_20 = self.get_eval_notes_sur_20(moduleimpl) + + # Les poids des évals pour chaque étudiant: là où il a des notes + # non neutralisées + # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) + # Note: les NaN sont remplacés par des 0 dans evals_notes + # et dans dans evals_poids_etuds + # (rappel: la comparaison est toujours false face à un NaN) + # shape: (nb_etuds, nb_evals, nb_ues) + poids_stacked = np.stack([evals_poids] * nb_etuds) + evals_poids_etuds = np.where( + np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, + poids_stacked, + 0, + ) + # Calcule la moyenne pondérée sur les notes disponibles: + evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module = np.sum( + evals_poids_etuds * evals_notes_stacked, axis=1 + ) / np.sum(evals_poids_etuds, axis=1) + self.etuds_moy_module = pd.DataFrame( + etuds_moy_module, + index=self.evals_notes.index, + columns=evals_poids_df.columns, + ) + return self.etuds_moy_module + + +def load_evaluations_poids( moduleimpl_id: int, default_poids=1.0 ) -> tuple[pd.DataFrame, list]: """Charge poids des évaluations d'un module et retourne un dataframe @@ -55,23 +275,25 @@ def df_load_evaluations_poids( ues = modimpl.formsemestre.query_ues(with_sport=False).all() ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] - df = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) - for eval_poids in EvaluationUEPoids.query.join( + evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) + for ue_poids in EvaluationUEPoids.query.join( EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): - df[eval_poids.ue_id][eval_poids.evaluation_id] = eval_poids.poids + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids if default_poids is not None: - df.fillna(value=default_poids, inplace=True) - return df, ues + evals_poids.fillna(value=default_poids, inplace=True) + return evals_poids, ues -def check_moduleimpl_conformity( +def moduleimpl_is_conforme( moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame ) -> bool: """Vérifie que les évaluations de ce moduleimpl sont bien conformes au PN. Un module est dit *conforme* si et seulement si la somme des poids de ses évaluations vers une UE de coefficient non nul est non nulle. + + Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs """ nb_evals, nb_ues = evals_poids.shape if nb_evals == 0: @@ -79,160 +301,52 @@ def check_moduleimpl_conformity( if nb_ues == 0: return False # situation absurde (pas d'UE) if len(modules_coefficients) != nb_ues: - raise ValueError("check_moduleimpl_conformity: nb ue incoherent") + raise ValueError("moduleimpl_is_conforme: nb ue incoherent") module_evals_poids = evals_poids.transpose().sum(axis=1).to_numpy() != 0 check = all( - (modules_coefficients[moduleimpl.module.id].to_numpy() != 0) + (modules_coefficients[moduleimpl.module_id].to_numpy() != 0) == module_evals_poids ) return check -def df_load_modimpl_notes(moduleimpl_id: int) -> tuple: - """Construit un dataframe avec toutes les notes de toutes les évaluations du module. - colonnes: le nom de la colonne est l'evaluation_id (int) - index (lignes): etudid (int) +class ModuleImplResultsClassic(ModuleImplResults): + "Calcul des moyennes de modules des formations classiques" - Résultat: (evals_notes, liste de évaluations du moduleimpl, - liste de booleens indiquant si l'évaluation est "complete") + def compute_module_moy(self) -> pd.Series: + """Calcule les moyennes des étudiants dans ce module - L'ensemble des étudiants est celui des inscrits au SEMESTRE. - - Les notes renvoyées sont "brutes" (séries de floats) et peuvent prendre les valeurs: - note : float (valeur enregistrée brute, non normalisée sur 20) - pas de note: NaN (rien en bd, ou étudiant non inscrit au module) - absent: NOTES_ABSENCE (NULL en bd) - excusé: NOTES_NEUTRALISE (voir sco_utils) - attente: NOTES_ATTENTE - - L'évaluation "complete" (prise en compte dans les calculs) si: - - soit tous les étudiants inscrits au module ont des notes - - soit elle a été déclarée "à prise ne compte immédiate" (publish_incomplete) - - N'utilise pas de cache ScoDoc. - """ - # L'index du dataframe est la liste des étudiants inscrits au semestre, - # sans les démissionnaires - etudids = [ - e.etudid - for e in ModuleImpl.query.get(moduleimpl_id).formsemestre.get_inscrits( - include_dem=False + Résultat: Series, lignes etud + = la note (moyenne) de l'étudiant pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef. + """ + modimpl = ModuleImpl.query.get(self.moduleimpl_id) + nb_etuds, nb_evals = self.evals_notes.shape + if nb_etuds == 0: + return pd.Series() + evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1) + assert evals_coefs.shape == (nb_evals,) + evals_notes_20 = self.get_eval_notes_sur_20(modimpl) + # Les coefs des évals pour chaque étudiant: là où il a des notes + # non neutralisées + # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) + # Note: les NaN sont remplacés par des 0 dans evals_notes + # et dans dans evals_poids_etuds + # (rappel: la comparaison est toujours False face à un NaN) + # shape: (nb_etuds, nb_evals) + coefs_stacked = np.stack([evals_coefs] * nb_etuds) + evals_coefs_etuds = np.where( + self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0 ) - ] - evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() - # --- Calcul nombre d'inscrits pour détermnier si évaluation "complete": - if evaluations: - # on prend les inscrits au module ET au semestre (donc sans démissionnaires) - inscrits_module = { - ins.etud.id for ins in evaluations[0].moduleimpl.inscriptions - }.intersection(etudids) - nb_inscrits_module = len(inscrits_module) - else: - nb_inscrits_module = 0 - # empty df with all students: - evals_notes = pd.DataFrame(index=etudids, dtype=float) - evaluations_completes = [] - for evaluation in evaluations: - eval_df = pd.read_sql_query( - """SELECT n.etudid, n.value AS "%(evaluation_id)s" - FROM notes_notes n, notes_moduleimpl_inscription i - WHERE evaluation_id=%(evaluation_id)s - AND n.etudid = i.etudid - AND i.moduleimpl_id = %(moduleimpl_id)s - ORDER BY n.etudid - """, - db.engine, - params={ - "evaluation_id": evaluation.id, - "moduleimpl_id": evaluation.moduleimpl.id, - }, - index_col="etudid", + # Calcule la moyenne pondérée sur les notes disponibles: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module = np.sum( + evals_coefs_etuds * evals_notes_20, axis=1 + ) / np.sum(evals_coefs_etuds, axis=1) + + self.etuds_moy_module = pd.Series( + etuds_moy_module, + index=self.evals_notes.index, ) - eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)]) - # is_complete ssi tous les inscrits (non dem) au semestre ont une note - is_complete = ( - len(set(eval_df.index).intersection(etudids)) == nb_inscrits_module - ) or evaluation.publish_incomplete - evaluations_completes.append(is_complete) - # NULL en base => ABS (= -999) - eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) - # Ce merge met à NULL les élements non présents - # (notes non saisies ou etuds non inscrits au module): - evals_notes = evals_notes.merge( - eval_df, how="left", left_index=True, right_index=True - ) - # Force columns names to integers (evaluation ids) - evals_notes.columns = pd.Int64Index( - [int(x) for x in evals_notes.columns], dtype="int64" - ) - return evals_notes, evaluations, evaluations_completes - - -def compute_module_moy( - evals_notes_df: pd.DataFrame, - evals_poids_df: pd.DataFrame, - evaluations: list, - evaluations_completes: list, -) -> pd.DataFrame: - """Calcule les moyennes des étudiants dans ce module - - - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid - valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, - NOTES_ABSENCE. - Les NaN désignent les notes manquantes (non saisies). - - - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs - - - evaluations: séquence d'évaluations (utilisées pour le coef et - le barème) - - - evaluations_completes: séquence de booléens indiquant les - évals à prendre en compte. - - Résultat: DataFrame, colonnes UE, lignes etud - = la note de l'étudiant dans chaque UE pour ce module. - ou NaN si les évaluations (dans lesquelles l'étudiant à des notes) - ne donnent pas de coef vers cette UE. - """ - nb_etuds, nb_evals = evals_notes_df.shape - nb_ues = evals_poids_df.shape[1] - assert evals_poids_df.shape[0] == nb_evals # compat notes/poids - if nb_etuds == 0: - return pd.DataFrame(index=[], columns=evals_poids_df.columns) - # Coefficients des évaluations, met à zéro ceux des évals incomplètes: - evals_coefs = ( - np.array( - [e.coefficient for e in evaluations], - dtype=float, - ) - * evaluations_completes - ).reshape(-1, 1) - evals_poids = evals_poids_df.values * evals_coefs - # -> evals_poids shape : (nb_evals, nb_ues) - assert evals_poids.shape == (nb_evals, nb_ues) - # Remplace les notes ATT, EXC, ABS, NaN par zéro et mets les notes sur 20: - evals_notes = np.where( - evals_notes_df.values > scu.NOTES_ABSENCE, evals_notes_df.values, 0.0 - ) / [e.note_max / 20.0 for e in evaluations] - # Les poids des évals pour les étudiant: là où il a des notes non neutralisées - # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) - # Note: les NaN sont remplacés par des 0 dans evals_notes - # et dans dans evals_poids_etuds - # (rappel: la comparaison est toujours false face à un NaN) - # shape: (nb_etuds, nb_evals, nb_ues) - poids_stacked = np.stack([evals_poids] * nb_etuds) - evals_poids_etuds = np.where( - np.stack([evals_notes_df.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, - poids_stacked, - 0, - ) - # Calcule la moyenne pondérée sur les notes disponibles: - evals_notes_stacked = np.stack([evals_notes] * nb_ues, axis=2) - with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) - etuds_moy_module = np.sum( - evals_poids_etuds * evals_notes_stacked, axis=1 - ) / np.sum(evals_poids_etuds, axis=1) - etuds_moy_module_df = pd.DataFrame( - etuds_moy_module, index=evals_notes_df.index, columns=evals_poids_df.columns - ) - return etuds_moy_module_df + return self.etuds_moy_module diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 51cc5e77b..8797b856c 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -31,7 +31,7 @@ import numpy as np import pandas as pd -def compute_sem_moys(etud_moy_ue_df, modimpl_coefs_df): +def compute_sem_moys_apc(etud_moy_ue_df, modimpl_coefs_df): """Calcule la moyenne générale indicative = moyenne des moyennes d'UE, pondérée par la somme de leurs coefs diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index a707633fd..855e3b7fa 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -25,7 +25,7 @@ # ############################################################################## -"""Fonctions de calcul des moyennes d'UE +"""Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ import numpy as np import pandas as pd @@ -39,9 +39,9 @@ from app.scodoc import sco_codes_parcours def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.DataFrame: - """Charge les coefs des modules de la formation pour le semestre indiqué. + """Charge les coefs APC des modules de la formation pour le semestre indiqué. - Ces coefs lient les modules à chaque UE. + En APC, ces coefs lient les modules à chaque UE. Résultat: (module_coefs_df, ues, modules) DataFrame rows = UEs, columns = modules, value = coef. @@ -86,7 +86,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data def df_load_modimpl_coefs( formsemestre: models.FormSemestre, ues=None, modimpls=None ) -> pd.DataFrame: - """Charge les coefs des modules du formsemestre indiqué. + """Charge les coefs APC des modules du formsemestre indiqué. Comme df_load_module_coefs mais prend seulement les UE et modules du formsemestre. @@ -127,45 +127,32 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: return modimpls_notes.swapaxes(0, 1) -def notes_sem_load_cube(formsemestre): +def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: """Calcule le cube des notes du semestre (charge toutes les notes, calcule les moyenne des modules et assemble le cube) Resultat: sem_cube : ndarray (etuds x modimpls x UEs) modimpls_evals_poids dict { modimpl.id : evals_poids } - modimpls_evals_notes dict { modimpl.id : evals_notes } - modimpls_evaluations dict { modimpl.id : liste des évaluations } - modimpls_evaluations_complete: {modimpl_id : liste de booleens (complete/non)} + modimpls_results dict { modimpl.id : ModuleImplResultsAPC } """ + modimpls_results = {} modimpls_evals_poids = {} - modimpls_evals_notes = {} - modimpls_evaluations = {} - modimpls_evaluations_complete = {} modimpls_notes = [] for modimpl in formsemestre.modimpls: - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - modimpl.id - ) - evals_poids, ues = moy_mod.df_load_evaluations_poids(modimpl.id) - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) - modimpls_evals_poids[modimpl.id] = evals_poids - modimpls_evals_notes[modimpl.id] = evals_notes - modimpls_evaluations[modimpl.id] = evaluations - modimpls_evaluations_complete[modimpl.id] = evaluations_completes + mod_results = moy_mod.ModuleImplResultsAPC(modimpl) + 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_notes.append(etuds_moy_module) return ( notes_sem_assemble_cube(modimpls_notes), modimpls_evals_poids, - modimpls_evals_notes, - modimpls_evaluations, - modimpls_evaluations_complete, + modimpls_results, ) -def compute_ue_moys( +def compute_ue_moys_apc( sem_cube: np.array, etuds: list, modimpls: list, @@ -173,7 +160,7 @@ def compute_ue_moys( modimpl_inscr_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame, ) -> pd.DataFrame: - """Calcul de la moyenne d'UE + """Calcul de la moyenne d'UE en mode APC (BUT). La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles @@ -182,11 +169,11 @@ def compute_ue_moys( sem_cube: notes moyennes aux modules ndarray (etuds x modimpls x UEs) (floats avec des NaN) - etuds : lites des étudiants (dim. 0 du cube) + etuds : listes des étudiants (dim. 0 du cube) modimpls : liste des modules à considérer (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) - module_inscr_df: matrice d'inscription du semestre (etud x modimpl) - module_coefs_df: matrice coefficients (UE x modimpl) + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs_df: matrice coefficients (UE x modimpl) Resultat: DataFrame columns UE, rows etudid """ @@ -228,3 +215,70 @@ def compute_ue_moys( return pd.DataFrame( etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) + + +def compute_ue_moys_classic( + formsemestre: FormSemestre, + sem_matrix: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, +) -> pd.DataFrame: + """Calcul de la moyenne d'UE en mode classique. + La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR + NI non inscrit à (au moins un) module de cette UE + NA pas de notes disponibles + ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] + + sem_matrix: notes moyennes aux modules + ndarray (etuds x modimpls) + (floats avec des NaN) + etuds : listes des étudiants (dim. 0 de la matrice) + ues : liste des UE + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs: vecteur des coefficients de modules + + Résultat: + - moyennes générales: pd.Series, index etudid + - moyennes d'UE: DataFrame columns UE, rows etudid + """ + nb_etuds, nb_modules = sem_matrix.shape + assert len(modimpl_coefs) == nb_modules + nb_ues = len(ues) + modimpl_inscr = modimpl_inscr_df.values + # Enlève les NaN du numérateur: + sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mods) + modimpl_coefs_etuds_no_nan = np.where( + np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds + ) + # Calcul des moyennes générales: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_gen = np.sum( + modimpl_coefs_etuds_no_nan * sem_matrix_inscrits, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index) + # Calcul des moyennes d'UE + ue_modules = np.array( + [[m.module.ue == ue for m in formsemestre.modimpls] for ue in ues] + )[..., np.newaxis] + modimpl_coefs_etuds_no_nan_stacked = np.stack( + [modimpl_coefs_etuds_no_nan.T] * nb_ues + ) + # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions + coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_ue = ( + np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2) + ).T + etud_moy_ue_df = pd.DataFrame( + etud_moy_ue, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues] + ) + return etud_moy_gen_s, etud_moy_ue_df diff --git a/app/comp/res_but.py b/app/comp/res_but.py new file mode 100644 index 000000000..cc0b88079 --- /dev/null +++ b/app/comp/res_but.py @@ -0,0 +1,65 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Résultats semestres BUT +""" + +from app.comp import moy_ue, moy_sem, inscr_mod +from app.comp.res_sem import NotesTableCompat + + +class ResultatsSemestreBUT(NotesTableCompat): + """Résultats BUT: organisation des calculs""" + + _cached_attrs = NotesTableCompat._cached_attrs + ( + "modimpl_coefs_df", + "modimpls_evals_poids", + "sem_cube", + ) + + def __init__(self, formsemestre): + super().__init__(formsemestre) + + if not self.load_cached(): + self.compute() + self.store() + + def compute(self): + "Charge les notes et inscriptions et calcule les moyennes d'UE et gen." + ( + self.sem_cube, + self.modimpls_evals_poids, + self.modimpls_results, + ) = moy_ue.notes_sem_load_cube(self.formsemestre) + self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) + self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( + self.formsemestre, ues=self.ues, modimpls=self.modimpls + ) + # l'idx de la colonne du mod modimpl.id est + # modimpl_coefs_df.columns.get_loc(modimpl.id) + # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) + self.etud_moy_ue = moy_ue.compute_ue_moys_apc( + self.sem_cube, + self.etuds, + self.modimpls, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs_df, + ) + self.etud_moy_gen = moy_sem.compute_sem_moys_apc( + self.etud_moy_ue, self.modimpl_coefs_df + ) + self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: + """La moyenne de l'étudiant dans le moduleimpl + En APC, il s'agit d'une moyenne indicative sans valeur. + Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) + """ + mod_idx = self.modimpl_coefs_df.columns.get_loc(moduleimpl_id) + etud_idx = self.etud_index[etudid] + # moyenne sur les UE: + return self.sem_cube[etud_idx, mod_idx].mean() diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py new file mode 100644 index 000000000..b203ec589 --- /dev/null +++ b/app/comp/res_classic.py @@ -0,0 +1,95 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Résultats semestres classiques (non APC) +""" +import numpy as np +import pandas as pd +from app.comp import moy_mod, moy_ue, moy_sem, inscr_mod +from app.comp.res_sem import NotesTableCompat +from app.models.formsemestre import FormSemestre + + +class ResultatsSemestreClassic(NotesTableCompat): + """Résultats du semestre (formation classique): organisation des calculs.""" + + _cached_attrs = NotesTableCompat._cached_attrs + ( + "modimpl_coefs", + "modimpl_idx", + "sem_matrix", + ) + + def __init__(self, formsemestre): + super().__init__(formsemestre) + + if not self.load_cached(): + self.compute() + self.store() + # recalculé (aussi rapide que de les cacher) + self.moy_min = self.etud_moy_gen.min() + self.moy_max = self.etud_moy_gen.max() + self.moy_moy = self.etud_moy_gen.mean() + + def compute(self): + "Charge les notes et inscriptions et calcule les moyennes d'UE et gen." + self.sem_matrix, self.modimpls_results = notes_sem_load_matrix( + self.formsemestre + ) + self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) + self.modimpl_coefs = np.array( + [m.module.coefficient for m in self.formsemestre.modimpls] + ) + self.modimpl_idx = {m.id: i for i, m in enumerate(self.formsemestre.modimpls)} + "l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]" + + self.etud_moy_gen, self.etud_moy_ue = moy_ue.compute_ue_moys_classic( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + ) + self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + + 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") + + +def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: + """Calcule la matrice des notes du semestre + (charge toutes les notes, calcule les moyenne des modules + et assemble la matrice) + Resultat: + sem_matrix : 2d-array (etuds x modimpls) + modimpls_results dict { modimpl.id : ModuleImplResultsClassic } + """ + modimpls_results = {} + modimpls_notes = [] + for modimpl in formsemestre.modimpls: + mod_results = moy_mod.ModuleImplResultsClassic(modimpl) + etuds_moy_module = mod_results.compute_module_moy() + modimpls_results[modimpl.id] = mod_results + modimpls_notes.append(etuds_moy_module) + return ( + notes_sem_assemble_matrix(modimpls_notes), + modimpls_results, + ) + + +def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray: + """Réuni les notes moyennes des modules du semestre en une matrice + + modimpls_notes : liste des moyennes de module + (Series rendus par compute_module_moy, index: etud) + Resultat: ndarray (etud x module) + """ + modimpls_notes_arr = [s.values for s in modimpls_notes] + modimpls_notes = np.stack(modimpls_notes_arr) + # passe de (mod x etud) à (etud x mod) + return modimpls_notes.T diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py new file mode 100644 index 000000000..5eccd3253 --- /dev/null +++ b/app/comp/res_sem.py @@ -0,0 +1,337 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +from collections import defaultdict +from functools import cached_property +import numpy as np +import pandas as pd +from app.comp.aux import StatsMoyenne +from app.models import FormSemestre, ModuleImpl +from app.scodoc import sco_utils as scu +from app.scodoc.sco_cache import ResultatsSemestreCache +from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF + +# Il faut bien distinguer +# - ce qui est caché de façon persistente (via redis): +# ce sont les attributs listés dans `_cached_attrs` +# le stockage et l'invalidation sont gérés dans sco_cache.py +# +# - les valeurs cachées durant le temps d'une requête +# (durée de vie de l'instance de ResultatsSemestre) +# qui sont notamment les attributs décorés par `@cached_property`` +# +class ResultatsSemestre: + _cached_attrs = ( + "etud_moy_gen_ranks", + "etud_moy_gen", + "etud_moy_ue", + "modimpl_inscr_df", + "modimpls_results", + ) + + def __init__(self, formsemestre: FormSemestre): + self.formsemestre = formsemestre + # BUT ou standard ? (apc == "approche par compétences") + self.is_apc = formsemestre.formation.is_apc() + # Attributs "virtuels", définis pas les sous-classes + # ResultatsSemestreBUT ou ResultatsSemestreStd + self.etud_moy_ue = {} + self.etud_moy_gen = {} + self.etud_moy_gen_ranks = {} + # TODO + + def load_cached(self) -> bool: + "Load cached dataframes, returns False si pas en cache" + data = ResultatsSemestreCache.get(self.formsemestre.id) + if not data: + return False + for attr in self._cached_attrs: + setattr(self, attr, data[attr]) + return True + + def store(self): + "Cache our data" + ResultatsSemestreCache.set( + self.formsemestre.id, + {attr: getattr(self, attr) for attr in self._cached_attrs}, + ) + + 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 + raise NotImplementedError() + + @cached_property + def etuds(self): + "Liste des inscrits au semestre, sans les démissionnaires" + # nb: si la liste des inscrits change, ResultatsSemestre devient invalide + return self.formsemestre.get_inscrits(include_dem=False) + + @cached_property + def etud_index(self): + "dict { etudid : indice dans les inscrits }" + return {e.id: idx for idx, e in enumerate(self.etuds)} + + @cached_property + def ues(self): + "Liste des UE du semestre" + return self.formsemestre.query_ues().all() + + @cached_property + def modimpls(self): + """Liste des modimpls du semestre + - triée par numéro de module en APC + - triée par numéros d'UE/matières/modules pour les formations standard. + """ + modimpls = self.formsemestre.modimpls.all() + if self.is_apc: + modimpls.sort(key=lambda m: (m.module.numero, m.module.code)) + else: + modimpls.sort( + key=lambda m: ( + m.module.ue.numero, + m.module.matiere.numero, + m.module.numero, + m.module.code, + ) + ) + return modimpls + + @cached_property + def ressources(self): + "Liste des ressources du semestre, triées par numéro de module" + return [ + m for m in self.modimpls if m.module.module_type == scu.ModuleType.RESSOURCE + ] + + @cached_property + def saes(self): + "Liste des SAÉs du semestre, triées par numéro de module" + return [m for m in self.modimpls if m.module.module_type == scu.ModuleType.SAE] + + +# Pour raccorder le code des anciens codes qui attendent une NoteTable +class NotesTableCompat(ResultatsSemestre): + """Implementation partielle de NotesTable WIP TODO + + Les méthodes définies dans cette classe sont là + pour conserver la compatibilité abvec les codes anciens et + il n'est pas recommandé de les utiliser dans de nouveaux + développements (API malcommode et peu efficace). + """ + + _cached_attrs = ResultatsSemestre._cached_attrs + () + + def __init__(self, formsemestre: FormSemestre): + super().__init__(formsemestre) + nb_etuds = len(self.etuds) + self.bonus = defaultdict(lambda: 0.0) # XXX TODO + self.ue_rangs = {u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.ues} + self.mod_rangs = { + m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls + } + self.moy_min = "NA" + self.moy_max = "NA" + + def get_etudids(self, sorted=False) -> list[int]: + """Liste des etudids inscrits, incluant les démissionnaires. + Si sorted, triée par moy. générale décroissante + Sinon, triée par ordre alphabetique de NOM + """ + # Note: pour avoir les inscrits non triés, + # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ] + if sorted: + # Tri par moy. generale décroissante + return [x[-1] for x in self.T] + + return [x["etudid"] for x in self.inscrlist] + + @cached_property + def inscrlist(self) -> list[dict]: # utilisé par PE seulement + """Liste de dict etud, avec démissionnaires + classée dans l'ordre alphabétique de noms. + """ + etuds = self.formsemestre.get_inscrits(include_dem=True) + etuds.sort(key=lambda e: e.sort_key) + return [e.to_dict_scodoc7() for e in etuds] + + @cached_property + def stats_moy_gen(self): + """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() + """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.ues: + if filter_sport and ue.type == UE_SPORT: + continue + d = ue.to_dict() + d.update(StatsMoyenne(self.etud_moy_ue[ue.id]).to_dict()) + ues.append(d) + return ues + + def get_modimpls_dict(self, ue_id=None): + """Liste des modules pour une UE (ou toutes si ue_id==None), + triés par numéros (selon le type de formation) + """ + if ue_id is None: + return [m.to_dict() for m in self.modimpls] + else: + return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] + + def get_etud_decision_sem(self, etudid: int) -> dict: + """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. + { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } + Si état défaillant, force le code a DEF + """ + if self.get_etud_etat(etudid) == DEF: + return { + "code": DEF, + "assidu": False, + "event_date": "", + "compense_formsemestre_id": None, + } + else: + return { + "code": ATT, # XXX TODO + "assidu": True, # XXX TODO + "event_date": "", + "compense_formsemestre_id": None, + } + + def get_etud_etat(self, etudid: int) -> str: + "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" + ins = self.formsemestre.etuds_inscriptions.get(etudid, None) + if ins is None: + return "" + return ins.etat + + def get_etud_moy_gen(self, etudid): # -> float | str + """Moyenne générale de cet etudiant dans ce semestre. + Prend en compte les UE capitalisées. (TODO) + Si apc, moyenne indicative. + Si pas de notes: 'NA' + """ + return self.etud_moy_gen[etudid] + + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: + """La moyenne de l'étudiant dans le moduleimpl + En APC, il s'agira d'une moyenne indicative sans valeur. + Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) + """ + raise NotImplementedError() # virtual method + + def get_etud_ue_status(self, etudid: int, ue_id: int): + return { + "cur_moy_ue": self.etud_moy_ue[ue_id][etudid], + "is_capitalized": False, # XXX TODO + } + + def get_etud_rang(self, etudid: int): + return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX + + def get_etud_rang_group(self, etudid: int, group_id: int): + return (None, 0) # XXX unimplemented TODO + + def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: + "liste des évaluations valides dans un module" + modimpl = ModuleImpl.query.get(moduleimpl_id) + evals_results = [] + for e in modimpl.evaluations: + d = e.to_dict() + d["heure_debut"] = e.heure_debut # datetime.time + d["heure_fin"] = e.heure_fin + d["jour"] = e.jour # datetime + d["notes"] = { + etud.id: { + "etudid": etud.id, + "value": self.results.modimpls_evals_notes[e.moduleimpl_id][e.id][ + etud.id + ], + } + for etud in self.results.etuds + } + evals_results.append(d) + return evals_results + + def get_moduleimpls_attente(self): + return [] # XXX TODO + + def get_mod_stats(self, moduleimpl_id): + return { + "moy": "-", + "max": "-", + "min": "-", + "nb_notes": "-", + "nb_missing": "-", + "nb_valid_evals": "-", + } + + def get_nom_short(self, etudid): + "formatte nom d'un etud (pour table recap)" + etud = self.identdict[etudid] + return ( + (etud["nom_usuel"] or etud["nom"]).upper() + + " " + + etud["prenom"].capitalize()[:2] + + "." + ) + + @cached_property + def T(self): + return self.get_table_moyennes_triees() + + def get_table_moyennes_triees(self) -> list: + """Result: liste de tuples + moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid + """ + table_moyennes = [] + etuds_inscriptions = self.formsemestre.etuds_inscriptions + + for etudid in etuds_inscriptions: + moy_gen = self.etud_moy_gen.get(etudid, False) + if moy_gen is False: + # pas de moyenne: démissionnaire ou def + t = ["-"] + ["0.00"] * len(self.ues) + ["NI"] * len(self.modimpls) + else: + moy_ues = self.etud_moy_ue.loc[etudid] + t = [moy_gen] + list(moy_ues) + # TODO UE capitalisées: ne pas afficher moyennes modules + for modimpl in self.modimpls: + val = self.get_etud_mod_moy(modimpl.id, etudid) + t.append(val) + t.append(etudid) + table_moyennes.append(t) + # tri par moyennes décroissantes, + # en laissant les démissionnaires à la fin, par ordre alphabetique + etuds = [ins.etud for ins in etuds_inscriptions.values()] + etuds.sort(key=lambda e: e.sort_key) + self._rang_alpha = {e.id: i for i, e in enumerate(etuds)} + table_moyennes.sort(key=self._row_key) + return table_moyennes + + def _row_key(self, x): + """clé de tri par moyennes décroissantes, + en laissant les demissionnaires à la fin, par ordre alphabetique. + (moy_gen, rang_alpha) + """ + try: + moy = -float(x[0]) + except (ValueError, TypeError): + moy = 1000.0 + return (moy, self._rang_alpha[x[-1]]) + + @cached_property + def identdict(self) -> dict: + """{ etudid : etud_dict } pour tous les inscrits au semestre""" + return { + ins.etud.id: ins.etud.to_dict_scodoc7() + for ins in self.formsemestre.inscriptions + } diff --git a/app/entreprises/__init__.py b/app/entreprises/__init__.py new file mode 100644 index 000000000..44968ffb1 --- /dev/null +++ b/app/entreprises/__init__.py @@ -0,0 +1,29 @@ +"""entreprises.__init__ +""" + +from flask import Blueprint +from app.scodoc import sco_etud +from app.auth.models import User + +bp = Blueprint("entreprises", __name__) + +LOGS_LEN = 10 + + +@bp.app_template_filter() +def format_prenom(s): + return sco_etud.format_prenom(s) + + +@bp.app_template_filter() +def format_nom(s): + return sco_etud.format_nom(s) + + +@bp.app_template_filter() +def get_nomcomplet(s): + user = User.query.filter_by(user_name=s).first() + return user.get_nomcomplet() + + +from app.entreprises import routes diff --git a/app/entreprises/forms.py b/app/entreprises/forms.py new file mode 100644 index 000000000..1f76732dc --- /dev/null +++ b/app/entreprises/forms.py @@ -0,0 +1,289 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# +############################################################################## + +import re +import requests + +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, FileRequired +from markupsafe import Markup +from sqlalchemy import text +from wtforms import StringField, SubmitField, TextAreaField, SelectField, HiddenField +from wtforms.fields import EmailField, DateField +from wtforms.validators import ValidationError, DataRequired, Email + +from app.entreprises.models import Entreprise, EntrepriseContact +from app.models import Identite +from app.auth.models import User + +CHAMP_REQUIS = "Ce champ est requis" + + +class EntrepriseCreationForm(FlaskForm): + siret = StringField( + "SIRET", + validators=[DataRequired(message=CHAMP_REQUIS)], + render_kw={"placeholder": "Numéro composé de 14 chiffres", "maxlength": "14"}, + ) + nom_entreprise = StringField( + "Nom de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + adresse = StringField( + "Adresse de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + codepostal = StringField( + "Code postal de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + ville = StringField( + "Ville de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + pays = StringField( + "Pays de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + render_kw={"style": "margin-bottom: 50px;"}, + ) + + nom_contact = StringField( + "Nom du contact", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + prenom_contact = StringField( + "Prénom du contact", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + telephone = StringField( + "Téléphone du contact", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + mail = EmailField( + "Mail du contact", + validators=[ + DataRequired(message=CHAMP_REQUIS), + Email(message="Adresse e-mail invalide"), + ], + ) + poste = StringField("Poste du contact", validators=[]) + service = StringField("Service du contact", validators=[]) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + def validate_siret(self, siret): + siret = siret.data.strip() + if re.match("^\d{14}$", siret) == None: + raise ValidationError("Format incorrect") + req = requests.get( + f"https://entreprise.data.gouv.fr/api/sirene/v1/siret/{siret}" + ) + if req.status_code != 200: + raise ValidationError("SIRET inexistant") + entreprise = Entreprise.query.filter_by(siret=siret).first() + if entreprise is not None: + lien = f'ici' + raise ValidationError( + Markup(f"Entreprise déjà présent, lien vers la fiche : {lien}") + ) + + +class EntrepriseModificationForm(FlaskForm): + siret = StringField("SIRET", validators=[], render_kw={"disabled": ""}) + nom = StringField( + "Nom de l'entreprise", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + adresse = StringField("Adresse", validators=[DataRequired(message=CHAMP_REQUIS)]) + codepostal = StringField( + "Code postal", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + ville = StringField("Ville", validators=[DataRequired(message=CHAMP_REQUIS)]) + pays = StringField("Pays", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + + +class OffreCreationForm(FlaskForm): + intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) + description = TextAreaField( + "Description", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + type_offre = SelectField( + "Type de l'offre", + choices=[("Stage"), ("Alternance")], + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + missions = TextAreaField( + "Missions", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + +class OffreModificationForm(FlaskForm): + intitule = StringField("Intitulé", validators=[DataRequired(message=CHAMP_REQUIS)]) + description = TextAreaField( + "Description", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + type_offre = SelectField( + "Type de l'offre", + choices=[("Stage"), ("Alternance")], + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + missions = TextAreaField( + "Missions", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + duree = StringField("Durée", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + + +class ContactCreationForm(FlaskForm): + hidden_entreprise_id = HiddenField() + nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) + prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) + telephone = StringField( + "Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + mail = EmailField( + "Mail", + validators=[ + DataRequired(message=CHAMP_REQUIS), + Email(message="Adresse e-mail invalide"), + ], + ) + poste = StringField("Poste", validators=[]) + service = StringField("Service", validators=[]) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + def validate(self): + rv = FlaskForm.validate(self) + if not rv: + return False + + contact = EntrepriseContact.query.filter_by( + entreprise_id=self.hidden_entreprise_id.data, + nom=self.nom.data, + prenom=self.prenom.data, + ).first() + + if contact is not None: + self.nom.errors.append("Ce contact existe déjà (même nom et prénom)") + self.prenom.errors.append("") + return False + + return True + + +class ContactModificationForm(FlaskForm): + nom = StringField("Nom", validators=[DataRequired(message=CHAMP_REQUIS)]) + prenom = StringField("Prénom", validators=[DataRequired(message=CHAMP_REQUIS)]) + telephone = StringField( + "Téléphone", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + mail = EmailField( + "Mail", + validators=[ + DataRequired(message=CHAMP_REQUIS), + Email(message="Adresse e-mail invalide"), + ], + ) + poste = StringField("Poste", validators=[]) + service = StringField("Service", validators=[]) + submit = SubmitField("Modifier", render_kw={"style": "margin-bottom: 10px;"}) + + +class HistoriqueCreationForm(FlaskForm): + etudiant = StringField( + "Étudiant", + validators=[DataRequired(message=CHAMP_REQUIS)], + render_kw={"placeholder": "Tapez le nom de l'étudiant puis selectionnez"}, + ) + type_offre = SelectField( + "Type de l'offre", + choices=[("Stage"), ("Alternance")], + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + date_debut = DateField( + "Date début", validators=[DataRequired(message=CHAMP_REQUIS)] + ) + date_fin = DateField("Date fin", validators=[DataRequired(message=CHAMP_REQUIS)]) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + def validate(self): + rv = FlaskForm.validate(self) + if not rv: + return False + + if self.date_debut.data > self.date_fin.data: + self.date_debut.errors.append("Les dates sont incompatibles") + self.date_fin.errors.append("Les dates sont incompatibles") + return False + return True + + def validate_etudiant(self, etudiant): + etudiant_data = etudiant.data.upper().strip() + stm = text( + "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" + ) + etudiant = ( + Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first() + ) + if etudiant is None: + raise ValidationError("Champ incorrect (selectionnez dans la liste)") + + +class EnvoiOffreForm(FlaskForm): + responsable = StringField( + "Responsable de formation", + validators=[DataRequired(message=CHAMP_REQUIS)], + ) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + def validate_responsable(self, responsable): + responsable_data = responsable.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" + ) + responsable = ( + User.query.from_statement(stm) + .params(responsable_data=responsable_data) + .first() + ) + if responsable is None: + raise ValidationError("Champ incorrect (selectionnez dans la liste)") + + +class AjoutFichierForm(FlaskForm): + fichier = FileField( + "Fichier", + validators=[ + FileRequired(message=CHAMP_REQUIS), + FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), + ], + ) + submit = SubmitField("Envoyer", render_kw={"style": "margin-bottom: 10px;"}) + + +class SuppressionConfirmationForm(FlaskForm): + submit = SubmitField("Supprimer", render_kw={"style": "margin-bottom: 10px;"}) diff --git a/app/entreprises/models.py b/app/entreprises/models.py new file mode 100644 index 000000000..b0cd25de7 --- /dev/null +++ b/app/entreprises/models.py @@ -0,0 +1,102 @@ +from app import db + + +class Entreprise(db.Model): + __tablename__ = "entreprises" + id = db.Column(db.Integer, primary_key=True) + siret = db.Column(db.Text) + nom = db.Column(db.Text) + adresse = db.Column(db.Text) + codepostal = db.Column(db.Text) + ville = db.Column(db.Text) + pays = db.Column(db.Text) + contacts = db.relationship( + "EntrepriseContact", + backref="entreprise", + lazy="dynamic", + cascade="all, delete-orphan", + ) + offres = db.relationship( + "EntrepriseOffre", + backref="entreprise", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + def to_dict(self): + return { + "siret": self.siret, + "nom": self.nom, + "adresse": self.adresse, + "codepostal": self.codepostal, + "ville": self.ville, + "pays": self.pays, + } + + +class EntrepriseContact(db.Model): + __tablename__ = "entreprise_contact" + id = db.Column(db.Integer, primary_key=True) + entreprise_id = db.Column( + db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") + ) + nom = db.Column(db.Text) + prenom = db.Column(db.Text) + telephone = db.Column(db.Text) + mail = db.Column(db.Text) + poste = db.Column(db.Text) + service = db.Column(db.Text) + + def to_dict(self): + return { + "nom": self.nom, + "prenom": self.prenom, + "telephone": self.telephone, + "mail": self.mail, + "poste": self.poste, + "service": self.service, + } + + +class EntrepriseOffre(db.Model): + __tablename__ = "entreprise_offre" + id = db.Column(db.Integer, primary_key=True) + entreprise_id = db.Column( + db.Integer, db.ForeignKey("entreprises.id", ondelete="cascade") + ) + date_ajout = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + intitule = db.Column(db.Text) + description = db.Column(db.Text) + type_offre = db.Column(db.Text) + missions = db.Column(db.Text) + duree = db.Column(db.Text) + + +class EntrepriseLog(db.Model): + __tablename__ = "entreprise_log" + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + authenticated_user = db.Column(db.Text) + object = db.Column(db.Integer) + text = db.Column(db.Text) + + +class EntrepriseEtudiant(db.Model): + __tablename__ = "entreprise_etudiant" + id = db.Column(db.Integer, primary_key=True) + entreprise_id = db.Column(db.Integer, db.ForeignKey("entreprises.id")) + etudid = db.Column(db.Integer) + type_offre = db.Column(db.Text) + date_debut = db.Column(db.Date) + date_fin = db.Column(db.Date) + formation_text = db.Column(db.Text) + formation_scodoc = db.Column(db.Integer) + + +class EntrepriseEnvoiOffre(db.Model): + __tablename__ = "entreprise_envoi_offre" + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) + receiver_id = db.Column(db.Integer, db.ForeignKey("user.id")) + offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) + date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py new file mode 100644 index 000000000..590dd313c --- /dev/null +++ b/app/entreprises/routes.py @@ -0,0 +1,630 @@ +import os +from config import Config +from datetime import datetime +import glob +import shutil + +from flask import render_template, redirect, url_for, request, flash, send_file, abort +from flask.json import jsonify +from flask_login import current_user + +from app.decorators import permission_required + +from app.entreprises import LOGS_LEN +from app.entreprises.forms import ( + EntrepriseCreationForm, + EntrepriseModificationForm, + SuppressionConfirmationForm, + OffreCreationForm, + OffreModificationForm, + ContactCreationForm, + ContactModificationForm, + HistoriqueCreationForm, + EnvoiOffreForm, + AjoutFichierForm, +) +from app.entreprises import bp +from app.entreprises.models import ( + Entreprise, + EntrepriseOffre, + EntrepriseContact, + EntrepriseLog, + EntrepriseEtudiant, + EntrepriseEnvoiOffre, +) +from app.models import Identite +from app.auth.models import User +from app.scodoc.sco_permissions import Permission +from app.scodoc import sco_etud, sco_excel +import app.scodoc.sco_utils as scu + +from app import db +from sqlalchemy import text +from werkzeug.utils import secure_filename + + +@bp.route("/", methods=["GET"]) +def index(): + entreprises = Entreprise.query.all() + logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).limit(LOGS_LEN).all() + return render_template( + "entreprises/entreprises.html", + title=("Entreprises"), + entreprises=entreprises, + logs=logs, + ) + + +@bp.route("/contacts", methods=["GET"]) +def contacts(): + contacts = ( + db.session.query(EntrepriseContact, Entreprise) + .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) + .all() + ) + logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).limit(LOGS_LEN).all() + return render_template( + "entreprises/contacts.html", title=("Contacts"), contacts=contacts, logs=logs + ) + + +@bp.route("/fiche_entreprise/", methods=["GET"]) +def fiche_entreprise(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + offres = entreprise.offres + offres_with_files = [] + for offre in offres: + files = [] + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre.entreprise_id}", + f"{offre.id}", + ) + if os.path.exists(path): + for dir in glob.glob( + f"{path}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]" + ): + for file in glob.glob(f"{dir}/*"): + file = [os.path.basename(dir), os.path.basename(file)] + files.append(file) + offres_with_files.append([offre, files]) + contacts = entreprise.contacts + logs = ( + EntrepriseLog.query.order_by(EntrepriseLog.date.desc()) + .filter_by(object=id) + .limit(LOGS_LEN) + .all() + ) + historique = ( + db.session.query(EntrepriseEtudiant, Identite) + .order_by(EntrepriseEtudiant.date_debut.desc()) + .filter(EntrepriseEtudiant.entreprise_id == id) + .join(Identite, Identite.id == EntrepriseEtudiant.etudid) + .all() + ) + return render_template( + "entreprises/fiche_entreprise.html", + title=("Fiche entreprise"), + entreprise=entreprise, + contacts=contacts, + offres=offres_with_files, + logs=logs, + historique=historique, + ) + + +@bp.route("/offres", methods=["GET"]) +def offres(): + offres_recus = ( + db.session.query(EntrepriseEnvoiOffre, EntrepriseOffre) + .filter(EntrepriseEnvoiOffre.receiver_id == current_user.id) + .join(EntrepriseOffre, EntrepriseOffre.id == EntrepriseEnvoiOffre.offre_id) + .all() + ) + return render_template( + "entreprises/offres.html", title=("Offres"), offres_recus=offres_recus + ) + + +@bp.route("/add_entreprise", methods=["GET", "POST"]) +def add_entreprise(): + form = EntrepriseCreationForm() + if form.validate_on_submit(): + entreprise = Entreprise( + nom=form.nom_entreprise.data.strip(), + siret=form.siret.data.strip(), + adresse=form.adresse.data.strip(), + codepostal=form.codepostal.data.strip(), + ville=form.ville.data.strip(), + pays=form.pays.data.strip(), + ) + db.session.add(entreprise) + db.session.commit() + db.session.refresh(entreprise) + contact = EntrepriseContact( + entreprise_id=entreprise.id, + nom=form.nom_contact.data.strip(), + prenom=form.prenom_contact.data.strip(), + telephone=form.telephone.data.strip(), + mail=form.mail.data.strip(), + poste=form.poste.data.strip(), + service=form.service.data.strip(), + ) + db.session.add(contact) + nom_entreprise = f"{entreprise.nom}" + log = EntrepriseLog( + authenticated_user=current_user.user_name, + text=f"{nom_entreprise} - Création de la fiche entreprise ({entreprise.nom}) avec un contact", + ) + db.session.add(log) + db.session.commit() + flash("L'entreprise a été ajouté à la liste.") + return redirect(url_for("entreprises.index")) + return render_template( + "entreprises/ajout_entreprise.html", + title=("Ajout entreprise + contact"), + form=form, + ) + + +@bp.route("/edit_entreprise/", methods=["GET", "POST"]) +def edit_entreprise(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + form = EntrepriseModificationForm() + if form.validate_on_submit(): + nom_entreprise = f"{form.nom.data.strip()}" + if entreprise.nom != form.nom.data.strip(): + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"{nom_entreprise} - Modification du nom (ancien nom : {entreprise.nom})", + ) + entreprise.nom = form.nom.data.strip() + db.session.add(log) + if entreprise.adresse != form.adresse.data.strip(): + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"{nom_entreprise} - Modification de l'adresse (ancienne adresse : {entreprise.adresse})", + ) + entreprise.adresse = form.adresse.data.strip() + db.session.add(log) + if entreprise.codepostal != form.codepostal.data.strip(): + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"{nom_entreprise} - Modification du code postal (ancien code postal : {entreprise.codepostal})", + ) + entreprise.codepostal = form.codepostal.data.strip() + db.session.add(log) + if entreprise.ville != form.ville.data.strip(): + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"{nom_entreprise} - Modification de la ville (ancienne ville : {entreprise.ville})", + ) + entreprise.ville = form.ville.data.strip() + db.session.add(log) + if entreprise.pays != form.pays.data.strip(): + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"{nom_entreprise} - Modification du pays (ancien pays : {entreprise.pays})", + ) + entreprise.pays = form.pays.data.strip() + db.session.add(log) + db.session.commit() + flash("L'entreprise a été modifié.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + elif request.method == "GET": + form.siret.data = entreprise.siret + form.nom.data = entreprise.nom + form.adresse.data = entreprise.adresse + form.codepostal.data = entreprise.codepostal + form.ville.data = entreprise.ville + form.pays.data = entreprise.pays + return render_template( + "entreprises/form.html", title=("Modification entreprise"), form=form + ) + + +@bp.route("/delete_entreprise/", methods=["GET", "POST"]) +def delete_entreprise(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + db.session.delete(entreprise) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text=f"Suppression de la fiche entreprise ({entreprise.nom})", + ) + db.session.add(log) + db.session.commit() + flash("L'entreprise a été supprimé de la liste.") + return redirect(url_for("entreprises.index")) + return render_template( + "entreprises/delete_confirmation.html", + title=("Supression entreprise"), + form=form, + ) + + +@bp.route("/add_offre/", methods=["GET", "POST"]) +def add_offre(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + form = OffreCreationForm() + if form.validate_on_submit(): + offre = EntrepriseOffre( + entreprise_id=entreprise.id, + intitule=form.intitule.data.strip(), + description=form.description.data.strip(), + type_offre=form.type_offre.data.strip(), + missions=form.missions.data.strip(), + duree=form.duree.data.strip(), + ) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text="Création d'une offre", + ) + db.session.add(offre) + db.session.add(log) + db.session.commit() + flash("L'offre a été ajouté à la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + return render_template("entreprises/form.html", title=("Ajout offre"), form=form) + + +@bp.route("/edit_offre/", methods=["GET", "POST"]) +def edit_offre(id): + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + form = OffreModificationForm() + if form.validate_on_submit(): + offre.intitule = form.intitule.data.strip() + offre.description = form.description.data.strip() + offre.type_offre = form.type_offre.data.strip() + offre.missions = form.missions.data.strip() + offre.duree = form.duree.data.strip() + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=offre.entreprise_id, + text="Modification d'une offre", + ) + db.session.add(log) + db.session.commit() + flash("L'offre a été modifié.") + return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise.id)) + elif request.method == "GET": + form.intitule.data = offre.intitule + form.description.data = offre.description + form.type_offre.data = offre.type_offre + form.missions.data = offre.missions + form.duree.data = offre.duree + return render_template( + "entreprises/form.html", title=("Modification offre"), form=form + ) + + +@bp.route("/delete_offre/", methods=["GET", "POST"]) +def delete_offre(id): + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + entreprise_id = offre.entreprise.id + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + db.session.delete(offre) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=offre.entreprise_id, + text="Suppression d'une offre", + ) + db.session.add(log) + db.session.commit() + flash("L'offre a été supprimé de la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise_id)) + return render_template( + "entreprises/delete_confirmation.html", title=("Supression offre"), form=form + ) + + +@bp.route("/add_contact/", methods=["GET", "POST"]) +def add_contact(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + form = ContactCreationForm(hidden_entreprise_id=entreprise.id) + if form.validate_on_submit(): + contact = EntrepriseContact( + entreprise_id=entreprise.id, + nom=form.nom.data.strip(), + prenom=form.prenom.data.strip(), + telephone=form.telephone.data.strip(), + mail=form.mail.data.strip(), + poste=form.poste.data.strip(), + service=form.service.data.strip(), + ) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=entreprise.id, + text="Création d'un contact", + ) + db.session.add(log) + db.session.add(contact) + db.session.commit() + flash("Le contact a été ajouté à la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + return render_template("entreprises/form.html", title=("Ajout contact"), form=form) + + +@bp.route("/edit_contact/", methods=["GET", "POST"]) +def edit_contact(id): + contact = EntrepriseContact.query.filter_by(id=id).first_or_404() + form = ContactModificationForm() + if form.validate_on_submit(): + contact.nom = form.nom.data.strip() + contact.prenom = form.prenom.data.strip() + contact.telephone = form.telephone.data.strip() + contact.mail = form.mail.data.strip() + contact.poste = form.poste.data.strip() + contact.service = form.service.data.strip() + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=contact.entreprise_id, + text="Modification d'un contact", + ) + db.session.add(log) + db.session.commit() + flash("Le contact a été modifié.") + return redirect( + url_for("entreprises.fiche_entreprise", id=contact.entreprise.id) + ) + elif request.method == "GET": + form.nom.data = contact.nom + form.prenom.data = contact.prenom + form.telephone.data = contact.telephone + form.mail.data = contact.mail + form.poste.data = contact.poste + form.service.data = contact.service + return render_template( + "entreprises/form.html", title=("Modification contact"), form=form + ) + + +@bp.route("/delete_contact/", methods=["GET", "POST"]) +def delete_contact(id): + contact = EntrepriseContact.query.filter_by(id=id).first_or_404() + entreprise_id = contact.entreprise.id + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + contact_count = EntrepriseContact.query.filter_by( + entreprise_id=contact.entreprise.id + ).count() + if contact_count == 1: + flash( + "Le contact n'a pas été supprimé de la fiche entreprise. (1 contact minimum)" + ) + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise_id)) + else: + db.session.delete(contact) + log = EntrepriseLog( + authenticated_user=current_user.user_name, + object=contact.entreprise_id, + text="Suppression d'un contact", + ) + db.session.add(log) + db.session.commit() + flash("Le contact a été supprimé de la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise_id)) + return render_template( + "entreprises/delete_confirmation.html", title=("Supression contact"), form=form + ) + + +@bp.route("/add_historique/", methods=["GET", "POST"]) +def add_historique(id): + entreprise = Entreprise.query.filter_by(id=id).first_or_404() + form = HistoriqueCreationForm() + if form.validate_on_submit(): + etudiant_nomcomplet = form.etudiant.data.upper().strip() + stm = text( + "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" + ) + etudiant = ( + Identite.query.from_statement(stm) + .params(nom_prenom=etudiant_nomcomplet) + .first() + ) + formation = etudiant.inscription_courante_date( + form.date_debut.data, form.date_fin.data + ) + historique = EntrepriseEtudiant( + entreprise_id=entreprise.id, + etudid=etudiant.id, + type_offre=form.type_offre.data.strip(), + date_debut=form.date_debut.data, + date_fin=form.date_fin.data, + formation_text=formation.formsemestre.titre if formation else None, + formation_scodoc=formation.formsemestre.formsemestre_id + if formation + else None, + ) + db.session.add(historique) + db.session.commit() + flash("L'étudiant a été ajouté sur la fiche entreprise.") + return redirect(url_for("entreprises.fiche_entreprise", id=entreprise.id)) + return render_template( + "entreprises/ajout_historique.html", title=("Ajout historique"), form=form + ) + + +@bp.route("/envoyer_offre/", methods=["GET", "POST"]) +def envoyer_offre(id): + offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() + form = EnvoiOffreForm() + if form.validate_on_submit(): + responsable_data = form.responsable.data.upper().strip() + stm = text( + "SELECT id, UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')')) FROM \"user\" WHERE UPPER(CONCAT(nom, ' ', prenom, ' ', '(', user_name, ')'))=:responsable_data" + ) + responsable = ( + User.query.from_statement(stm) + .params(responsable_data=responsable_data) + .first() + ) + envoi_offre = EntrepriseEnvoiOffre( + sender_id=current_user.id, receiver_id=responsable.id, offre_id=offre.id + ) + db.session.add(envoi_offre) + db.session.commit() + flash(f"L'offre a été envoyé à {responsable.get_nomplogin()}.") + return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) + return render_template( + "entreprises/envoi_offre_form.html", title=("Envoyer une offre"), form=form + ) + + +@bp.route("/etudiants") +def json_etudiants(): + term = request.args.get("term").strip() + etudiants = Identite.query.filter(Identite.nom.ilike(f"%{term}%")).all() + list = [] + content = {} + for etudiant in etudiants: + value = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}" + if etudiant.inscription_courante() is not None: + content = { + "id": f"{etudiant.id}", + "value": value, + "info": f"{etudiant.inscription_courante().formsemestre.titre}", + } + else: + content = {"id": f"{etudiant.id}", "value": value} + list.append(content) + content = {} + return jsonify(results=list) + + +@bp.route("/responsables") +def json_responsables(): + term = request.args.get("term").strip() + responsables = User.query.filter( + User.nom.ilike(f"%{term}%"), User.nom.is_not(None), User.prenom.is_not(None) + ).all() + list = [] + content = {} + for responsable in responsables: + value = f"{responsable.get_nomplogin()}" + content = {"id": f"{responsable.id}", "value": value, "info": ""} + list.append(content) + content = {} + return jsonify(results=list) + + +@bp.route("/export_entreprises") +def export_entreprises(): + entreprises = Entreprise.query.all() + if entreprises: + keys = ["siret", "nom", "adresse", "ville", "codepostal", "pays"] + titles = keys[:] + L = [ + [entreprise.to_dict().get(k, "") for k in keys] + for entreprise in entreprises + ] + title = "entreprises" + xlsx = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) + filename = title + return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + else: + abort(404) + + +@bp.route("/export_contacts") +def export_contacts(): + contacts = EntrepriseContact.query.all() + if contacts: + keys = ["nom", "prenom", "telephone", "mail", "poste", "service"] + titles = keys[:] + L = [[contact.to_dict().get(k, "") for k in keys] for contact in contacts] + title = "contacts" + xlsx = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) + filename = title + return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + else: + abort(404) + + +@bp.route( + "/get_offre_file////" +) +def get_offre_file(entreprise_id, offre_id, filedir, filename): + if os.path.isfile( + os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{entreprise_id}", + f"{offre_id}", + f"{filedir}", + f"{filename}", + ) + ): + return send_file( + os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{entreprise_id}", + f"{offre_id}", + f"{filedir}", + f"{filename}", + ), + as_attachment=True, + ) + else: + abort(404) + + +@bp.route("/add_offre_file/", methods=["GET", "POST"]) +def add_offre_file(offre_id): + offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() + form = AjoutFichierForm() + if form.validate_on_submit(): + date = f"{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}" + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre.entreprise_id}", + f"{offre.id}", + f"{date}", + ) + os.makedirs(path) + file = form.fichier.data + filename = secure_filename(file.filename) + file.save(os.path.join(path, filename)) + flash("Le fichier a été ajouté a l'offre.") + return redirect(url_for("entreprises.fiche_entreprise", id=offre.entreprise_id)) + return render_template( + "entreprises/form.html", title=("Ajout fichier à une offre"), form=form + ) + + +@bp.route("/delete_offre_file//", methods=["GET", "POST"]) +def delete_offre_file(offre_id, filedir): + offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() + form = SuppressionConfirmationForm() + if form.validate_on_submit(): + path = os.path.join( + Config.SCODOC_VAR_DIR, + "entreprises", + f"{offre.entreprise_id}", + f"{offre_id}", + f"{filedir}", + ) + if os.path.isdir(path): + shutil.rmtree(path) + flash("Le fichier relié à l'offre a été supprimé.") + return redirect( + url_for("entreprises.fiche_entreprise", id=offre.entreprise_id) + ) + return render_template( + "entreprises/delete_confirmation.html", + title=("Suppression fichier d'une offre"), + form=form, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 0fee7bc48..642e31873 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -14,12 +14,6 @@ from app.models.raw_sql_init import create_database_functions from app.models.absences import Absence, AbsenceNotification, BilletAbsence from app.models.departements import Departement - -from app.models.entreprises import ( - Entreprise, - EntrepriseCorrespondant, - EntrepriseContact, -) from app.models.etudiants import ( Identite, Adresse, diff --git a/app/models/departements.py b/app/models/departements.py index 0734e35b0..7ed2f4b56 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -19,7 +19,7 @@ class Departement(db.Model): db.Boolean(), nullable=False, default=True, server_default="true" ) # sur page d'accueil - entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement") + # entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement") etudiants = db.relationship("Identite", lazy="dynamic", backref="departement") formations = db.relationship("Formation", lazy="dynamic", backref="departement") formsemestres = db.relationship( diff --git a/app/models/entreprises.py b/app/models/entreprises.py deleted file mode 100644 index c5d05e934..000000000 --- a/app/models/entreprises.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: UTF-8 -* - -"""Gestion des absences -""" - -from app import db - - -class Entreprise(db.Model): - """une entreprise""" - - __tablename__ = "entreprises" - id = db.Column(db.Integer, primary_key=True) - entreprise_id = db.synonym("id") - dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) - nom = db.Column(db.Text) - adresse = db.Column(db.Text) - ville = db.Column(db.Text) - codepostal = db.Column(db.Text) - pays = db.Column(db.Text) - contact_origine = db.Column(db.Text) - secteur = db.Column(db.Text) - note = db.Column(db.Text) - privee = db.Column(db.Text) - localisation = db.Column(db.Text) - # -1 inconnue, 0, 25, 50, 75, 100: - qualite_relation = db.Column(db.Integer) - plus10salaries = db.Column(db.Boolean()) - date_creation = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - - -class EntrepriseCorrespondant(db.Model): - """Personne contact en entreprise""" - - __tablename__ = "entreprise_correspondant" - id = db.Column(db.Integer, primary_key=True) - entreprise_corresp_id = db.synonym("id") - entreprise_id = db.Column(db.Integer, db.ForeignKey("entreprises.id")) - nom = db.Column(db.Text) - prenom = db.Column(db.Text) - civilite = db.Column(db.Text) - fonction = db.Column(db.Text) - phone1 = db.Column(db.Text) - phone2 = db.Column(db.Text) - mobile = db.Column(db.Text) - mail1 = db.Column(db.Text) - mail2 = db.Column(db.Text) - fax = db.Column(db.Text) - note = db.Column(db.Text) - - -class EntrepriseContact(db.Model): - """Evènement (contact) avec une entreprise""" - - __tablename__ = "entreprise_contact" - id = db.Column(db.Integer, primary_key=True) - entreprise_contact_id = db.synonym("id") - date = db.Column(db.DateTime(timezone=True)) - type_contact = db.Column(db.Text) - entreprise_id = db.Column(db.Integer, db.ForeignKey("entreprises.id")) - entreprise_corresp_id = db.Column( - db.Integer, db.ForeignKey("entreprise_correspondant.id") - ) - etudid = db.Column(db.Integer) # sans contrainte pour garder logs après suppression - description = db.Column(db.Text) - enseignant = db.Column(db.Text) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 9c8457fac..42e22e00b 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,11 +4,15 @@ et données rattachées (adresses, annotations, ...) """ -from flask import g, url_for +from functools import cached_property +from flask import abort, url_for +from flask import g, request from app import db from app import models +from app.scodoc import notesdb as ndb + class Identite(db.Model): """étudiant""" @@ -50,14 +54,24 @@ class Identite(db.Model): def __repr__(self): return f"" + @classmethod + def from_request(cls, etudid=None, code_nip=None): + """Etudiant à partir de l'etudid ou du code_nip, soit + passés en argument soit retrouvés directement dans la requête web. + Erreur 404 si inexistant. + """ + args = make_etud_args(etudid=etudid, code_nip=code_nip) + return Identite.query.filter_by(**args).first_or_404() + + @property def civilite_str(self): """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, personnes ne souhaitant pas d'affichage). """ return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] - def nom_disp(self): - "nom à afficher" + def nom_disp(self) -> str: + "Nom à afficher" if self.nom_usuel: return ( (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel @@ -65,10 +79,50 @@ class Identite(db.Model): else: return self.nom + @cached_property + def nomprenom(self, reverse=False) -> str: + """Civilité/nom/prenom pour affichages: "M. Pierre Dupont" + Si reverse, "Dupont Pierre", sans civilité. + """ + nom = self.nom_usuel or self.nom + prenom = self.prenom_str + if reverse: + fields = (nom, prenom) + else: + fields = (self.civilite_str, prenom, nom) + return " ".join([x for x in fields if x]) + + @property + def prenom_str(self): + """Prénom à afficher. Par exemple: "Jean-Christophe" """ + if not self.prenom: + return "" + frags = self.prenom.split() + r = [] + for frag in frags: + fields = frag.split("-") + r.append("-".join([x.lower().capitalize() for x in fields])) + return " ".join(r) + + @cached_property + def sort_key(self) -> tuple: + "clé pour tris par ordre alphabétique" + return (self.nom_usuel or self.nom).lower(), self.prenom.lower() + def get_first_email(self, field="email") -> str: - "le mail associé à la première adrese de l'étudiant, ou None" + "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 + def to_dict_scodoc7(self): + """Représentation dictionnaire, + compatible ScoDoc7 mais sans infos admission + """ + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) + 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""" from app.scodoc import sco_photos @@ -104,6 +158,17 @@ class Identite(db.Model): ] return r[0] if r else None + def inscription_courante_date(self, date_debut, date_fin): + """La première inscription à un formsemestre incluant la + période [date_debut, date_fin] + """ + r = [ + ins + for ins in self.formsemestre_inscriptions + if ins.formsemestre.contient_periode(date_debut, date_fin) + ] + return r[0] if r else None + def etat_inscription(self, formsemestre_id): """etat de l'inscription de cet étudiant au semestre: False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF @@ -117,6 +182,42 @@ class Identite(db.Model): return False +def make_etud_args( + etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True +) -> dict: + """forme args dict pour requete recherche etudiant + On peut specifier etudid + ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine + (dans cet ordre). + + Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine" + """ + args = None + if etudid: + args = {"etudid": etudid} + elif code_nip: + args = {"code_nip": code_nip} + elif use_request: # use form from current request (Flask global) + if request.method == "POST": + vals = request.form + elif request.method == "GET": + vals = request.args + else: + vals = {} + if "etudid" in vals: + args = {"etudid": int(vals["etudid"])} + elif "code_nip" in vals: + args = {"code_nip": str(vals["code_nip"])} + elif "code_ine" in vals: + args = {"code_ine": str(vals["code_ine"])} + if not args: + if abort_404: + abort(404, "pas d'étudiant sélectionné") + elif raise_exc: + raise ValueError("make_etud_args: pas d'étudiant sélectionné !") + return args + + class Adresse(db.Model): """Adresse d'un étudiant (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 450ea2ccf..928dd6093 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -3,6 +3,7 @@ """ScoDoc models: formsemestre """ import datetime +from functools import cached_property import flask_sqlalchemy @@ -84,7 +85,11 @@ class FormSemestre(db.Model): etapes = db.relationship( "FormSemestreEtape", cascade="all,delete", backref="formsemestre" ) - modimpls = db.relationship("ModuleImpl", backref="formsemestre", lazy="dynamic") + modimpls = db.relationship( + "ModuleImpl", + backref="formsemestre", + lazy="dynamic", + ) etuds = db.relationship( "Identite", secondary="notes_formsemestre_inscription", @@ -146,6 +151,13 @@ class FormSemestre(db.Model): today = datetime.date.today() return (self.date_debut <= today) and (today <= self.date_fin) + def contient_periode(self, date_debut, date_fin) -> bool: + """Vrai si l'intervalle [date_debut, date_fin] est + inclus dans le semestre. + (les dates de début et fin sont incluses) + """ + return (self.date_debut <= date_debut) and (date_fin <= self.date_fin) + def est_decale(self): """Vrai si semestre "décalé" c'est à dire semestres impairs commençant entre janvier et juin @@ -240,7 +252,7 @@ class FormSemestre(db.Model): etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) - def get_inscrits(self, include_dem=False) -> list: + def get_inscrits(self, include_dem=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si all, tous les étudiants, avec les démissionnaires. """ @@ -249,6 +261,11 @@ class FormSemestre(db.Model): else: return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + @cached_property + def etuds_inscriptions(self) -> dict: + """Map { etudid : inscription }""" + return {ins.etud.id: ins for ins in self.inscriptions} + # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre notes_formsemestre_responsables = db.Table( diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 172fe0767..fe48555ef 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -50,7 +50,7 @@ class ModuleImpl(db.Model): if evaluations_poids is None: from app.comp import moy_mod - evaluations_poids, _ = moy_mod.df_load_evaluations_poids(self.id) + evaluations_poids, _ = moy_mod.load_evaluations_poids(self.id) df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) return evaluations_poids @@ -69,7 +69,7 @@ class ModuleImpl(db.Model): return True from app.comp import moy_mod - return moy_mod.check_moduleimpl_conformity( + return moy_mod.moduleimpl_is_conforme( self, self.get_evaluations_poids(), self.module.formation.get_module_coefs(self.module.semestre_id), diff --git a/app/pe/pe_tagtable.py b/app/pe/pe_tagtable.py index 03acc4a60..54e5e3955 100644 --- a/app/pe/pe_tagtable.py +++ b/app/pe/pe_tagtable.py @@ -68,7 +68,7 @@ class TableTag(object): self.taglist = [] self.resultats = {} - self.rangs = {} + self.etud_moy_gen_ranks = {} self.statistiques = {} # ***************************************************************************************************************** @@ -117,15 +117,15 @@ class TableTag(object): # ----------------------------------------------------------------------------------------------------------- def get_moy_from_stats(self, tag): - """ Renvoie la moyenne des notes calculées pour d'un tag donné""" + """Renvoie la moyenne des notes calculées pour d'un tag donné""" return self.statistiques[tag][0] if tag in self.statistiques else None def get_min_from_stats(self, tag): - """ Renvoie la plus basse des notes calculées pour d'un tag donné""" + """Renvoie la plus basse des notes calculées pour d'un tag donné""" return self.statistiques[tag][1] if tag in self.statistiques else None def get_max_from_stats(self, tag): - """ Renvoie la plus haute des notes calculées pour d'un tag donné""" + """Renvoie la plus haute des notes calculées pour d'un tag donné""" return self.statistiques[tag][2] if tag in self.statistiques else None # ----------------------------------------------------------------------------------------------------------- @@ -236,7 +236,7 @@ class TableTag(object): return moystr def str_res_d_un_etudiant(self, etudid, delim=";"): - """Renvoie sur une ligne les résultats d'un étudiant à tous les tags (par ordre alphabétique). """ + """Renvoie sur une ligne les résultats d'un étudiant à tous les tags (par ordre alphabétique).""" return delim.join( [self.str_resTag_d_un_etudiant(tag, etudid) for tag in self.get_all_tags()] ) @@ -256,7 +256,7 @@ class TableTag(object): # ----------------------------------------------------------------------- def str_tagtable(self, delim=";", decimal_sep=","): - """Renvoie une chaine de caractère listant toutes les moyennes, les rangs des étudiants pour tous les tags. """ + """Renvoie une chaine de caractère listant toutes les moyennes, les rangs des étudiants pour tous les tags.""" entete = ["etudid", "nom", "prenom"] for tag in self.get_all_tags(): entete += [titre + "_" + tag for titre in ["note", "rang", "nb_inscrit"]] diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index 996feddab..828ea228c 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -25,7 +25,9 @@ # ############################################################################## -"""Calculs sur les notes et cache des resultats +"""Calculs sur les notes et cache des résultats + + Ancien code ScoDoc 7 en cours de rénovation """ from operator import itemgetter @@ -102,7 +104,7 @@ def comp_ranks(T): def get_sem_ues_modimpls(formsemestre_id, modimpls=None): """Get liste des UE du semestre (à partir des moduleimpls) - (utilisé quand on ne peut pas construire nt et faire nt.get_ues()) + (utilisé quand on ne peut pas construire nt et faire nt.get_ues_stat_dict()) """ if modimpls is None: modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) @@ -200,7 +202,7 @@ class NotesTable: self.inscrlist.sort(key=itemgetter("nomp")) # { etudid : rang dans l'ordre alphabetique } - self.rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} + self._rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} self.bonus = scu.DictDefault(defaultvalue=0) # Notes dans les modules { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } } @@ -294,7 +296,7 @@ class NotesTable: for ue in self._ues: is_cap[ue["ue_id"]] = ue_status[ue["ue_id"]]["is_capitalized"] - for modimpl in self.get_modimpls(): + for modimpl in self.get_modimpls_dict(): val = self.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) if is_cap[modimpl["module"]["ue_id"]]: t.append("-c-") @@ -316,7 +318,7 @@ class NotesTable: self.moy_min = self.moy_max = "NA" # calcul rangs (/ moyenne generale) - self.rangs = comp_ranks(T) + self.etud_moy_gen_ranks = comp_ranks(T) self.rangs_groupes = ( {} @@ -364,7 +366,7 @@ class NotesTable: moy = -float(x[0]) except (ValueError, TypeError): moy = 1000.0 - return (moy, self.rang_alpha[x[-1]]) + return (moy, self._rang_alpha[x[-1]]) def get_etudids(self, sorted=False): if sorted: @@ -417,46 +419,17 @@ class NotesTable: else: return ' (%s) ' % etat - def get_ues(self, filter_sport=False, filter_non_inscrit=False, etudid=None): - """liste des ue, ordonnée par numero. - Si filter_non_inscrit, retire les UE dans lesquelles l'etudiant n'est - inscrit à aucun module. + def get_ues_stat_dict(self, filter_sport=False): # was get_ues() + """Liste des UEs, ordonnée par numero. Si filter_sport, retire les UE de type SPORT """ - if not filter_sport and not filter_non_inscrit: + if not filter_sport: return self._ues - - if filter_sport: - ues_src = [ue for ue in self._ues if ue["type"] != UE_SPORT] else: - ues_src = self._ues - if not filter_non_inscrit: - return ues_src - ues = [] - for ue in ues_src: - if self.get_etud_ue_status(etudid, ue["ue_id"])["is_capitalized"]: - # garde toujours les UE capitalisees - has_note = True - else: - has_note = False - # verifie que l'etud. est inscrit a au moins un module de l'UE - # (en fait verifie qu'il a une note) - modimpls = self.get_modimpls(ue["ue_id"]) + return [ue for ue in self._ues if ue["type"] != UE_SPORT] - for modi in modimpls: - moy = self.get_etud_mod_moy(modi["moduleimpl_id"], etudid) - try: - float(moy) - has_note = True - break - except: - pass - if has_note: - ues.append(ue) - return ues - - def get_modimpls(self, ue_id=None): - "liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." + def get_modimpls_dict(self, ue_id=None): + "Liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." if ue_id is None: r = self._modimpls else: @@ -522,7 +495,7 @@ class NotesTable: Les moyennes d'UE ne tiennent pas compte des capitalisations. """ - ues = self.get_ues() + ues = self.get_ues_stat_dict() sum_moy = 0 # la somme des moyennes générales valides nb_moy = 0 # le nombre de moyennes générales valides for ue in ues: @@ -561,9 +534,9 @@ class NotesTable: i = 0 for ue in ues: i += 1 - ue["nb_moy"] = len(ue["_notes"]) - if ue["nb_moy"] > 0: - ue["moy"] = sum(ue["_notes"]) / ue["nb_moy"] + ue["nb_vals"] = len(ue["_notes"]) + if ue["nb_vals"] > 0: + ue["moy"] = sum(ue["_notes"]) / ue["nb_vals"] ue["max"] = max(ue["_notes"]) ue["min"] = min(ue["_notes"]) else: @@ -591,7 +564,7 @@ class NotesTable: Si non inscrit, moy == 'NI' et sum_coefs==0 """ assert ue_id - modimpls = self.get_modimpls(ue_id) + modimpls = self.get_modimpls_dict(ue_id) nb_notes = 0 # dans cette UE sum_notes = 0.0 sum_coefs = 0.0 @@ -767,7 +740,7 @@ class NotesTable: sem_ects_pot_fond = 0.0 sem_ects_pot_pro = 0.0 - for ue in self.get_ues(): + for ue in self.get_ues_stat_dict(): # - On calcule la moyenne d'UE courante: if not block_computation: mu = self.comp_etud_moy_ue(etudid, ue_id=ue["ue_id"], cnx=cnx) @@ -948,7 +921,7 @@ class NotesTable: return infos - def get_etud_moy_gen(self, etudid): + def get_etud_moy_gen(self, etudid): # -> float | str """Moyenne generale de cet etudiant dans ce semestre. Prend en compte les UE capitalisées. Si pas de notes: 'NA' @@ -981,7 +954,7 @@ class NotesTable: return self.T def get_etud_rang(self, etudid) -> str: - return self.rangs.get(etudid, "999") + return self.etud_moy_gen_ranks.get(etudid, "999") def get_etud_rang_group(self, etudid, group_id): """Returns rank of etud in this group and number of etuds in group. @@ -1347,7 +1320,7 @@ class NotesTable: # Rappel des épisodes précédents: T est une liste de liste # Colonnes: 0 moy_gen, moy_ue1, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid - ues = self.get_ues() # incluant le(s) UE de sport + ues = self.get_ues_stat_dict() # incluant le(s) UE de sport for t in self.T: etudid = t[-1] if etudid in results.etud_moy_gen: # evite les démissionnaires @@ -1358,4 +1331,4 @@ class NotesTable: # re-trie selon la nouvelle moyenne générale: self.T.sort(key=self._row_key) # Remplace aussi le rang: - self.rangs = results.etud_moy_gen_ranks + self.etud_moy_gen_ranks = results.etud_moy_gen_ranks diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 00a1870d2..2f108c243 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -32,6 +32,8 @@ import datetime from flask import url_for, g, request, abort +from app import log +from app.models import Identite import app.scodoc.sco_utils as scu from app.scodoc import notesdb as ndb from app.scodoc.scolog import logdb @@ -46,7 +48,6 @@ from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl from app.scodoc import sco_photos from app.scodoc import sco_preferences -from app import log from app.scodoc.sco_exceptions import ScoValueError @@ -71,8 +72,8 @@ def doSignaleAbsence( etudid: etudiant concerné. Si non spécifié, cherche dans les paramètres de la requête courante. """ - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - etudid = etud["etudid"] + etud = Identite.from_request(etudid) + if not moduleimpl_id: moduleimpl_id = None description_abs = description @@ -82,7 +83,7 @@ def doSignaleAbsence( for jour in dates: if demijournee == 2: sco_abs.add_absence( - etudid, + etud.id, jour, False, estjust, @@ -90,7 +91,7 @@ def doSignaleAbsence( moduleimpl_id, ) sco_abs.add_absence( - etudid, + etud.id, jour, True, estjust, @@ -100,7 +101,7 @@ def doSignaleAbsence( nbadded += 2 else: sco_abs.add_absence( - etudid, + etud.id, jour, demijournee, estjust, @@ -113,27 +114,27 @@ def doSignaleAbsence( J = "" else: J = "NON " - M = "" + indication_module = "" if moduleimpl_id and moduleimpl_id != "NULL": mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] formsemestre_id = mod["formsemestre_id"] nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues(etudid=etudid) + ues = nt.get_ues_stat_dict() for ue in ues: - modimpls = nt.get_modimpls(ue_id=ue["ue_id"]) + modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) for modimpl in modimpls: if modimpl["moduleimpl_id"] == moduleimpl_id: - M = "dans le module %s" % modimpl["module"]["code"] + indication_module = "dans le module %s" % modimpl["module"]["code"] H = [ html_sco_header.sco_header( - page_title="Signalement d'une absence pour %(nomprenom)s" % etud, + page_title=f"Signalement d'une absence pour {etud.nomprenom}", ), """

Signalement d'absences

""", ] if dates: H.append( """

Ajout de %d absences %sjustifiées du %s au %s %s

""" - % (nbadded, J, datedebut, datefin, M) + % (nbadded, J, datedebut, datefin, indication_module) ) else: H.append( @@ -142,11 +143,18 @@ def doSignaleAbsence( ) H.append( - """ -
""" - % etud + f""" +
+ """ ) H.append(sco_find_etud.form_search_etud()) H.append(html_sco_header.sco_footer()) @@ -175,7 +183,7 @@ def SignaleAbsenceEtud(): # etudid implied "abs_require_module", formsemestre_id ) nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues(etudid=etudid) + ues = nt.get_ues_stat_dict() if require_module: menu_module = """ +{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/ajout_historique.html b/app/templates/entreprises/ajout_historique.html new file mode 100644 index 000000000..c0a546d59 --- /dev/null +++ b/app/templates/entreprises/ajout_historique.html @@ -0,0 +1,32 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{super()}} + + +{% endblock %} + +{% block app_content %} +

{{ title }}

+
+
+
+ {{ wtf.quick_form(form, novalidate=True) }} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/contacts.html b/app/templates/entreprises/contacts.html new file mode 100644 index 000000000..54d77bd55 --- /dev/null +++ b/app/templates/entreprises/contacts.html @@ -0,0 +1,47 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} + +{% block app_content %} + {% if logs %} +
+

Dernières opérations

+
    + {% for log in logs %} +
  • {{ log.date.strftime('%d %b %Hh%M') }}{{ log.text|safe }} par {{ log.authenticated_user|get_nomcomplet }}
  • + {% endfor %} +
+
+ {% endif %} +
+

Liste des contacts

+ {% if contacts %} +
+ + + + + + + + + {% for contact in contacts %} + + + + + + + + {% endfor %} +
NomPrenomTelephoneMailEntreprise
{{ contact[0].nom }}{{ contact[0].prenom }}{{ contact[0].telephone }}{{ contact[0].mail }}{{ contact[1].nom }}
+ {% else %} +
Aucun contact présent dans la base
+
+ {% endif %} +
+ {% if contacts %} + Exporter la liste des contacts + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/delete_confirmation.html b/app/templates/entreprises/delete_confirmation.html new file mode 100644 index 000000000..4894bca3e --- /dev/null +++ b/app/templates/entreprises/delete_confirmation.html @@ -0,0 +1,15 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

{{ title }}

+
+
Cliquez sur le bouton supprimer pour confirmer votre supression
+
+
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/entreprises.html b/app/templates/entreprises/entreprises.html new file mode 100644 index 000000000..fe790a8d6 --- /dev/null +++ b/app/templates/entreprises/entreprises.html @@ -0,0 +1,63 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} + +{% block app_content %} + {% if logs %} +
+

Dernières opérations

+
    + {% for log in logs %} +
  • {{ log.date.strftime('%d %b %Hh%M') }}{{ log.text|safe }} par {{ log.authenticated_user|get_nomcomplet }}
  • + {% endfor %} +
+
+ {% endif %} +
+

Liste des entreprises

+ {% if entreprises %} +
+ + + + + + + + + + + {% for entreprise in entreprises %} + + + + + + + + + + {% endfor %} +
SIRETNomAdresseCode postalVillePaysAction
{{ entreprise.siret }}{{ entreprise.nom }}{{ entreprise.adresse }}{{ entreprise.codepostal }}{{ entreprise.ville }}{{ entreprise.pays }} + +
+ {% else %} +
Aucune entreprise présent dans la base
+
+
+ {% endif %} +
+ Ajouter une entreprise + {% if entreprises %} + Exporter la liste des entreprises + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/envoi_offre_form.html b/app/templates/entreprises/envoi_offre_form.html new file mode 100644 index 000000000..f67cb4e40 --- /dev/null +++ b/app/templates/entreprises/envoi_offre_form.html @@ -0,0 +1,32 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{super()}} + + +{% endblock %} + +{% block app_content %} +

{{ title }}

+
+
+
+ {{ wtf.quick_form(form, novalidate=True) }} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/fiche_entreprise.html b/app/templates/entreprises/fiche_entreprise.html new file mode 100644 index 000000000..d34531207 --- /dev/null +++ b/app/templates/entreprises/fiche_entreprise.html @@ -0,0 +1,76 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} + +{% block app_content %} +{% if logs %} +
+

Dernières opérations sur cette fiche

+
    + {% for log in logs %} +
  • + {{ log.date.strftime('%d %b %Hh%M') }} + {{ log.text|safe }} par {{ log.authenticated_user|get_nomcomplet }} +
  • + {% endfor %} +
+
+{% endif %} +{% if historique %} +
+

Historique

+
    + {% for data in historique %} +
  • + {{ data[0].date_debut.strftime('%d/%m/%Y') }} - {{ + data[0].date_fin.strftime('%d/%m/%Y') }} + + {{ data[0].type_offre }} réalisé par {{ data[1].nom|format_nom }} {{ data[1].prenom|format_prenom }} en + {{ data[0].formation_text }} + +
  • + {% endfor %} +
+
+{% endif %} +
+

Fiche entreprise - {{ entreprise.nom }} ({{ entreprise.siret }})

+ +
+

+ SIRET : {{ entreprise.siret }}
+ Nom : {{ entreprise.nom }}
+ Adresse : {{ entreprise.adresse }}
+ Code postal : {{ entreprise.codepostal }}
+ Ville : {{ entreprise.ville }}
+ Pays : {{ entreprise.pays }} +

+
+ + {% if contacts %} +
+ {% for contact in contacts %} + Contact {{loop.index}} + {% include 'entreprises/_contact.html' %} + {% endfor %} +
+ {% endif %} + + {% if offres %} +
+ {% for offre in offres %} + Offre {{loop.index}} (ajouté le {{offre[0].date_ajout.strftime('%d/%m/%Y') }}) + {% include 'entreprises/_offre.html' %} + {% endfor %} +
+ {% endif %} + + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/form.html b/app/templates/entreprises/form.html new file mode 100644 index 000000000..066224d8b --- /dev/null +++ b/app/templates/entreprises/form.html @@ -0,0 +1,19 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{super()}} + + +{% endblock %} + +{% block app_content %} +

{{ title }}

+
+
+
+ {{ wtf.quick_form(form, novalidate=True) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/offres.html b/app/templates/entreprises/offres.html new file mode 100644 index 000000000..ff3ab9bdc --- /dev/null +++ b/app/templates/entreprises/offres.html @@ -0,0 +1,29 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} + +{% block app_content %} +
+

{{ title }}

+ {% if offres_recus %} +
+
+ {% for offre in offres_recus %} +
+

+ Date envoi : {{ offre[0].date_envoi.strftime('%d/%m/%Y %H:%M') }}
+ Intitulé : {{ offre[1].intitule }}
+ Description : {{ offre[1].description }}
+ Type de l'offre : {{ offre[1].type_offre }}
+ Missions : {{ offre[1].missions }}
+ Durée : {{ offre[1].duree }}
+

+
+ {% endfor %} +
+
+ {% else %} +
Aucune offre reçue
+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/error_500.html b/app/templates/error_500.html index d986a00da..23a5c6095 100644 --- a/app/templates/error_500.html +++ b/app/templates/error_500.html @@ -1,11 +1,13 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} {% block title %}Une erreur est survenue !{% endblock %} {% block body %}

Une erreur est survenue !

-

Oups... ScoDoc version {{SCOVERSION}} a - un problème, désolé.

+

Oups... ScoDoc version + {{SCOVERSION}} + a un problème, désolé.

{{date}}

Si le problème persiste, contacter l'administrateur de votre site, diff --git a/app/templates/error_access_denied.html b/app/templates/error_access_denied.html index 5a765b761..516361d5c 100644 --- a/app/templates/error_access_denied.html +++ b/app/templates/error_access_denied.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} diff --git a/app/templates/formsemestre_header.html b/app/templates/formsemestre_header.html index 441a32872..aa8464ef4 100644 --- a/app/templates/formsemestre_header.html +++ b/app/templates/formsemestre_header.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {# Description un semestre (barre de menu et infos) #}

diff --git a/app/templates/main/index.html b/app/templates/main/index.html index 6e21ac465..35005c95d 100644 --- a/app/templates/main/index.html +++ b/app/templates/main/index.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} diff --git a/app/templates/pn/form_descr.html b/app/templates/pn/form_descr.html index 347180257..6a0cda4ea 100644 --- a/app/templates/pn/form_descr.html +++ b/app/templates/pn/form_descr.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {# Chapeau description d'une formation #}
diff --git a/app/templates/pn/form_mods.html b/app/templates/pn/form_mods.html index 90faee024..cba283478 100644 --- a/app/templates/pn/form_mods.html +++ b/app/templates/pn/form_mods.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {# Édition liste modules APC (SAÉ ou ressources) #}
diff --git a/app/templates/pn/form_modules_ue_coefs.html b/app/templates/pn/form_modules_ue_coefs.html index c94fe60c1..186a8e7fd 100644 --- a/app/templates/pn/form_modules_ue_coefs.html +++ b/app/templates/pn/form_modules_ue_coefs.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #}

Édition des coefficients des modules vers les UEs

Double-cliquer pour changer une valeur. diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html index 69d698391..cc9812dc5 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {# Édition liste UEs APC #}
Unités d'Enseignement (UEs)
diff --git a/app/templates/pn/ue_infos.html b/app/templates/pn/ue_infos.html index f1757c734..b45c0b9bc 100644 --- a/app/templates/pn/ue_infos.html +++ b/app/templates/pn/ue_infos.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {# Informations sur une UE #} {% extends "sco_page.html" %} diff --git a/app/templates/sco_page.html b/app/templates/sco_page.html index 249bfb39e..e593d9c0c 100644 --- a/app/templates/sco_page.html +++ b/app/templates/sco_page.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'bootstrap/base.html' %} {% block styles %} diff --git a/app/templates/sco_value_error.html b/app/templates/sco_value_error.html index 9b7f3b547..36ed8d206 100644 --- a/app/templates/sco_value_error.html +++ b/app/templates/sco_value_error.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} diff --git a/app/templates/scodoc.html b/app/templates/scodoc.html index 2aaf9e22c..2b8255e51 100644 --- a/app/templates/scodoc.html +++ b/app/templates/scodoc.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} @@ -23,7 +24,7 @@ {% endfor %} {% if current_user.is_administrator() %} -
  • créer un nouveau département
  • +
  • créer un nouveau département
  • {% endif %} diff --git a/app/templates/scolar/photos_import_files.html b/app/templates/scolar/photos_import_files.html index f4bae574a..460d7d7e4 100644 --- a/app/templates/scolar/photos_import_files.html +++ b/app/templates/scolar/photos_import_files.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #} {% extends 'base.html' %} {% block app_content %} diff --git a/app/templates/scolar/photos_import_files.txt b/app/templates/scolar/photos_import_files.txt index d9aab53ee..cb6777b5c 100755 --- a/app/templates/scolar/photos_import_files.txt +++ b/app/templates/scolar/photos_import_files.txt @@ -1,4 +1,4 @@ - +{# -*- mode: jinja-raw -*- #} Importation des photo effectuée {% if ignored_zipfiles %} diff --git a/app/templates/sidebar.html b/app/templates/sidebar.html index ba55e6886..eb5df4afa 100644 --- a/app/templates/sidebar.html +++ b/app/templates/sidebar.html @@ -1,98 +1,95 @@ {# Barre marge gauche ScoDoc #} +{# -*- mode: jinja-html -*- #} \ No newline at end of file diff --git a/app/templates/sidebar_dept.html b/app/templates/sidebar_dept.html index 2e694b438..d8458f51e 100644 --- a/app/templates/sidebar_dept.html +++ b/app/templates/sidebar_dept.html @@ -1,3 +1,4 @@ +{# -*- mode: jinja-html -*- #}

    Dépt. {{ prefs["DeptName"] }} diff --git a/app/views/absences.py b/app/views/absences.py index dcbac37fc..96215c4f5 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -429,18 +429,9 @@ def SignaleAbsenceGrHebdo( ] # modimpls_list = [] - # Initialize with first student - ues = nt.get_ues(etudid=etuds[0]["etudid"]) + ues = nt.get_ues_stat_dict() for ue in ues: - modimpls_list += nt.get_modimpls(ue_id=ue["ue_id"]) - - # Add modules other students are subscribed to - for etud in etuds[1:]: - modimpls_etud = [] - ues = nt.get_ues(etudid=etud["etudid"]) - for ue in ues: - modimpls_etud += nt.get_modimpls(ue_id=ue["ue_id"]) - modimpls_list += [m for m in modimpls_etud if m not in modimpls_list] + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) menu_module = "" for modimpl in modimpls_list: @@ -606,18 +597,9 @@ def SignaleAbsenceGrSemestre( # if etuds: modimpls_list = [] - # Initialize with first student - ues = nt.get_ues(etudid=etuds[0]["etudid"]) + ues = nt.get_ues_stat_dict() for ue in ues: - modimpls_list += nt.get_modimpls(ue_id=ue["ue_id"]) - - # Add modules other students are subscribed to - for etud in etuds[1:]: - modimpls_etud = [] - ues = nt.get_ues(etudid=etud["etudid"]) - for ue in ues: - modimpls_etud += nt.get_modimpls(ue_id=ue["ue_id"]) - modimpls_list += [m for m in modimpls_etud if m not in modimpls_list] + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) menu_module = "" for modimpl in modimpls_list: @@ -750,8 +732,8 @@ def _gen_form_saisie_groupe( if etud["cursem"]: nt = sco_cache.NotesTableCache.get( etud["cursem"]["formsemestre_id"] - ) # > get_ues, get_etud_ue_status - for ue in nt.get_ues(): + ) # > get_ues_stat_dict, get_etud_ue_status + for ue in nt.get_ues_stat_dict(): status = nt.get_etud_ue_status(etudid, ue["ue_id"]) if status["is_capitalized"]: cap.append(ue["acronyme"]) diff --git a/app/views/notes.py b/app/views/notes.py index 4ca10dff6..da404fdaf 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -296,7 +296,7 @@ def formsemestre_bulletinetud( code_nip=str(code_nip) ).first_or_404() if format == "json": - r = bulletin_but.ResultatsSemestreBUT(formsemestre) + r = bulletin_but.BulletinBUT(formsemestre) return jsonify(r.bulletin_etud(etud, formsemestre)) elif format == "html": return render_template( diff --git a/app/views/scolar.py b/app/views/scolar.py index e41183671..6bddb9475 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -41,6 +41,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from wtforms import SubmitField +from app import log from app.decorators import ( scodoc, scodoc7func, @@ -50,12 +51,12 @@ from app.decorators import ( login_required, ) from app.models.etudiants import Identite +from app.models.etudiants import make_etud_args from app.views import scolar_bp as bp import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb -from app import log from app.scodoc.scolog import logdb from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( @@ -455,7 +456,7 @@ def etud_info(etudid=None, format="xml"): if not format in ("xml", "json"): raise ScoValueError("format demandé non supporté par cette fonction.") t0 = time.time() - args = sco_etud.make_etud_args(etudid=etudid) + args = make_etud_args(etudid=etudid) cnx = ndb.GetDBConnexion() etuds = sco_etud.etudident_list(cnx, args) if not etuds: diff --git a/migrations/versions/2dfafee725ae_creation_table_relations_entreprrises.py b/migrations/versions/2dfafee725ae_creation_table_relations_entreprrises.py new file mode 100644 index 000000000..05adc3566 --- /dev/null +++ b/migrations/versions/2dfafee725ae_creation_table_relations_entreprrises.py @@ -0,0 +1,255 @@ +"""creation tables relations entreprises + +Revision ID: f3b62d64efa3 +Revises: 91be8a06d423 +Create Date: 2021-12-24 10:36:27.150085 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "f3b62d64efa3" +down_revision = "91be8a06d423" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "entreprise_log", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "date", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("authenticated_user", sa.Text(), nullable=True), + sa.Column("object", sa.Integer(), nullable=True), + sa.Column("text", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "entreprise_etudiant", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entreprise_id", sa.Integer(), nullable=True), + sa.Column("etudid", sa.Integer(), nullable=True), + sa.Column("type_offre", sa.Text(), nullable=True), + sa.Column("date_debut", sa.Date(), nullable=True), + sa.Column("date_fin", sa.Date(), nullable=True), + sa.Column("formation_text", sa.Text(), nullable=True), + sa.Column("formation_scodoc", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["entreprise_id"], ["entreprises.id"], ondelete="cascade" + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "entreprise_offre", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entreprise_id", sa.Integer(), nullable=True), + sa.Column( + "date_ajout", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("intitule", sa.Text(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("type_offre", sa.Text(), nullable=True), + sa.Column("missions", sa.Text(), nullable=True), + sa.Column("duree", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["entreprise_id"], ["entreprises.id"], ondelete="cascade" + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "entreprise_envoi_offre", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("sender_id", sa.Integer(), nullable=True), + sa.Column("receiver_id", sa.Integer(), nullable=True), + sa.Column("offre_id", sa.Integer(), nullable=True), + sa.Column( + "date_envoi", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["offre_id"], + ["entreprise_offre.id"], + ), + sa.ForeignKeyConstraint( + ["sender_id"], + ["user.id"], + ), + sa.ForeignKeyConstraint( + ["receiver_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.drop_constraint( + "entreprise_contact_entreprise_corresp_id_fkey", + "entreprise_contact", + type_="foreignkey", + ) + op.drop_table("entreprise_correspondant") + op.add_column("entreprise_contact", sa.Column("nom", sa.Text(), nullable=True)) + op.add_column("entreprise_contact", sa.Column("prenom", sa.Text(), nullable=True)) + op.add_column( + "entreprise_contact", sa.Column("telephone", sa.Text(), nullable=True) + ) + op.add_column("entreprise_contact", sa.Column("mail", sa.Text(), nullable=True)) + op.add_column("entreprise_contact", sa.Column("poste", sa.Text(), nullable=True)) + op.add_column("entreprise_contact", sa.Column("service", sa.Text(), nullable=True)) + op.drop_column("entreprise_contact", "description") + op.drop_column("entreprise_contact", "enseignant") + op.drop_column("entreprise_contact", "date") + op.drop_column("entreprise_contact", "type_contact") + op.drop_column("entreprise_contact", "etudid") + op.drop_column("entreprise_contact", "entreprise_corresp_id") + + op.add_column("entreprises", sa.Column("siret", sa.Text(), nullable=True)) + op.drop_index("ix_entreprises_dept_id", table_name="entreprises") + op.drop_constraint("entreprises_dept_id_fkey", "entreprises", type_="foreignkey") + op.drop_column("entreprises", "qualite_relation") + op.drop_column("entreprises", "note") + op.drop_column("entreprises", "contact_origine") + op.drop_column("entreprises", "plus10salaries") + op.drop_column("entreprises", "privee") + op.drop_column("entreprises", "secteur") + op.drop_column("entreprises", "date_creation") + op.drop_column("entreprises", "dept_id") + op.drop_column("entreprises", "localisation") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "entreprises", + sa.Column("localisation", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", + sa.Column("dept_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", + sa.Column( + "date_creation", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + autoincrement=False, + nullable=True, + ), + ) + op.add_column( + "entreprises", + sa.Column("secteur", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", + sa.Column("privee", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", + sa.Column("plus10salaries", sa.BOOLEAN(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", + sa.Column("contact_origine", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprises", sa.Column("note", sa.TEXT(), autoincrement=False, nullable=True) + ) + op.add_column( + "entreprises", + sa.Column("qualite_relation", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.create_foreign_key( + "entreprises_dept_id_fkey", "entreprises", "departement", ["dept_id"], ["id"] + ) + op.create_index("ix_entreprises_dept_id", "entreprises", ["dept_id"], unique=False) + op.drop_column("entreprises", "siret") + op.add_column( + "entreprise_contact", + sa.Column( + "entreprise_corresp_id", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.add_column( + "entreprise_contact", + sa.Column("etudid", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprise_contact", + sa.Column("type_contact", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprise_contact", + sa.Column( + "date", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + ) + op.add_column( + "entreprise_contact", + sa.Column("enseignant", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.add_column( + "entreprise_contact", + sa.Column("description", sa.TEXT(), autoincrement=False, nullable=True), + ) + op.create_table( + "entreprise_correspondant", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("entreprise_id", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("nom", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("prenom", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("civilite", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("fonction", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("phone1", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("phone2", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("mobile", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("mail1", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("mail2", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("fax", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("note", sa.TEXT(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint( + ["entreprise_id"], + ["entreprises.id"], + name="entreprise_correspondant_entreprise_id_fkey", + ), + sa.PrimaryKeyConstraint("id", name="entreprise_correspondant_pkey"), + ) + op.create_foreign_key( + "entreprise_contact_entreprise_corresp_id_fkey", + "entreprise_contact", + "entreprise_correspondant", + ["entreprise_corresp_id"], + ["id"], + ) + op.drop_column("entreprise_contact", "service") + op.drop_column("entreprise_contact", "poste") + op.drop_column("entreprise_contact", "mail") + op.drop_column("entreprise_contact", "telephone") + op.drop_column("entreprise_contact", "prenom") + op.drop_column("entreprise_contact", "nom") + + op.drop_table("entreprise_envoi_offre") + op.drop_table("entreprise_offre") + op.drop_table("entreprise_etudiant") + op.drop_table("entreprise_log") + # ### end Alembic commands ### diff --git a/pylintrc b/pylintrc index 057a85cd0..21454d07e 100644 --- a/pylintrc +++ b/pylintrc @@ -19,3 +19,5 @@ ignored-classes=Permission, # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules=entreprises + +good-names=d,e,f,i,j,k,t,u,v,x,y,z,H,F,ue \ No newline at end of file diff --git a/sco_version.py b/sco_version.py index bc906576b..84e9b0f57 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.1.16" +SCOVERSION = "9.2.0a" SCONAME = "ScoDoc" diff --git a/scodoc.py b/scodoc.py index 94c8a7f09..28e2f59b2 100755 --- a/scodoc.py +++ b/scodoc.py @@ -29,7 +29,6 @@ from app.models import ModuleImpl, ModuleImplInscription from app.models import Identite from app.models import departements from app.models.evaluations import Evaluation -from app.scodoc.sco_etud import identite_create from app.scodoc.sco_permissions import Permission from app.views import notes, scolar import tools @@ -249,6 +248,18 @@ def edit_role(rolename, addpermissionname=None, removepermissionname=None): # e db.session.commit() +@app.cli.command() +@click.argument("rolename") +def delete_role(rolename): + """Delete a role""" + role = Role.query.filter_by(name=rolename).first() + if role is None: + sys.stderr.write(f"delete_role: role {rolename} does not exists\n") + return 1 + db.session.delete(role) + db.session.commit() + + @app.cli.command() @click.argument("username") @click.option("-d", "--dept", "dept_acronym") diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index a73c98bbc..07705c211 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -4,6 +4,8 @@ et calcul moyennes modules """ import numpy as np import pandas as pd +from app.models.modules import Module +from app.models.moduleimpls import ModuleImpl from tests.unit import setup from app import db @@ -135,70 +137,72 @@ def test_module_conformity(test_client): ) assert isinstance(modules_coefficients, pd.DataFrame) assert modules_coefficients.shape == (nb_ues, nb_mods) - evals_poids, ues = moy_mod.df_load_evaluations_poids(evaluation.moduleimpl_id) + evals_poids, ues = moy_mod.load_evaluations_poids(evaluation.moduleimpl_id) assert isinstance(evals_poids, pd.DataFrame) assert len(ues) == nb_ues assert all(evals_poids.dtypes == np.float64) assert evals_poids.shape == (nb_evals, nb_ues) - assert not moy_mod.check_moduleimpl_conformity( + assert not moy_mod.moduleimpl_is_conforme( evaluation.moduleimpl, evals_poids, modules_coefficients ) -def test_module_moy_elem(test_client): - """Vérification calcul moyenne d'un module - (notes entrées dans un DataFrame sans passer par ScoDoc) - """ - # Création de deux évaluations: - e1 = Evaluation(note_max=20.0, coefficient=1.0) - e2 = Evaluation(note_max=20.0, coefficient=1.0) - db.session.add(e1) - db.session.add(e2) - db.session.commit() - # Repris du notebook CalculNotesBUT.ipynb - data = [ # Les notes de chaque étudiant dans les 2 evals: - { - e1.id: 11.0, - e2.id: 16.0, - }, - { - e1.id: None, # une absence - e2.id: 17.0, - }, - { - e1.id: 13.0, - e2.id: NOTES_NEUTRALISE, # une abs EXC - }, - { - e1.id: 14.0, - e2.id: 19.0, - }, - { - e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC) - e2.id: None, # et une ABS - }, - ] - evals_notes_df = pd.DataFrame( - data, index=["etud1", "etud2", "etud3", "etud4", "etud5"] - ) - # Poids des évaluations (1 ligne / évaluation) - data = [ - {"UE1": 1, "UE2": 0, "UE3": 0}, - {"UE1": 2, "UE2": 5, "UE3": 0}, - ] - evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float) - evaluations = [e1, e2] - etuds_moy_module_df = moy_mod.compute_module_moy( - evals_notes_df.fillna(0.0), evals_poids_df, evaluations, [True, True] - ) - NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) - r = etuds_moy_module_df.fillna(NAN) - assert tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) - assert tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) - assert tuple(r.loc["etud3"]) == (13, NAN, NAN) - assert tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) - assert tuple(r.loc["etud5"]) == (0.0, 0.0, NAN) - # note: les notes UE3 sont toutes NAN car les poids vers l'UE3 sont nuls +# En ScoDoc 9.2 test ne peut plus exister car compute_module_moy +# est maintenant incorporé dans la classe ModuleImplResultsAPC +# def test_module_moy_elem(test_client): +# """Vérification calcul moyenne d'un module +# (notes entrées dans un DataFrame sans passer par ScoDoc) +# """ +# # Création de deux évaluations: +# e1 = Evaluation(note_max=20.0, coefficient=1.0) +# e2 = Evaluation(note_max=20.0, coefficient=1.0) +# db.session.add(e1) +# db.session.add(e2) +# db.session.flush() +# # Repris du notebook CalculNotesBUT.ipynb +# data = [ # Les notes de chaque étudiant dans les 2 evals: +# { +# e1.id: 11.0, +# e2.id: 16.0, +# }, +# { +# e1.id: None, # une absence +# e2.id: 17.0, +# }, +# { +# e1.id: 13.0, +# e2.id: NOTES_NEUTRALISE, # une abs EXC +# }, +# { +# e1.id: 14.0, +# e2.id: 19.0, +# }, +# { +# e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC) +# e2.id: None, # et une ABS +# }, +# ] +# evals_notes_df = pd.DataFrame( +# data, index=["etud1", "etud2", "etud3", "etud4", "etud5"] +# ) +# # Poids des évaluations (1 ligne / évaluation) +# data = [ +# {"UE1": 1, "UE2": 0, "UE3": 0}, +# {"UE1": 2, "UE2": 5, "UE3": 0}, +# ] +# evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float) +# evaluations = [e1, e2] +# etuds_moy_module_df = moy_mod.compute_module_moy( +# evals_notes_df.fillna(0.0), evals_poids_df, evaluations, [True, True] +# ) +# NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) +# r = etuds_moy_module_df.fillna(NAN) +# assert tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) +# assert tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) +# assert tuple(r.loc["etud3"]) == (13, NAN, NAN) +# assert tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) +# assert tuple(r.loc["etud5"]) == (0.0, 0.0, NAN) +# # note: les notes UE3 sont toutes NAN car les poids vers l'UE3 sont nuls def test_module_moy(test_client): @@ -237,7 +241,7 @@ def test_module_moy(test_client): nb_evals = models.Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).count() assert nb_evals == 2 nb_ues = 3 - + modimpl = ModuleImpl.query.get(moduleimpl_id) # --- Change les notes et recalcule les moyennes du module # (rappel: on a deux évaluations: evaluation1, evaluation2, et un seul étudiant) def change_notes(n1, n2): @@ -245,17 +249,14 @@ def test_module_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Calcul de la moyenne du module - evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id) assert evals_poids.shape == (nb_evals, nb_ues) - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - moduleimpl_id - ) - assert evals_notes[evaluations[0].id].dtype == np.float64 - assert evaluation1.id == evaluations[0].id - assert evaluation2.id == evaluations[1].id - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) + + mod_results = moy_mod.ModuleImplResultsAPC(modimpl) + evals_notes = mod_results.evals_notes + assert evals_notes[evaluation1.id].dtype == np.float64 + + etuds_moy_module = mod_results.compute_module_moy(evals_poids) return etuds_moy_module # --- Notes ordinaires: diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py index d27463f40..8f9ec3ece 100644 --- a/tests/unit/test_but_ues.py +++ b/tests/unit/test_but_ues.py @@ -69,9 +69,9 @@ def test_ue_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Recalcul des moyennes - sem_cube, _, _, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() - etud_moy_ue = moy_ue.compute_ue_moys( + etud_moy_ue = moy_ue.compute_ue_moys_apc( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df ) return etud_moy_ue @@ -112,9 +112,9 @@ def test_ue_moy(test_client): exception_raised = True assert exception_raised # Recalcule les notes: - sem_cube, _, _, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() - etud_moy_ue = moy_ue.compute_ue_moys( + etud_moy_ue = moy_ue.compute_ue_moys_apc( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df ) assert etud_moy_ue[ue1.id][etudid] == n1 diff --git a/tools/debian/postinst b/tools/debian/postinst index 84001fc9e..1f3683eac 100755 --- a/tools/debian/postinst +++ b/tools/debian/postinst @@ -64,7 +64,7 @@ fi # ------------ LIEN VERS .env # Pour conserver le .env entre les mises à jour, on le génère dans -# /opt/scodoc-data/;env et on le lie: +# /opt/scodoc-data/.env et on le lie: if [ ! -e "$SCODOC_DIR/.env" ] && [ ! -L "$SCODOC_DIR/.env" ] then ln -s "$SCODOC_VAR_DIR/.env" "$SCODOC_DIR"