diff --git a/README.md b/README.md index e28a92aa..29ac566a 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,12 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes). -### État actuel (26 sept 21) +### État actuel (4 dec 21) - - 9.0 reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: + - 9.0 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: + - ancien module "Entreprises" (obsolète) -**Fonctionnalités non intégrées:** - - - génération LaTeX des avis de poursuite d'études - - - ancien module "Entreprises" (obsolete) + - 9.1 (branche "PNBUT") est la version de développement. ### Lignes de commandes @@ -42,7 +39,7 @@ les fichiers locaux (archives, photos, configurations, logs) sous postgresql et la configuration du système Linux. ### Fichiers locaux -Sous `/opt/scodoc-data`, fichiers et répertoires appartienant à l'utilisateur `scodoc`. +Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`. Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous `/opt/scodoc-data/config`. @@ -89,11 +86,22 @@ exemple pour travailler en mode "développement" avec `FLASK_ENV=development`. ### Tests unitaires +Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`. +Avant le premier lancement, créer cette base ainsi: + + ./tools/create_database.sh SCODOC_TEST + export FLASK_ENV=test + flask db upgrade + +Cette commande n'est nécessaire que la première fois (le contenu de la base +est effacé au début de chaque test, mais son schéma reste) et aussi si des +migrations (changements de schéma) ont eu lieu dans le code. + Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les scripts de tests: Lancer au préalable: - flask sco-delete-dept TEST00 && flask sco-create-dept TEST00 + flask delete-dept TEST00 && flask create-dept TEST00 Puis dérouler les tests unitaires: @@ -109,7 +117,8 @@ On peut aussi utiliser les tests unitaires pour mettre la base de données de développement dans un état connu, par exemple pour éviter de recréer à la main étudiants et semestres quand on développe. -Il suffit de positionner une variable d'environnement indiquant la BD utilisée par les tests: +Il suffit de positionner une variable d'environnement indiquant la BD +utilisée par les tests: export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV @@ -157,7 +166,7 @@ Sur une machine de DEV, lancer flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data -le fichier `.prof` sera alors écrit dans `/opt/scoidoc-data` (on peut aussi utiliser `/tmp`). +le fichier `.prof` sera alors écrit dans `/opt/scodoc-data` (on peut aussi utiliser `/tmp`). Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien: diff --git a/app/__init__.py b/app/__init__.py index 41875f33..0943a91f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -357,7 +357,7 @@ def sco_db_insert_constants(): current_app.logger.info("Init Sco db") # Modalités: - models.NotesFormModalite.insert_modalites() + models.FormationModalite.insert_modalites() def initialize_scodoc_database(erase=False, create_all=False): diff --git a/app/api/__init__.py b/app/api/__init__.py index 34ebbc77..d397a93d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -2,7 +2,25 @@ """ from flask import Blueprint +from flask import request bp = Blueprint("api", __name__) + +def requested_format(default_format="json", allowed_formats=None): + """Extract required format from query string. + * default value is json. A list of allowed formats may be provided + (['json'] considered if not provided). + * if the required format is not in allowed list, returns None. + + NB: if json in not in allowed_formats, format specification is mandatory. + """ + format_type = request.args.get("format", default_format) + if format_type in (allowed_formats or ["json"]): + return format_type + return None + + +from app.api import tokens from app.api import sco_api +from app.api import logos diff --git a/app/api/auth.py b/app/api/auth.py index 24348aab..331cd388 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,6 +1,7 @@ # -*- coding: UTF-8 -* # Authentication code borrowed from Miguel Grinberg's Mega Tutorial # (see https://github.com/miguelgrinberg/microblog) +# and modified for ScoDoc # Under The MIT License (MIT) @@ -23,6 +24,7 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from flask import g from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth from app.auth.models import User from app.api.errors import error_response @@ -35,6 +37,7 @@ token_auth = HTTPTokenAuth() def verify_password(username, password): user = User.query.filter_by(user_name=username).first() if user and user.check_password(password): + g.current_user = user return user @@ -45,9 +48,30 @@ def basic_auth_error(status): @token_auth.verify_token def verify_token(token): - return User.check_token(token) if token else None + user = User.check_token(token) if token else None + g.current_user = user + return user @token_auth.error_handler def token_auth_error(status): return error_response(status) + + +@token_auth.get_user_roles +def get_user_roles(user): + return user.roles + + +# def token_permission_required(permission): +# def decorator(f): +# @wraps(f) +# def decorated_function(*args, **kwargs): +# scodoc_dept = getattr(g, "scodoc_dept", None) +# if not current_user.has_permission(permission, scodoc_dept): +# abort(403) +# return f(*args, **kwargs) + +# return login_required(decorated_function) + +# return decorator diff --git a/app/api/sco_api.py b/app/api/sco_api.py index e2619a0b..6aa488c2 100644 --- a/app/api/sco_api.py +++ b/app/api/sco_api.py @@ -38,19 +38,43 @@ # Scolarite/Notes/groups_view # Scolarite/Notes/moduleimpl_status # Scolarite/setGroups +from datetime import datetime -from flask import jsonify, request, url_for, abort -from app import db -from app.api import bp +from flask import jsonify, request, g, send_file +from sqlalchemy.sql import func + +from app import db, log +from app.api import bp, requested_format from app.api.auth import token_auth -from app.api.errors import bad_request - +from app.api.errors import error_response from app import models +from app.models import FormSemestre, FormSemestreInscription, Identite +from app.scodoc.sco_permissions import Permission -@bp.route("/ScoDoc/api/list_depts", methods=["GET"]) +@bp.route("list_depts", methods=["GET"]) @token_auth.login_required def list_depts(): depts = models.Departement.query.filter_by(visible=True).all() - data = {"items": [d.to_dict() for d in depts]} + data = [d.to_dict() for d in depts] return jsonify(data) + + +@bp.route("/etudiants/courant", methods=["GET"]) +@token_auth.login_required +def etudiants(): + """Liste de tous les étudiants actuellement inscrits à un semestre + en cours. + """ + # Vérification de l'accès: permission Observateir sur tous les départements + # (c'est un exemple à compléter) + if not g.current_user.has_permission(Permission.ScoObservateur, None): + return error_response(401, message="accès interdit") + + query = db.session.query(Identite).filter( + FormSemestreInscription.formsemestre_id == FormSemestre.id, + FormSemestreInscription.etudid == Identite.id, + FormSemestre.date_debut <= func.now(), + FormSemestre.date_fin >= func.now(), + ) + return jsonify([e.to_dict_bul(include_urls=False) for e in query]) diff --git a/app/auth/models.py b/app/auth/models.py index f3e406f7..d9c5455b 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -213,6 +213,9 @@ class User(UserMixin, db.Model): @staticmethod def check_token(token): + """Retreive user for given token, chek token's validity + and returns the user object. + """ user = User.query.filter_by(token=token).first() if user is None or user.token_expiration < datetime.utcnow(): return None @@ -269,7 +272,7 @@ class User(UserMixin, db.Model): """string repr. of user's roles (with depts) e.g. "Ens_RT, Ens_Info, Secr_CJ" """ - return ",".join(f"{r.role.name}_{r.dept or ''}" for r in self.user_roles) + return ",".join(f"{r.role.name or ''}_{r.dept or ''}" for r in self.user_roles) def is_administrator(self): "True if i'm an active SuperAdmin" diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py new file mode 100644 index 00000000..4ff2849f --- /dev/null +++ b/app/but/bulletin_but.py @@ -0,0 +1,422 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +from collections import defaultdict +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 + + +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 + """ + + _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] + if coef > 0: + d[mi.module.code] = { + "id": mi.id, + "coef": coef, + "moyenne": fmt_note( + etud_moy_module[self.modimpl_coefs_df.columns.get_loc(mi.id)][ + ue_idx + ] + ), + } + return d + + def etud_ue_results(self, etud, ue): + "dict synthèse résultats UE" + d = { + "id": ue.id, + "numero": ue.numero, + "ECTS": { + "acquis": 0, # XXX TODO voir jury + "total": ue.ects, + }, + "competence": None, # XXX TODO lien avec référentiel + "moyenne": { + "value": fmt_note(self.etud_moy_ue[ue.id][etud.id]), + "min": fmt_note(self.etud_moy_ue[ue.id].min()), + "max": fmt_note(self.etud_moy_ue[ue.id].max()), + "moy": fmt_note(self.etud_moy_ue[ue.id].mean()), + }, + "bonus": None, # XXX TODO + "malus": None, # XXX TODO voir ce qui est ici + "capitalise": None, # "AAAA-MM-JJ" TODO + "ressources": self.etud_ue_mod_results(etud, ue, self.ressources), + "saes": self.etud_ue_mod_results(etud, ue, self.saes), + } + return d + + def etud_mods_results(self, etud, modimpls) -> dict: + """dict synthèse résultats des modules indiqués, + avec évaluations de chacun.""" + d = {} + # etud_idx = self.etud_index[etud.id] + for mi in modimpls: + # mod_idx = self.modimpl_coefs_df.columns.get_loc(mi.id) + # # moyennes indicatives (moyennes de moyennes d'UE) + # try: + # moyennes_etuds = np.nan_to_num( + # np.nanmean(self.sem_cube[:, mod_idx, :], axis=1), + # copy=False, + # ) + # except RuntimeWarning: # all nans in np.nanmean (sur certains etuds sans notes valides) + # pass + # try: + # 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, + "url": url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=mi.id, + ), + "moyenne": { + # # moyenne indicative de module: moyenne des UE, ignorant celles sans notes (nan) + # "value": fmt_note(moy_indicative_mod), + # "min": fmt_note(moyennes_etuds.min()), + # "max": fmt_note(moyennes_etuds.max()), + # "moy": fmt_note(moyennes_etuds.mean()), + }, + "evaluations": [ + self.etud_eval_results(etud, e) + for eidx, e in enumerate(mi.evaluations) + if e.visibulletin + and self.modimpls_evaluations_complete[mi.id][eidx] + ], + } + 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 + notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() + d = { + "id": e.id, + "description": e.description, + "date": e.jour.isoformat() if e.jour else None, + "heure_debut": e.heure_debut.strftime("%H:%M") if e.heure_debut else None, + "heure_fin": e.heure_fin.strftime("%H:%M") if e.heure_debut else None, + "coef": e.coefficient, + "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, + "note": { + "value": fmt_note( + self.modimpls_evals_notes[e.moduleimpl_id][e.id][etud.id], + note_max=e.note_max, + ), + "min": fmt_note(notes_ok.min()), + "max": fmt_note(notes_ok.max()), + "moy": fmt_note(notes_ok.mean()), + }, + "url": url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e.id, + ), + } + return d + + def bulletin_etud(self, etud, formsemestre) -> dict: + """Le bulletin de l'étudiant dans ce semestre""" + etat_inscription = etud.etat_inscription(formsemestre.id) + d = { + "version": "0", + "type": "BUT", + "date": datetime.datetime.utcnow().isoformat() + "Z", + "etudiant": etud.to_dict_bul(), + "formation": { + "id": formsemestre.formation.id, + "acronyme": formsemestre.formation.acronyme, + "titre_officiel": formsemestre.formation.titre_officiel, + "titre": formsemestre.formation.titre, + }, + "formsemestre_id": formsemestre.id, + "etat_inscription": etat_inscription, + "options": bulletin_option_affichage(formsemestre), + } + semestre_infos = { + "etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo], + "date_debut": formsemestre.date_debut.isoformat(), + "date_fin": formsemestre.date_fin.isoformat(), + "annee_universitaire": self.formsemestre.annee_scolaire_str(), + "inscription": "TODO-MM-JJ", # XXX TODO + "numero": formsemestre.semestre_id, + "groupes": [], # XXX TODO + "absences": { # XXX TODO + "injustifie": 1, + "total": 33, + }, + } + semestre_infos.update( + sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) + ) + if etat_inscription == scu.INSCRIT: + semestre_infos.update( + { + "notes": { # moyenne des moyennes générales du semestre + "value": fmt_note(self.etud_moy_gen[etud.id]), + "min": fmt_note(self.etud_moy_gen.min()), + "moy": fmt_note(self.etud_moy_gen.mean()), + "max": fmt_note(self.etud_moy_gen.max()), + }, + "rang": { # classement wrt moyenne général, indicatif + "value": self.etud_moy_gen_ranks[etud.id], + "total": len(self.etuds), + }, + }, + ) + d.update( + { + "ressources": self.etud_mods_results(etud, self.ressources), + "saes": self.etud_mods_results(etud, self.saes), + "ues": { + ue.acronyme: self.etud_ue_results(etud, ue) for ue in self.ues + }, + "semestre": semestre_infos, + }, + ) + else: + semestre_infos.update( + { + "notes": { + "value": "DEM", + "min": "", + "moy": "", + "max": "", + }, + "rang": {"value": "DEM", "total": len(self.etuds)}, + } + ) + d.update( + { + "semestre": semestre_infos, + "ressources": {}, + "saes": {}, + "ues": {}, + } + ) + + 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 new file mode 100644 index 00000000..9e234dc0 --- /dev/null +++ b/app/but/bulletin_but_xml_compat.py @@ -0,0 +1,326 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Génération du bulletin en format XML / compatibilité ScoDoc 7 + + => exporte quelques résultats BUT dans le format des anciens bulletins XML ScoDoc 7 + afin d'avoir un affichage acceptable sur les ENT anciens. + +Les plate-formes modernes utilisent uniquement la version JSON (but/bulletin_but.py) +""" + + +import datetime +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from app import log +from app.but import bulletin_but +from app.models import FormSemestre, Identite +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc import sco_abs +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_edit_ue +from app.scodoc import sco_etud +from app.scodoc import sco_photos +from app.scodoc import sco_preferences +from app.scodoc import sco_xml + + +def bulletin_but_xml_compat( + formsemestre_id, + etudid, + doc=None, # XML document + force_publishing=False, + xml_nodate=False, + xml_with_decisions=False, # inclue les decisions même si non publiées + version="long", +) -> str: + """Bulletin XML au format ScoDoc 7, avec informations "BUT" """ + from app.scodoc import sco_bulletins + + log( + "bulletin_but_xml_compat( formsemestre_id=%s, etudid=%s )" + % (formsemestre_id, etudid) + ) + sem = FormSemestre.query.get_or_404(formsemestre_id) + etud = Identite.query.get_or_404(etudid) + results = bulletin_but.ResultatsSemestreBUT(sem) + nb_inscrits = len(results.etuds) + if sem.bul_hide_xml or force_publishing: + published = "1" + else: + published = "0" + if xml_nodate: + docdate = "" + else: + docdate = datetime.datetime.now().isoformat() + el = { + "etudid": str(etudid), + "formsemestre_id": str(formsemestre_id), + "date": docdate, + "publie": published, + } + if sem.etapes: + el["etape_apo"] = sem.etapes[0].etape_apo or "" + n = 2 + for et in sem.etapes[1:]: + el["etape_apo" + str(n)] = et.etape_apo or "" + n += 1 + x = Element("bulletinetud", **el) + if doc: + is_appending = True + doc.append(x) + else: + is_appending = False + doc = x + # Infos sur l'etudiant + doc.append( + Element( + "etudiant", + etudid=str(etudid), + code_nip=etud.code_nip or "", + 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 + 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 ""), + ) + ) + # Disponible pour publication ? + if not published: + return doc # stop ! + # Moyenne générale: + doc.append( + Element( + "note", + value=scu.fmt_note(results.etud_moy_gen[etud.id]), + min=scu.fmt_note(results.etud_moy_gen.min()), + max=scu.fmt_note(results.etud_moy_gen.max()), + moy=scu.fmt_note(results.etud_moy_gen.mean()), # moyenne des moy. gen. + ) + ) + rang = 0 # XXX TODO rang de l'étduiant selon la moy gen indicative + bonus = 0 # XXX TODO valeur du bonus sport + doc.append(Element("rang", value=str(rang), ninscrits=str(nb_inscrits))) + # XXX TODO: ajouter "rang_group" : rangs dans les partitions + doc.append(Element("note_max", value="20")) # notes toujours sur 20 + doc.append(Element("bonus_sport_culture", value=str(bonus))) + # Liste les UE / modules /evals + for ue in results.ues: + rang_ue = 0 # XXX TODO rang de l'étudiant dans cette UE + nb_inscrits_ue = ( + nb_inscrits # approx: compliqué de définir le "nb d'inscrit à une UE" + ) + x_ue = Element( + "ue", + id=str(ue.id), + numero=scu.quote_xml_attr(ue.numero), + acronyme=scu.quote_xml_attr(ue.acronyme or ""), + titre=scu.quote_xml_attr(ue.titre or ""), + code_apogee=scu.quote_xml_attr(ue.code_apogee or ""), + ) + doc.append(x_ue) + if ue.type != sco_codes_parcours.UE_SPORT: + v = results.etud_moy_ue[ue.id][etud.id] + else: + v = 0 # XXX TODO valeur bonus sport pour cet étudiant + x_ue.append( + Element( + "note", + value=scu.fmt_note(v), + min=scu.fmt_note(results.etud_moy_ue[ue.id].min()), + max=scu.fmt_note(results.etud_moy_ue[ue.id].max()), + ) + ) + x_ue.append(Element("ects", value=str(ue.ects if ue.ects else 0))) + x_ue.append(Element("rang", value=str(rang_ue))) + x_ue.append(Element("effectif", value=str(nb_inscrits_ue))) + # Liste les modules rattachés à cette UE + for modimpl in results.modimpls: + # Liste ici uniquement les modules rattachés à cette UE + if modimpl.module.ue.id == ue.id: + mod_moy = scu.fmt_note(results.etud_moy_ue[ue.id][etud.id]) + coef = results.modimpl_coefs_df[modimpl.id][ue.id] + x_mod = Element( + "module", + id=str(modimpl.id), + code=str(modimpl.module.code or ""), + coefficient=str(coef), + numero=str(modimpl.module.numero or 0), + titre=scu.quote_xml_attr(modimpl.module.titre or ""), + abbrev=scu.quote_xml_attr(modimpl.module.abbrev or ""), + code_apogee=scu.quote_xml_attr(modimpl.module.code_apogee or ""), + ) + x_ue.append(x_mod) + x_mod.append( + Element( + "note", + value=mod_moy, + min=scu.fmt_note(results.etud_moy_ue[ue.id].min()), + max=scu.fmt_note(results.etud_moy_ue[ue.id].max()), + moy=scu.fmt_note(results.etud_moy_ue[ue.id].mean()), + ) + ) + # XXX TODO rangs et effectifs + # --- notes de chaque eval: + if version != "short": + for e in modimpl.evaluations: + if e.visibulletin or version == "long": + x_eval = Element( + "evaluation", + jour=e.jour.isoformat() if e.jour else "", + heure_debut=e.heure_debut.isoformat() + if e.heure_debut + else "", + heure_fin=e.heure_fin.isoformat() + if e.heure_debut + else "", + coefficient=str(e.coefficient), + # pas les poids en XML compat + evaluation_type=str(e.evaluation_type), + description=scu.quote_xml_attr(e.description), + # notes envoyées sur 20, ceci juste pour garder trace: + note_max_origin=str(e.note_max), + ) + x_mod.append(x_eval) + x_eval.append( + Element( + "note", + value=scu.fmt_note( + results.modimpls_evals_notes[e.moduleimpl_id][ + e.id + ][etud.id] + ), + ) + ) + # XXX TODO: Evaluations incomplètes ou futures: XXX + # XXX TODO UE capitalisee (listee seulement si meilleure que l'UE courante) + + # --- Absences + if sco_preferences.get_preference("bul_show_abs", formsemestre_id): + nbabs, nbabsjust = sem.get_abs_count(etud.id) + doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust))) + + # -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py --------- + # TODO : refactoring + + # --- Decision Jury + if ( + sco_preferences.get_preference("bul_show_decision", formsemestre_id) + or xml_with_decisions + ): + infos, dpv = sco_bulletins.etud_descr_situation_semestre( + etudid, + formsemestre_id, + format="xml", + show_uevalid=sco_preferences.get_preference( + "bul_show_uevalid", formsemestre_id + ), + ) + x_situation = Element("situation") + x_situation.text = scu.quote_xml_attr(infos["situation"]) + doc.append(x_situation) + if dpv: + decision = dpv["decisions"][0] + etat = decision["etat"] + if decision["decision_sem"]: + code = decision["decision_sem"]["code"] or "" + else: + code = "" + if ( + decision["decision_sem"] + and "compense_formsemestre_id" in decision["decision_sem"] + ): + doc.append( + Element( + "decision", + code=code, + etat=str(etat), + compense_formsemestre_id=str( + decision["decision_sem"]["compense_formsemestre_id"] or "" + ), + ) + ) + else: + doc.append(Element("decision", code=code, etat=str(etat))) + + if decision[ + "decisions_ue" + ]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee) + for ue_id in decision["decisions_ue"].keys(): + ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0] + doc.append( + Element( + "decision_ue", + ue_id=str(ue["ue_id"]), + numero=scu.quote_xml_attr(ue["numero"]), + acronyme=scu.quote_xml_attr(ue["acronyme"]), + titre=scu.quote_xml_attr(ue["titre"]), + code=decision["decisions_ue"][ue_id]["code"], + ) + ) + + for aut in decision["autorisations"]: + doc.append( + Element( + "autorisation_inscription", semestre_id=str(aut["semestre_id"]) + ) + ) + else: + doc.append(Element("decision", code="", etat="DEM")) + # --- Appreciations + cnx = ndb.GetDBConnexion() + apprecs = sco_etud.appreciations_list( + cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} + ) + for appr in apprecs: + x_appr = Element( + "appreciation", + date=ndb.DateDMYtoISO(appr["date"]), + ) + x_appr.text = scu.quote_xml_attr(appr["comment"]) + doc.append(x_appr) + + if is_appending: + 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/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py new file mode 100644 index 00000000..a1cab459 --- /dev/null +++ b/app/but/forms/refcomp_forms.py @@ -0,0 +1,35 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""ScoDoc 9 : Formulaires / référentiel de compétence +""" + +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, FileRequired +from wtforms import SelectField, SubmitField + + +class FormationRefCompForm(FlaskForm): + referentiel_competence = SelectField("Référentiels déjà chargés") + submit = SubmitField("Valider") + cancel = SubmitField("Annuler") + + +class RefCompLoadForm(FlaskForm): + upload = FileField( + label="Sélectionner un fichier XML Orébut", + validators=[ + FileRequired(), + FileAllowed( + [ + "xml", + ], + "Fichier XML Orébut seulement", + ), + ], + ) + submit = SubmitField("Valider") + cancel = SubmitField("Annuler") diff --git a/app/but/import_refcomp.py b/app/but/import_refcomp.py new file mode 100644 index 00000000..e0e59ca8 --- /dev/null +++ b/app/but/import_refcomp.py @@ -0,0 +1,126 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +from xml.etree import ElementTree +from typing import TextIO + +from app import db + +from app.models.but_refcomp import ( + ApcReferentielCompetences, + ApcCompetence, + ApcSituationPro, + ApcAppCritique, + ApcComposanteEssentielle, + ApcNiveau, + ApcParcours, + ApcAnneeParcours, + ApcParcoursNiveauCompetence, +) +from app.scodoc.sco_exceptions import ScoFormatError + + +def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): + """Importation XML Orébut + peut lever TypeError ou ScoFormatError + Résultat: instance de ApcReferentielCompetences + """ + try: + root = ElementTree.XML(xml_data) + except ElementTree.ParseError: + raise ScoFormatError("fichier XML Orébut invalide") + if root.tag != "referentiel_competence": + raise ScoFormatError("élément racine 'referentiel_competence' manquant") + args = ApcReferentielCompetences.attr_from_xml(root.attrib) + args["dept_id"] = dept_id + args["scodoc_orig_filename"] = orig_filename + ref = ApcReferentielCompetences(**args) + db.session.add(ref) + competences = root.find("competences") + if not competences: + raise ScoFormatError("élément 'competences' manquant") + for competence in competences.findall("competence"): + c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) + ref.competences.append(c) + # --- SITUATIONS + situations = competence.find("situations") + for situation in situations: + libelle = "".join(situation.itertext()).strip() + s = ApcSituationPro(competence_id=c.id, libelle=libelle) + c.situations.append(s) + # --- COMPOSANTES ESSENTIELLES + composantes = competence.find("composantes_essentielles") + for composante in composantes: + libelle = "".join(composante.itertext()).strip() + ce = ApcComposanteEssentielle(libelle=libelle) + c.composantes_essentielles.append(ce) + # --- NIVEAUX (années) + niveaux = competence.find("niveaux") + for niveau in niveaux: + niv = ApcNiveau(**ApcNiveau.attr_from_xml(niveau.attrib)) + c.niveaux.append(niv) + acs = niveau.find("acs") + for ac in acs: + libelle = "".join(ac.itertext()).strip() + code = ac.attrib["code"] + niv.app_critiques.append(ApcAppCritique(code=code, libelle=libelle)) + # --- PARCOURS + parcours = root.find("parcours") + if not parcours: + raise ScoFormatError("élément 'parcours' manquant") + for parcour in parcours.findall("parcour"): + parc = ApcParcours(**ApcParcours.attr_from_xml(parcour.attrib)) + ref.parcours.append(parc) + for annee in parcour.findall("annee"): + a = ApcAnneeParcours(**ApcAnneeParcours.attr_from_xml(annee.attrib)) + parc.annees.append(a) + for competence in annee.findall("competence"): + nom = competence.attrib["nom"] + niveau = int(competence.attrib["niveau"]) + # Retrouve la competence + comp = ref.competences.filter_by(titre=nom).all() + if len(comp) == 0: + raise ScoFormatError(f"competence {nom} référencée mais on définie") + elif len(comp) > 1: + raise ScoFormatError(f"competence {nom} ambigüe") + ass = ApcParcoursNiveauCompetence( + niveau=niveau, annee_parcours=a, competence=comp[0] + ) + db.session.add(ass) + + db.session.commit() + return ref + + +""" +xmlfile = open("but-RT-refcomp-30112021.xml") +tree = ElementTree.parse(xmlfile) +# get root element +root = tree.getroot() +assert root.tag == "referentiel_competence" + +ref = ApcReferentielCompetences(**ApcReferentielCompetences.attr_from_xml(root.attrib)) + +competences = root.find("competences") +if not competences: + raise ScoFormatError("élément 'competences' manquant") + +competence = competences.findall("competence")[0] # XXX + +from app.but.import_refcomp import * +dept_id = models.Departement.query.first().id +data = open("tests/data/but-RT-refcomp-exemple.xml").read() +ref = orebut_import_refcomp(data, dept_id) +#------ +from app.but.import_refcomp import * +ref = ApcReferentielCompetences.query.first() +p = ApcParcours(code="PARC", libelle="Parcours Test") +ref.parcours.append(p) +annee = ApcAnneeParcours(numero=1) +p.annees.append(annee) +annee.competences +c = ref.competences.filter_by(titre="Administrer").first() +annee.competences.append(c) +""" diff --git a/app/comp/df_cache.py b/app/comp/df_cache.py new file mode 100644 index 00000000..dec325e2 --- /dev/null +++ b/app/comp/df_cache.py @@ -0,0 +1,49 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""caches pour tables APC +""" + +from app.scodoc import sco_cache + + +class ModuleCoefsCache(sco_cache.ScoDocCache): + """Cache for module coefs + Clé: formation_id.semestre_idx + Valeur: DataFrame (df_load_module_coefs) + """ + + prefix = "MCO" + + +class EvaluationsPoidsCache(sco_cache.ScoDocCache): + """Cache for poids evals + Clé: moduleimpl_id + Valeur: DataFrame (df_load_evaluations_poids) + """ + + prefix = "EPC" diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py new file mode 100644 index 00000000..c34547e6 --- /dev/null +++ b/app/comp/inscr_mod.py @@ -0,0 +1,70 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Matrices d'inscription aux modules d'un semestre +""" +import numpy as np +import pandas as pd + +from app import db +from app import models + +# +# Le chargement des inscriptions est long: matrice nb_module x nb_etuds +# sur test debug 116 etuds, 18 modules, on est autour de 250ms. +# On a testé trois approches, ci-dessous (et retenu la 1ere) +# +def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: + """Charge la matrice des inscriptions aux modules du semestre + rows: etudid + columns: moduleimpl_id (en chaîne) + value: bool (0/1 inscrit ou pas) + """ + # méthode la moins lente: une requete par module, merge les dataframes + moduleimpl_ids = [m.id for m in formsemestre.modimpls] + etudids = [i.etudid for i in formsemestre.get_inscrits(include_dem=False)] + df = pd.DataFrame(index=etudids, dtype=int) + for moduleimpl_id in moduleimpl_ids: + ins_df = pd.read_sql_query( + """SELECT etudid, 1 AS "%(moduleimpl_id)s" + FROM notes_moduleimpl_inscription + WHERE moduleimpl_id=%(moduleimpl_id)s""", + db.engine, + params={"moduleimpl_id": moduleimpl_id}, + index_col="etudid", + dtype=int, + ) + df = df.merge(ins_df, how="left", left_index=True, right_index=True) + # les colonnes de df sont en float (Nan) quand il n'y a + # aucun inscrit au module. + df.fillna(0, inplace=True) # les non-inscrits + return df.astype(bool) # x100 25.5s 15s 17s + + +# chrono avec timeit: +# timeit.timeit('x = df_load_module_inscr_v0(696)', number=100, globals=globals()) + + +def df_load_modimpl_inscr_v0(formsemestre): + # methode 0, pur SQL Alchemy, 1.5 à 2 fois plus lente + moduleimpl_ids = [m.id for m in formsemestre.modimpls] + etudids = [i.etudid for i in formsemestre.inscriptions] + df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) + for modimpl in formsemestre.modimpls: + ins_mod = df[modimpl.id] + for inscr in modimpl.inscriptions: + ins_mod[inscr.etudid] = True + return df # x100 30.7s 46s 32s + + +def df_load_modimpl_inscr_v2(formsemestre): + moduleimpl_ids = [m.id for m in formsemestre.modimpls] + etudids = [i.etudid for i in formsemestre.inscriptions] + df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) + cursor = db.engine.execute( + "select moduleimpl_id, etudid from notes_moduleimpl_inscription i, notes_moduleimpl m where i.moduleimpl_id = m.id and m.formsemestre_id = %(formsemestre_id)s", + {"formsemestre_id": formsemestre.id}, + ) + for moduleimpl_id, etudid in cursor: + df[moduleimpl_id][etudid] = True + return df # x100 44s, 31s, 29s, 28s diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py new file mode 100644 index 00000000..40616cba --- /dev/null +++ b/app/comp/moy_mod.py @@ -0,0 +1,238 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Fonctions de calcul des moyennes de modules (modules, ressources ou SAÉ) + +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. +""" +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( + moduleimpl_id: int, default_poids=1.0 +) -> tuple[pd.DataFrame, list]: + """Charge poids des évaluations d'un module et retourne un dataframe + rows = evaluations, columns = UE, value = poids (float). + Les valeurs manquantes (évaluations sans coef vers des UE) sont + remplies par default_poids. + Résultat: (evals_poids, liste de UE du semestre) + """ + modimpl = ModuleImpl.query.get(moduleimpl_id) + evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() + 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( + EvaluationUEPoids.evaluation + ).filter_by(moduleimpl_id=moduleimpl_id): + df[eval_poids.ue_id][eval_poids.evaluation_id] = eval_poids.poids + if default_poids is not None: + df.fillna(value=default_poids, inplace=True) + return df, ues + + +def check_moduleimpl_conformity( + 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. + """ + nb_evals, nb_ues = evals_poids.shape + if nb_evals == 0: + return True # modules vides conformes + 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") + module_evals_poids = evals_poids.transpose().sum(axis=1).to_numpy() != 0 + check = all( + (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) + + Résultat: (evals_notes, liste de évaluations du moduleimpl, + liste de booleens indiquant si l'évaluation est "complete") + + 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 + ) + ] + 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", + ) + 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 diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py new file mode 100644 index 00000000..037b4cd0 --- /dev/null +++ b/app/comp/moy_sem.py @@ -0,0 +1,79 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Fonctions de calcul des moyennes de semestre (indicatives dans le BUT) +""" +import numpy as np +import pandas as pd + + +def compute_sem_moys(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 + + etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid + modimpl_coefs_df: DataFrame, colonnes moduleimpl_id, lignes UE + + Result: panda Series, index etudid, valeur float (moyenne générale) + """ + moy_gen = (etud_moy_ue_df * modimpl_coefs_df.values.sum(axis=1)).sum( + axis=1 + ) / modimpl_coefs_df.values.sum() + return moy_gen + + +def comp_ranks_series(notes: pd.Series): + """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur numérique) + en tenant compte des ex-aequos + Le resultat est: { etudid : rang } où rang est une chaine decrivant le rang + """ + notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant + rangs = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne + N = len(notes) + nb_ex = 0 # nb d'ex-aequo consécutifs en cours + notes_i = notes.iat + for i, etudid in enumerate(notes.index): + # test ex-aequo + if i < (N - 1): + next = notes_i[i + 1] + else: + next = None + val = notes_i[i] + if nb_ex: + srang = "%d ex" % (i + 1 - nb_ex) + if val == next: + nb_ex += 1 + else: + nb_ex = 0 + else: + if val == next: + srang = "%d ex" % (i + 1 - nb_ex) + nb_ex = 1 + else: + srang = "%d" % (i + 1) + rangs[etudid] = srang + return rangs diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py new file mode 100644 index 00000000..74994f26 --- /dev/null +++ b/app/comp/moy_ue.py @@ -0,0 +1,230 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Fonctions de calcul des moyennes d'UE +""" +import numpy as np +import pandas as pd + +from app import db +from app import models +from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef +from app.comp import moy_mod +from app.models.formsemestre import FormSemestre +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é. + + Ces coefs lient les modules à chaque UE. + + Résultat: (module_coefs_df, ues, modules) + DataFrame rows = UEs, columns = modules, value = coef. + + Considère toutes les UE (sauf sport) et modules du semestre. + Les coefs non définis (pas en base) sont mis à zéro. + + Si semestre_idx None, prend toutes les UE de la formation. + """ + ues = ( + UniteEns.query.filter_by(formation_id=formation_id) + .filter(UniteEns.type != sco_codes_parcours.UE_SPORT) + .order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme) + ) + modules = Module.query.filter_by(formation_id=formation_id).order_by( + Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code + ) + if semestre_idx is not None: + ues = ues.filter_by(semestre_idx=semestre_idx) + modules = modules.filter_by(semestre_id=semestre_idx) + ues = ues.all() + modules = modules.all() + ue_ids = [ue.id for ue in ues] + module_ids = [module.id for module in modules] + module_coefs_df = pd.DataFrame(columns=module_ids, index=ue_ids, dtype=float) + query = ( + db.session.query(ModuleUECoef) + .filter(UniteEns.formation_id == formation_id) + .filter(ModuleUECoef.ue_id == UniteEns.id) + ) + if semestre_idx is not None: + query = query.filter(UniteEns.semestre_idx == semestre_idx) + + for mod_coef in query: + module_coefs_df[mod_coef.module_id][mod_coef.ue_id] = mod_coef.coef + + module_coefs_df.fillna(value=0, inplace=True) + + return module_coefs_df, ues, modules + + +def df_load_modimpl_coefs( + formsemestre: models.FormSemestre, ues=None, modimpls=None +) -> pd.DataFrame: + """Charge les coefs des modules du formsemestre indiqué. + + Comme df_load_module_coefs mais prend seulement les UE + et modules du formsemestre. + Si ues et modimpls sont None, prend tous ceux du formsemestre. + Résultat: (module_coefs_df, ues, modules) + DataFrame rows = UEs, columns = modimpl, value = coef. + """ + if ues is None: + ues = formsemestre.query_ues().all() + ue_ids = [x.id for x in ues] + if modimpls is None: + modimpls = formsemestre.modimpls.all() + modimpl_ids = [x.id for x in modimpls] + mod2impl = {m.module.id: m.id for m in modimpls} + modimpl_coefs_df = pd.DataFrame(columns=modimpl_ids, index=ue_ids, dtype=float) + mod_coefs = ( + db.session.query(ModuleUECoef) + .filter(ModuleUECoef.module_id == ModuleImpl.module_id) + .filter(ModuleImpl.formsemestre_id == formsemestre.id) + ) + + for mod_coef in mod_coefs: + modimpl_coefs_df[mod2impl[mod_coef.module_id]][mod_coef.ue_id] = mod_coef.coef + modimpl_coefs_df.fillna(value=0, inplace=True) + return modimpl_coefs_df, ues, modimpls + + +def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: + """Réuni les notes moyennes des modules du semestre en un "cube" + + modimpls_notes : liste des moyennes de module + (DataFrames rendus par compute_module_moy, (etud x UE)) + Resultat: ndarray (etud x module x UE) + """ + modimpls_notes_arr = [df.values for df in modimpls_notes] + modimpls_notes = np.stack(modimpls_notes_arr) + # passe de (mod x etud x ue) à (etud x mod x UE) + return modimpls_notes.swapaxes(0, 1) + + +def notes_sem_load_cube(formsemestre): + """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_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 + modimpls_notes.append(etuds_moy_module) + return ( + notes_sem_assemble_cube(modimpls_notes), + modimpls_evals_poids, + modimpls_evals_notes, + modimpls_evaluations, + modimpls_evaluations_complete, + ) + + +def compute_ue_moys( + sem_cube: np.array, + etuds: list, + modimpls: list, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs_df: pd.DataFrame, +) -> pd.DataFrame: + """Calcul de la moyenne d'UE + 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_cube: notes moyennes aux modules + ndarray (etuds x modimpls x UEs) + (floats avec des NaN) + etuds : lites 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) + + Resultat: DataFrame columns UE, rows etudid + """ + nb_etuds, nb_modules, nb_ues = sem_cube.shape + assert len(etuds) == nb_etuds + assert len(modimpls) == nb_modules + assert len(ues) == nb_ues + assert modimpl_inscr_df.shape[0] == nb_etuds + assert modimpl_inscr_df.shape[1] == nb_modules + assert modimpl_coefs_df.shape[0] == nb_ues + assert modimpl_coefs_df.shape[1] == nb_modules + modimpl_inscr = modimpl_inscr_df.values + modimpl_coefs = modimpl_coefs_df.values + if nb_etuds == 0: + return pd.DataFrame( + index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index + ) + # Duplique les inscriptions sur les UEs: + modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues, axis=2) + # Enlève les NaN du numérateur: + # si on veut prendre en compte les modules avec notes neutralisées ? + sem_cube_no_nan = np.nan_to_num(sem_cube, nan=0.0) + + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_cube_inscrits = np.where(modimpl_inscr_stacked, sem_cube_no_nan, 0.0) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr_stacked, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN + modimpl_coefs_etuds_no_nan = np.where(np.isnan(sem_cube), 0.0, modimpl_coefs_etuds) + # + # Version vectorisée + # + etud_moy_ue = np.sum( + modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + return pd.DataFrame( + etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index + ) diff --git a/app/decorators.py b/app/decorators.py index a688cb17..8ebf5dea 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -50,8 +50,15 @@ def scodoc(func): @wraps(func) def scodoc_function(*args, **kwargs): + # print("@scodoc") # interdit les POST si pas loggué - if request.method == "POST" and not current_user.is_authenticated: + if ( + request.method == "POST" + and not current_user.is_authenticated + and not request.form.get( + "__ac_password" + ) # exception pour compat API ScoDoc7 + ): current_app.logger.info( "POST by non authenticated user (request.form=%s)", str(request.form)[:2048], @@ -71,6 +78,7 @@ def scodoc(func): # current_app.logger.info("setting dept to None") g.scodoc_dept = None g.scodoc_dept_id = -1 # invalide + return func(*args, **kwargs) return scodoc_function @@ -100,8 +108,8 @@ def permission_required_compat_scodoc7(permission): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): - # current_app.logger.warning("PERMISSION; kwargs=%s" % str(kwargs)) # cherche les paramètre d'auth: + # print("@permission_required_compat_scodoc7") auth_ok = False if request.method == "GET": user_name = request.args.get("__ac_name") @@ -116,7 +124,6 @@ def permission_required_compat_scodoc7(permission): if u and u.check_password(user_password): auth_ok = True flask_login.login_user(u) - # reprend le chemin classique: scodoc_dept = getattr(g, "scodoc_dept", None) @@ -153,6 +160,7 @@ def scodoc7func(func): 2. or be called directly from Python. """ + # print("@scodoc7func") # Détermine si on est appelé via une route ("toplevel") # ou par un appel de fonction python normal. top_level = not hasattr(g, "scodoc7_decorated") diff --git a/app/models/__init__.py b/app/models/__init__.py index d54a8982..0fee7bc4 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -30,37 +30,44 @@ from app.models.etudiants import ( EtudAnnotation, ) from app.models.events import Scolog, ScolarNews -from app.models.formations import ( - NotesFormation, - NotesUE, - NotesMatiere, - NotesModule, - NotesTag, - notes_modules_tags, -) +from app.models.formations import Formation, Matiere +from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags +from app.models.ues import UniteEns from app.models.formsemestre import ( FormSemestre, - NotesFormsemestreEtape, - NotesFormModalite, - NotesFormsemestreUECoef, - NotesFormsemestreUEComputationExpr, - NotesFormsemestreCustomMenu, - NotesFormsemestreInscription, + FormSemestreEtape, + FormationModalite, + FormSemestreUECoef, + FormSemestreUEComputationExpr, + FormSemestreCustomMenu, + FormSemestreInscription, notes_formsemestre_responsables, - NotesModuleImpl, - notes_modules_enseignants, - NotesModuleImplInscription, - NotesEvaluation, NotesSemSet, notes_semset_formsemestre, ) +from app.models.moduleimpls import ( + ModuleImpl, + notes_modules_enseignants, + ModuleImplInscription, +) +from app.models.evaluations import ( + Evaluation, + EvaluationUEPoids, +) from app.models.groups import Partition, GroupDescr, group_membership from app.models.notes import ( ScolarEvent, - ScolarFormsemestreValidation, + ScolarFormSemestreValidation, ScolarAutorisationInscription, - NotesAppreciations, + BulAppreciations, NotesNotes, NotesNotesLog, ) from app.models.preferences import ScoPreference, ScoDocSiteConfig + +from app.models.but_refcomp import ( + ApcReferentielCompetences, + ApcCompetence, + ApcSituationPro, + ApcAppCritique, +) diff --git a/app/models/absences.py b/app/models/absences.py index e6e243b7..658a390d 100644 --- a/app/models/absences.py +++ b/app/models/absences.py @@ -49,7 +49,7 @@ class AbsenceNotification(db.Model): nbabsjust = db.Column(db.Integer) formsemestre_id = db.Column( db.Integer, - db.ForeignKey("notes_formsemestre.id"), + db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"), ) diff --git a/app/models/but_pn.py b/app/models/but_pn.py new file mode 100644 index 00000000..35afbe16 --- /dev/null +++ b/app/models/but_pn.py @@ -0,0 +1,19 @@ +"""ScoDoc 9 models : Formation BUT 2021 +""" +from enum import unique +from typing import Any + +from app import db + +from app.scodoc.sco_utils import ModuleType + + +class APCFormation(db.Model): + """Formation par compétence""" + + id = db.Column(db.Integer, primary_key=True) + dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) + specialite = db.Column(db.Text(), nullable=False) # "RT" + specialite_long = db.Column( + db.Text(), nullable=False + ) # "Réseaux et télécommunications" diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py new file mode 100644 index 00000000..b930bf9e --- /dev/null +++ b/app/models/but_refcomp.py @@ -0,0 +1,287 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 models : Référentiel Compétence BUT 2021 +""" +from datetime import datetime +from enum import unique +from typing import Any + +from app import db + +from app.scodoc.sco_utils import ModuleType + + +class XMLModel: + _xml_attribs = {} # to be overloaded + id = "_" + + @classmethod + def attr_from_xml(cls, args: dict) -> dict: + """dict with attributes imported from Orébut XML + and renamed for our models. + The mapping is specified by the _xml_attribs + attribute in each model class. + """ + return {cls._xml_attribs.get(k, k): v for (k, v) in args.items()} + + def __repr__(self): + return f'<{self.__class__.__name__} {self.id} "{self.titre if hasattr(self, "titre") else ""}">' + + +class ApcReferentielCompetences(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) + specialite = db.Column(db.Text()) + specialite_long = db.Column(db.Text()) + type_titre = db.Column(db.Text()) + _xml_attribs = { # Orébut xml attrib : attribute + "type": "type_titre", + } + # ScoDoc specific fields: + scodoc_date_loaded = db.Column(db.DateTime, default=datetime.utcnow) + scodoc_orig_filename = db.Column(db.Text()) + # Relations: + competences = db.relationship( + "ApcCompetence", + backref="referentiel", + lazy="dynamic", + cascade="all, delete-orphan", + ) + parcours = db.relationship( + "ApcParcours", + backref="referentiel", + lazy="dynamic", + cascade="all, delete-orphan", + ) + formations = db.relationship("Formation", backref="referentiel_competence") + + def to_dict(self): + """Représentation complète du ref. de comp. + comme un dict. + """ + return { + "dept_id": self.dept_id, + "specialite": self.specialite, + "specialite_long": self.specialite_long, + "type_titre": self.type_titre, + "scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z" + if self.scodoc_date_loaded + else "", + "scodoc_orig_filename": self.scodoc_orig_filename, + "competences": {x.titre: x.to_dict() for x in self.competences}, + "parcours": {x.code: x.to_dict() for x in self.parcours}, + } + + +class ApcCompetence(db.Model, XMLModel): + __table_args__ = ( + # les compétences dans Orébut sont identifiées par leur "titre" + # unique au sein d'un référentiel: + db.UniqueConstraint( + "referentiel_id", "titre", name="apc_competence_referentiel_id_titre_key" + ), + ) + id = db.Column(db.Integer, primary_key=True) + referentiel_id = db.Column( + db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False + ) + titre = db.Column(db.Text(), nullable=False, index=True) + titre_long = db.Column(db.Text()) + couleur = db.Column(db.Text()) + numero = db.Column(db.Integer) # ordre de présentation + _xml_attribs = { # xml_attrib : attribute + "name": "titre", + "libelle_long": "titre_long", + } + situations = db.relationship( + "ApcSituationPro", + backref="competence", + lazy="dynamic", + cascade="all, delete-orphan", + ) + composantes_essentielles = db.relationship( + "ApcComposanteEssentielle", + backref="competence", + lazy="dynamic", + cascade="all, delete-orphan", + ) + niveaux = db.relationship( + "ApcNiveau", + backref="competence", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + def to_dict(self): + return { + "titre": self.titre, + "titre_long": self.titre_long, + "couleur": self.couleur, + "numero": self.numero, + "situations": [x.to_dict() for x in self.situations], + "composantes_essentielles": [ + x.to_dict() for x in self.composantes_essentielles + ], + "niveaux": {x.annee: x.to_dict() for x in self.niveaux}, + } + + +class ApcSituationPro(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + competence_id = db.Column( + db.Integer, db.ForeignKey("apc_competence.id"), nullable=False + ) + libelle = db.Column(db.Text(), nullable=False) + # aucun attribut (le text devient le libellé) + def to_dict(self): + return {"libelle": self.libelle} + + +class ApcComposanteEssentielle(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + competence_id = db.Column( + db.Integer, db.ForeignKey("apc_competence.id"), nullable=False + ) + libelle = db.Column(db.Text(), nullable=False) + + def to_dict(self): + return {"libelle": self.libelle} + + +class ApcNiveau(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + competence_id = db.Column( + db.Integer, db.ForeignKey("apc_competence.id"), nullable=False + ) + libelle = db.Column(db.Text(), nullable=False) + annee = db.Column(db.Text(), nullable=False) # "BUT2" + # L'ordre est l'année d'apparition de ce niveau + ordre = db.Column(db.Integer, nullable=False) # 1, 2, 3 + app_critiques = db.relationship( + "ApcAppCritique", + backref="niveau", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + def to_dict(self): + return { + "libelle": self.libelle, + "annee": self.annee, + "ordre": self.ordre, + "app_critiques": {x.code: x.to_dict() for x in self.app_critiques}, + } + + +class ApcAppCritique(db.Model, XMLModel): + "Apprentissage Critique BUT" + id = db.Column(db.Integer, primary_key=True) + niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False) + code = db.Column(db.Text(), nullable=False, index=True) + libelle = db.Column(db.Text()) + + modules = db.relationship( + "Module", + secondary="apc_modules_acs", + lazy="dynamic", + backref=db.backref("app_critiques", lazy="dynamic"), + ) + + def to_dict(self): + return {"libelle": self.libelle} + + def get_label(self): + return self.code + " - " + self.titre + + def __repr__(self): + return "".format(self.code) + + def get_saes(self): + """Liste des SAE associées""" + return [m for m in self.modules if m.module_type == ModuleType.SAE] + + +ApcAppCritiqueModules = db.Table( + "apc_modules_acs", + db.Column("module_id", db.ForeignKey("notes_modules.id")), + db.Column("app_crit_id", db.ForeignKey("apc_app_critique.id")), +) + + +class ApcParcours(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + referentiel_id = db.Column( + db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False + ) + numero = db.Column(db.Integer) # ordre de présentation + code = db.Column(db.Text(), nullable=False) + libelle = db.Column(db.Text(), nullable=False) + annees = db.relationship( + "ApcAnneeParcours", + backref="parcours", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + def to_dict(self): + return { + "code": self.code, + "numero": self.numero, + "libelle": self.libelle, + "annees": {x.ordre: x.to_dict() for x in self.annees}, + } + + +class ApcAnneeParcours(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + parcours_id = db.Column( + db.Integer, db.ForeignKey("apc_parcours.id"), nullable=False + ) + ordre = db.Column(db.Integer) + + def to_dict(self): + return { + "ordre": self.ordre, + "competences": { + x.competence.titre: {"niveau": x.niveau} + for x in self.niveaux_competences + }, + } + + +class ApcParcoursNiveauCompetence(db.Model): + """Association entre année de parcours et compétence. + Le "niveau" de la compétence est donné ici + (convention Orébut) + """ + + competence_id = db.Column( + db.Integer, + db.ForeignKey("apc_competence.id", ondelete="CASCADE"), + primary_key=True, + ) + annee_parcours_id = db.Column( + db.Integer, + db.ForeignKey("apc_annee_parcours.id", ondelete="CASCADE"), + primary_key=True, + ) + niveau = db.Column(db.Integer, nullable=False) # 1, 2, 3 + competence = db.relationship( + ApcCompetence, + backref=db.backref( + "annee_parcours", + passive_deletes=True, + cascade="save-update, merge, delete, delete-orphan", + ), + ) + annee_parcours = db.relationship( + ApcAnneeParcours, + backref=db.backref( + "niveaux_competences", + passive_deletes=True, + cascade="save-update, merge, delete, delete-orphan", + ), + ) diff --git a/app/models/departements.py b/app/models/departements.py index 36aa8d4c..95167383 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -21,9 +21,7 @@ class Departement(db.Model): entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement") etudiants = db.relationship("Identite", lazy="dynamic", backref="departement") - formations = db.relationship( - "NotesFormation", lazy="dynamic", backref="departement" - ) + formations = db.relationship("Formation", lazy="dynamic", backref="departement") formsemestres = db.relationship( "FormSemestre", lazy="dynamic", backref="departement" ) @@ -44,3 +42,8 @@ class Departement(db.Model): "date_creation": self.date_creation, } return data + + @classmethod + def from_acronym(cls, acronym): + dept = cls.query.filter_by(acronym=acronym).first_or_404() + return dept diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 2511f0ca..0ae36bd2 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,7 +4,10 @@ et données rattachées (adresses, annotations, ...) """ +from flask import g, url_for + from app import db +from app import models from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN @@ -41,11 +44,81 @@ class Identite(db.Model): code_nip = db.Column(db.Text()) code_ine = db.Column(db.Text()) # Ancien id ScoDoc7 pour les migrations de bases anciennes - # ne pas utiliser après migrate_scodoc7_dept_archive + # ne pas utiliser après migrate_scodoc7_dept_archives scodoc7_id = db.Column(db.Text(), nullable=True) # + adresses = db.relationship("Adresse", lazy="dynamic", backref="etud") billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") + def __repr__(self): + return f"" + + 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" + if self.nom_usuel: + return ( + (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel + ) + else: + return self.nom + + def get_first_email(self, field="email") -> str: + "le mail associé à la première adrese de l'étudiant, ou None" + return self.adresses[0].email or None if self.adresses.count() > 0 else None + + def to_dict_bul(self, include_urls=True): + """Infos exportées dans les bulletins""" + from app.scodoc import sco_photos + + d = { + "civilite": self.civilite, + "code_ine": self.code_ine, + "code_nip": self.code_nip, + "date_naissance": self.date_naissance.isoformat() + if self.date_naissance + else None, + "email": self.get_first_email(), + "emailperso": self.get_first_email("emailperso"), + "etudid": self.id, + "nom": self.nom_disp(), + "prenom": self.prenom, + } + if include_urls: + d["fiche_url"] = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id + ) + d["photo_url"] = (sco_photos.get_etud_photo_url(self.id),) + return d + + def inscription_courante(self): + """La première inscription à un formsemestre _actuellement_ en cours. + None s'il n'y en a pas (ou plus, ou pas encore). + """ + r = [ + ins + for ins in self.formsemestre_inscriptions + if ins.formsemestre.est_courant() + ] + 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 + """ + # voir si ce n'est pas trop lent: + ins = models.FormSemestreInscription.query.filter_by( + etudid=self.id, formsemestre_id=formsemestre_id + ).first() + if ins: + return ins.etat + return False + class Adresse(db.Model): """Adresse d'un étudiant @@ -149,10 +222,13 @@ class ItemSuiviTag(db.Model): # Association tag <-> module itemsuivi_tags_assoc = db.Table( "itemsuivi_tags_assoc", - db.Column("tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id")), - db.Column("itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id")), + db.Column( + "tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id", ondelete="CASCADE") + ), + db.Column( + "itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id", ondelete="CASCADE") + ), ) -# ON DELETE CASCADE ? class EtudAnnotation(db.Model): diff --git a/app/models/evaluations.py b/app/models/evaluations.py new file mode 100644 index 00000000..e0733fb7 --- /dev/null +++ b/app/models/evaluations.py @@ -0,0 +1,141 @@ +# -*- coding: UTF-8 -* + +"""ScoDoc models: evaluations +""" + +from app import db +from app.models import APO_CODE_STR_LEN +from app.models import SHORT_STR_LEN +from app.models import CODE_STR_LEN +from app.models import UniteEns + +import app.scodoc.notesdb as ndb +from app.scodoc import sco_evaluation_db + + +class Evaluation(db.Model): + """Evaluation (contrôle, examen, ...)""" + + __tablename__ = "notes_evaluation" + + id = db.Column(db.Integer, primary_key=True) + evaluation_id = db.synonym("id") + moduleimpl_id = db.Column( + db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True + ) + jour = db.Column(db.Date) + heure_debut = db.Column(db.Time) + heure_fin = db.Column(db.Time) + description = db.Column(db.Text) + note_max = db.Column(db.Float) + coefficient = db.Column(db.Float) + visibulletin = db.Column( + db.Boolean, nullable=False, default=True, server_default="true" + ) + publish_incomplete = db.Column( + db.Boolean, nullable=False, default=False, server_default="false" + ) + # type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session" + evaluation_type = db.Column( + db.Integer, nullable=False, default=0, server_default="0" + ) + # ordre de presentation (par défaut, le plus petit numero + # est la plus ancienne eval): + numero = db.Column(db.Integer) + ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) + + def __repr__(self): + return f"""""" + + def to_dict(self): + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators + e["evaluation_id"] = self.id + e["jour"] = ndb.DateISOtoDMY(e["jour"]) + e["numero"] = ndb.int_null_is_zero(e["numero"]) + return sco_evaluation_db.evaluation_enrich_dict(e) + + def from_dict(self, data): + """Set evaluation attributes from given dict values.""" + sco_evaluation_db._check_evaluation_args(data) + for k in self.__dict__.keys(): + if k != "_sa_instance_state" and k != "id" and k in data: + setattr(self, k, data[k]) + + def clone(self, not_copying=()): + """Clone, not copying the given attrs + Attention: la copie n'a pas d'id avant le prochain commit + """ + d = dict(self.__dict__) + d.pop("id") # get rid of id + d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr + for k in not_copying: + d.pop(k) + copy = self.__class__(**d) + db.session.add(copy) + return copy + + def set_ue_poids(self, ue, poids: float) -> None: + """Set poids évaluation vers cette UE""" + self.update_ue_poids_dict({ue.id: poids}) + + def set_ue_poids_dict(self, ue_poids_dict: dict) -> None: + """set poids vers les UE (remplace existants) + ue_poids_dict = { ue_id : poids } + """ + L = [] + for ue_id, poids in ue_poids_dict.items(): + ue = UniteEns.query.get(ue_id) + L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)) + self.ue_poids = L + self.moduleimpl.invalidate_evaluations_poids() # inval cache + + def update_ue_poids_dict(self, ue_poids_dict: dict) -> None: + """update poids vers UE (ajoute aux existants)""" + current = self.get_ue_poids_dict() + current.update(ue_poids_dict) + self.set_ue_poids_dict(current) + + def get_ue_poids_dict(self) -> dict: + """returns { ue_id : poids }""" + return {p.ue.id: p.poids for p in self.ue_poids} + + def get_ue_poids_str(self) -> str: + """string describing poids, for excel cells and pdfs + Note: si les poids ne sont pas initialisés (poids par défaut), + ils ne sont pas affichés. + """ + return ", ".join([f"{p.ue.acronyme}: {p.poids}" for p in self.ue_poids]) + + +class EvaluationUEPoids(db.Model): + """Poids des évaluations (BUT) + association many to many + """ + + evaluation_id = db.Column( + db.Integer, + db.ForeignKey("notes_evaluation.id", ondelete="CASCADE"), + primary_key=True, + ) + ue_id = db.Column( + db.Integer, + db.ForeignKey("notes_ue.id", ondelete="CASCADE"), + primary_key=True, + ) + poids = db.Column( + db.Float, + nullable=False, + ) + evaluation = db.relationship( + Evaluation, + backref=db.backref("ue_poids", cascade="all, delete-orphan"), + ) + ue = db.relationship( + UniteEns, + backref=db.backref("evaluation_ue_poids", cascade="all, delete-orphan"), + ) + + def __repr__(self): + return f"" diff --git a/app/models/formations.py b/app/models/formations.py index c4f81e52..3abbf388 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -1,13 +1,16 @@ -"""ScoDoc8 models : Formations (hors BUT) +"""ScoDoc 9 models : Formations """ -from typing import Any from app import db -from app.models import APO_CODE_STR_LEN +from app.comp import df_cache from app.models import SHORT_STR_LEN +from app.scodoc import notesdb as ndb +from app.scodoc import sco_cache +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_utils as scu -class NotesFormation(db.Model): +class Formation(db.Model): """Programme pédagogique d'une formation""" __tablename__ = "notes_formations" @@ -30,51 +33,97 @@ class NotesFormation(db.Model): type_parcours = db.Column(db.Integer, default=0, server_default="0") code_specialite = db.Column(db.String(SHORT_STR_LEN)) - ues = db.relationship("NotesUE", backref="formation", lazy="dynamic") + # Optionnel, pour les formations type BUT + referentiel_competence_id = db.Column( + db.Integer, db.ForeignKey("apc_referentiel_competences.id") + ) + ues = db.relationship("UniteEns", backref="formation", lazy="dynamic") formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation") - ues = db.relationship("NotesUE", lazy="dynamic", backref="formation") + ues = db.relationship("UniteEns", lazy="dynamic", backref="formation") + modules = db.relationship("Module", lazy="dynamic", backref="formation") def __repr__(self): return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme}')>" + def to_dict(self): + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["formation_id"] = self.id + return e -class NotesUE(db.Model): - """Unité d'Enseignement""" + def get_parcours(self): + """get l'instance de TypeParcours de cette formation""" + return sco_codes_parcours.get_parcours_from_code(self.type_parcours) - __tablename__ = "notes_ue" + def is_apc(self): + "True si formation APC avec SAE (BUT)" + return self.get_parcours().APC_SAE - id = db.Column(db.Integer, primary_key=True) - ue_id = db.synonym("id") - formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) - acronyme = db.Column(db.Text(), nullable=False) - numero = db.Column(db.Integer) # ordre de présentation - titre = db.Column(db.Text()) - # Type d'UE: 0 normal ("fondamentale"), 1 "sport", 2 "projet et stage (LP)", - # 4 "élective" - type = db.Column(db.Integer, default=0, server_default="0") - # Les UE sont "compatibles" (pour la capitalisation) ssi elles ont ^m code - # note: la fonction SQL notes_newid_ucod doit être créée à part - ue_code = db.Column( - db.String(SHORT_STR_LEN), - server_default=db.text("notes_newid_ucod()"), - nullable=False, - ) - ects = db.Column(db.Float) # nombre de credits ECTS - is_external = db.Column(db.Boolean(), default=False, server_default="false") - # id de l'element pedagogique Apogee correspondant: - code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) - # coef UE, utilise seulement si l'option use_ue_coefs est activée: - coefficient = db.Column(db.Float) + def get_module_coefs(self, semestre_idx: int = None): + """Les coefs des modules vers les UE (accès via cache)""" + from app.comp import moy_ue - # relations - matieres = db.relationship("NotesMatiere", lazy="dynamic", backref="ue") - modules = db.relationship("NotesModule", lazy="dynamic", backref="ue") + if semestre_idx is None: + key = f"{self.id}" + else: + key = f"{self.id}.{semestre_idx}" - def __repr__(self): - return f"<{self.__class__.__name__}(id={self.id}, formation_id={self.formation_id}, acronyme='{self.acronyme}')>" + modules_coefficients = df_cache.ModuleCoefsCache.get(key) + if modules_coefficients is None: + modules_coefficients, _, _ = moy_ue.df_load_module_coefs( + self.id, semestre_idx + ) + df_cache.ModuleCoefsCache.set(key, modules_coefficients) + return modules_coefficients + + def has_locked_sems(self): + "True if there is a locked formsemestre in this formation" + return len(self.formsemestres.filter_by(etat=False).all()) > 0 + + def invalidate_module_coefs(self, semestre_idx: int = None): + """Invalide les coefficients de modules cachés. + Si semestre_idx est None, invalide tous les semestres, + sinon invalide le semestre indiqué et le cache de la formation. + """ + if semestre_idx is None: + keys = {f"{self.id}.{m.semestre_id}" for m in self.modules} + else: + keys = f"{self.id}.{semestre_idx}" + df_cache.ModuleCoefsCache.delete_many(keys | {f"{self.id}"}) + sco_cache.invalidate_formsemestre() + + def invalidate_cached_sems(self): + for sem in self.formsemestres: + sco_cache.invalidate_formsemestre(formsemestre_id=sem.id) + + def force_semestre_modules_aux_ues(self) -> None: + """ + Affecte à chaque module de cette formation le semestre de son UE de rattachement, + si elle en a une. + Devrait être appelé lorsqu'on change le type de formation vers le BUT, et aussi + lorsqu'on change le semestre d'une UE BUT. + Utile pour la migration des anciennes formations vers le BUT. + Invalide les caches coefs/poids. + """ + if not self.is_apc(): + return + change = False + for mod in self.modules: + if ( + mod.ue.semestre_idx is not None + and mod.ue.semestre_idx > 0 + and mod.semestre_id != mod.ue.semestre_idx + ): + mod.semestre_id = mod.ue.semestre_idx + db.session.add(mod) + change = True + db.session.commit() + if change: + self.invalidate_module_coefs() -class NotesMatiere(db.Model): +class Matiere(db.Model): """Matières: regroupe les modules d'une UE La matière a peu d'utilité en dehors de la présentation des modules d'une UE. @@ -89,60 +138,4 @@ class NotesMatiere(db.Model): titre = db.Column(db.Text()) numero = db.Column(db.Integer) # ordre de présentation - modules = db.relationship("NotesModule", lazy="dynamic", backref="matiere") - - -class NotesModule(db.Model): - """Module""" - - __tablename__ = "notes_modules" - - id = db.Column(db.Integer, primary_key=True) - module_id = db.synonym("id") - titre = db.Column(db.Text()) - abbrev = db.Column(db.Text()) # nom court - # certains départements ont des codes infiniment longs: donc Text ! - code = db.Column(db.Text(), nullable=False) - heures_cours = db.Column(db.Float) - heures_td = db.Column(db.Float) - heures_tp = db.Column(db.Float) - coefficient = db.Column(db.Float) # coef PPN - ects = db.Column(db.Float) # Crédits ECTS - ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), index=True) - formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) - matiere_id = db.Column(db.Integer, db.ForeignKey("notes_matieres.id")) - # pas un id mais le numéro du semestre: 1, 2, ... - semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") - numero = db.Column(db.Integer) # ordre de présentation - # id de l'element pedagogique Apogee correspondant: - code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) - module_type = db.Column(db.Integer) # NULL ou 0:defaut, 1: malus (NOTES_MALUS) - # Relations: - modimpls = db.relationship("NotesModuleImpl", backref="module", lazy="dynamic") - - -class NotesTag(db.Model): - """Tag sur un module""" - - __tablename__ = "notes_tags" - __table_args__ = (db.UniqueConstraint("title", "dept_id"),) - - id = db.Column(db.Integer, primary_key=True) - tag_id = db.synonym("id") - - dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) - title = db.Column(db.Text(), nullable=False) - - -# Association tag <-> module -notes_modules_tags = db.Table( - "notes_modules_tags", - db.Column( - "tag_id", - db.Integer, - db.ForeignKey("notes_tags.id", ondelete="CASCADE"), - ), - db.Column( - "module_id", db.Integer, db.ForeignKey("notes_modules.id", ondelete="CASCADE") - ), -) + modules = db.relationship("Module", lazy="dynamic", backref="matiere") diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 73830b06..450ea2cc 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -1,19 +1,29 @@ # -*- coding: UTF-8 -* -"""ScoDoc models +"""ScoDoc models: formsemestre """ -from typing import Any +import datetime + +import flask_sqlalchemy from app import db from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN +from app.models import UniteEns + +import app.scodoc.sco_utils as scu +from app.models.ues import UniteEns +from app.models.modules import Module +from app.models.moduleimpls import ModuleImpl +from app.models.etudiants import Identite +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_preferences +from app.scodoc.sco_vdi import ApoEtapeVDI class FormSemestre(db.Model): - """Mise en oeuvre d'un semestre de formation - was notes_formsemestre - """ + """Mise en oeuvre d'un semestre de formation""" __tablename__ = "notes_formsemestre" @@ -32,7 +42,7 @@ class FormSemestre(db.Model): ) # False si verrouillé modalite = db.Column( db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") - ) + ) # "FI", "FAP", "FC", ... # gestion compensation sem DUT: gestion_compensation = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" @@ -72,20 +82,172 @@ class FormSemestre(db.Model): # Relations: etapes = db.relationship( - "NotesFormsemestreEtape", cascade="all,delete", backref="formsemestre" + "FormSemestreEtape", cascade="all,delete", backref="formsemestre" ) - formsemestres = db.relationship( - "NotesModuleImpl", backref="formsemestre", lazy="dynamic" + modimpls = db.relationship("ModuleImpl", backref="formsemestre", lazy="dynamic") + etuds = db.relationship( + "Identite", + secondary="notes_formsemestre_inscription", + viewonly=True, + lazy="dynamic", + ) + responsables = db.relationship( + "User", + secondary="notes_formsemestre_responsables", + lazy=True, + backref=db.backref("formsemestres", lazy=True), ) - # Ancien id ScoDoc7 pour les migrations de bases anciennes - # ne pas utiliser après migrate_scodoc7_dept_archive + # ne pas utiliser après migrate_scodoc7_dept_archives scodoc7_id = db.Column(db.Text(), nullable=True) def __init__(self, **kwargs): super(FormSemestre, self).__init__(**kwargs) if self.modalite is None: - self.modalite = NotesFormModalite.DEFAULT_MODALITE + self.modalite = FormationModalite.DEFAULT_MODALITE + + def to_dict(self): + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + d["formsemestre_id"] = self.id + d["date_debut"] = ( + self.date_debut.strftime("%d/%m/%Y") if self.date_debut else "" + ) + d["date_fin"] = self.date_fin.strftime("%d/%m/%Y") if self.date_fin else "" + d["responsables"] = [u.id for u in self.responsables] + return d + + def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: + """UE des modules de ce semestre, triées par numéro. + - Formations classiques: les UEs auxquelles appartiennent + les modules mis en place dans ce semestre. + - Formations APC / BUT: les UEs de la formation qui ont + le même numéro de semestre que ce formsemestre. + """ + if self.formation.get_parcours().APC_SAE: + sem_ues = UniteEns.query.filter_by( + formation=self.formation, semestre_idx=self.semestre_id + ) + else: + sem_ues = db.session.query(UniteEns).filter( + ModuleImpl.formsemestre_id == self.id, + Module.id == ModuleImpl.module_id, + UniteEns.id == Module.ue_id, + ) + if not with_sport: + sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT) + return sem_ues.order_by(UniteEns.numero) + + def est_courant(self) -> bool: + """Vrai si la date actuelle (now) est dans le semestre + (les dates de début et fin sont incluses) + """ + today = datetime.date.today() + return (self.date_debut <= today) and (today <= self.date_fin) + + def est_decale(self): + """Vrai si semestre "décalé" + c'est à dire semestres impairs commençant entre janvier et juin + et les pairs entre juillet et decembre + """ + if self.semestre_id <= 0: + return False # formations sans semestres + return (self.semestre_id % 2 and self.date_debut.month <= 6) or ( + not self.semestre_id % 2 and self.date_debut.month > 6 + ) + + def etapes_apo_str(self) -> str: + """Chaine décrivant les étapes de ce semestre + ex: "V1RT, V1RT3, V1RT4" + """ + if not self.etapes: + return "" + return ", ".join([str(x.etape_apo) for x in self.etapes]) + + def responsables_str(self, abbrev_prenom=True) -> str: + """chaîne "J. Dupond, X. Martin" + ou "Jacques Dupond, Xavier Martin" + """ + if not self.responsables: + return "" + if abbrev_prenom: + return ", ".join([u.get_prenomnom() for u in self.responsables]) + else: + return ", ".join([u.get_nomcomplet() for u in self.responsables]) + + def annee_scolaire_str(self): + "2021 - 2022" + return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) + + def session_id(self) -> str: + """identifiant externe de semestre de formation + Exemple: RT-DUT-FI-S1-ANNEE + + DEPT-TYPE-MODALITE+-S?|SPECIALITE + + TYPE=DUT|LP*|M* + MODALITE=FC|FI|FA (si plusieurs, en inverse alpha) + + SPECIALITE=[A-Z]+ EON,ASSUR, ... (si pas Sn ou SnD) + + ANNEE=annee universitaire de debut (exemple: un S2 de 2013-2014 sera S2-2013) + """ + imputation_dept = sco_preferences.get_preference("ImputationDept", self.id) + if not imputation_dept: + imputation_dept = sco_preferences.get_preference("DeptName") + imputation_dept = imputation_dept.upper() + parcours_name = self.formation.get_parcours().NAME + modalite = self.modalite + # exception pour code Apprentissage: + modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA") + if self.semestre_id > 0: + decale = "D" if self.est_decale() else "" + semestre_id = f"S{self.semestre_id}{decale}" + else: + semestre_id = self.formation.code_specialite or "" + annee_sco = str( + scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month) + ) + return scu.sanitize_string( + "-".join((imputation_dept, parcours_name, modalite, semestre_id, annee_sco)) + ) + + def titre_mois(self) -> str: + """Le titre et les dates du semestre, pour affichage dans des listes + Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)" + """ + return f"""{self.titre_num()} {self.modalite or ''} ({ + scu.MONTH_NAMES_ABBREV[self.date_debut.month-1]} { + self.date_debut.year} - { + scu.MONTH_NAMES_ABBREV[self.date_fin.month -1]} { + self.date_fin.year})""" + + def titre_num(self) -> str: + """Le titre est le semestre, ex ""DUT Informatique semestre 2"" """ + if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID: + return self.titre + return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}" + + def get_abs_count(self, etudid): + """Les comptes d'absences de cet étudiant dans ce semestre: + tuple (nb abs non justifiées, nb abs justifiées) + Utilise un cache. + """ + from app.scodoc import sco_abs + + return sco_abs.get_abs_count_in_interval( + etudid, self.date_debut.isoformat(), self.date_fin.isoformat() + ) + + def get_inscrits(self, include_dem=False) -> list: + """Liste des étudiants inscrits à ce semestre + Si all, tous les étudiants, avec les démissionnaires. + """ + if include_dem: + return [ins.etud for ins in self.inscriptions] + else: + return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre @@ -100,7 +262,7 @@ notes_formsemestre_responsables = db.Table( ) -class NotesFormsemestreEtape(db.Model): +class FormSemestreEtape(db.Model): """Étape Apogée associées au semestre""" __tablename__ = "notes_formsemestre_etapes" @@ -109,10 +271,16 @@ class NotesFormsemestreEtape(db.Model): db.Integer, db.ForeignKey("notes_formsemestre.id"), ) - etape_apo = db.Column(db.String(APO_CODE_STR_LEN)) + etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True) + + def __repr__(self): + return f"" + + def as_apovdi(self): + return ApoEtapeVDI(self.etape_apo) -class NotesFormModalite(db.Model): +class FormationModalite(db.Model): """Modalités de formation, utilisées pour la présentation (grouper les semestres, générer des codes, etc.) """ @@ -139,7 +307,7 @@ class NotesFormModalite(db.Model): numero = 0 try: for (code, titre) in ( - (NotesFormModalite.DEFAULT_MODALITE, "Formation Initiale"), + (FormationModalite.DEFAULT_MODALITE, "Formation Initiale"), ("FAP", "Apprentissage"), ("FC", "Formation Continue"), ("DEC", "Formation Décalées"), @@ -150,9 +318,9 @@ class NotesFormModalite(db.Model): ("EXT", "Extérieur"), ("OTHER", "Autres formations"), ): - modalite = NotesFormModalite.query.filter_by(modalite=code).first() + modalite = FormationModalite.query.filter_by(modalite=code).first() if modalite is None: - modalite = NotesFormModalite( + modalite = FormationModalite( modalite=code, titre=titre, numero=numero ) db.session.add(modalite) @@ -163,7 +331,7 @@ class NotesFormModalite(db.Model): raise -class NotesFormsemestreUECoef(db.Model): +class FormSemestreUECoef(db.Model): """Coef des UE capitalisees arrivant dans ce semestre""" __tablename__ = "notes_formsemestre_uecoef" @@ -182,7 +350,7 @@ class NotesFormsemestreUECoef(db.Model): coefficient = db.Column(db.Float, nullable=False) -class NotesFormsemestreUEComputationExpr(db.Model): +class FormSemestreUEComputationExpr(db.Model): """Formules utilisateurs pour calcul moyenne UE""" __tablename__ = "notes_formsemestre_ue_computation_expr" @@ -202,7 +370,7 @@ class NotesFormsemestreUEComputationExpr(db.Model): computation_expr = db.Column(db.Text()) -class NotesFormsemestreCustomMenu(db.Model): +class FormSemestreCustomMenu(db.Model): """Menu custom associe au semestre""" __tablename__ = "notes_formsemestre_custommenu" @@ -218,7 +386,7 @@ class NotesFormsemestreCustomMenu(db.Model): idx = db.Column(db.Integer, default=0, server_default="0") # rang dans le menu -class NotesFormsemestreInscription(db.Model): +class FormSemestreInscription(db.Model): """Inscription à un semestre de formation""" __tablename__ = "notes_formsemestre_inscription" @@ -227,100 +395,30 @@ class NotesFormsemestreInscription(db.Model): id = db.Column(db.Integer, primary_key=True) formsemestre_inscription_id = db.synonym("id") - etudid = db.Column(db.Integer, db.ForeignKey("identite.id")) + etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True) formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), + index=True, + ) + etud = db.relationship( + Identite, + backref=db.backref("formsemestre_inscriptions", cascade="all, delete-orphan"), + ) + formsemestre = db.relationship( + FormSemestre, + backref=db.backref( + "inscriptions", + cascade="all, delete-orphan", + order_by="FormSemestreInscription.etudid", + ), ) # I inscrit, D demission en cours de semestre, DEF si "defaillant" - etat = db.Column(db.String(CODE_STR_LEN)) + etat = db.Column(db.String(CODE_STR_LEN), index=True) # etape apogee d'inscription (experimental 2020) etape = db.Column(db.String(APO_CODE_STR_LEN)) -class NotesModuleImpl(db.Model): - """Mise en oeuvre d'un module pour une annee/semestre""" - - __tablename__ = "notes_moduleimpl" - __table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),) - - id = db.Column(db.Integer, primary_key=True) - moduleimpl_id = db.synonym("id") - module_id = db.Column( - db.Integer, - db.ForeignKey("notes_modules.id"), - ) - formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - index=True, - ) - responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")) - # formule de calcul moyenne: - computation_expr = db.Column(db.Text()) - - -# Enseignants (chargés de TD ou TP) d'un moduleimpl -notes_modules_enseignants = db.Table( - "notes_modules_enseignants", - db.Column( - "moduleimpl_id", - db.Integer, - db.ForeignKey("notes_moduleimpl.id"), - ), - db.Column("ens_id", db.Integer, db.ForeignKey("user.id")), - # ? db.UniqueConstraint("moduleimpl_id", "ens_id"), -) -# XXX il manque probablement une relation pour gérer cela - - -class NotesModuleImplInscription(db.Model): - """Inscription à un module (etudiants,moduleimpl)""" - - __tablename__ = "notes_moduleimpl_inscription" - __table_args__ = (db.UniqueConstraint("moduleimpl_id", "etudid"),) - - id = db.Column(db.Integer, primary_key=True) - moduleimpl_inscription_id = db.synonym("id") - moduleimpl_id = db.Column( - db.Integer, - db.ForeignKey("notes_moduleimpl.id"), - index=True, - ) - etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True) - - -class NotesEvaluation(db.Model): - """Evaluation (contrôle, examen, ...)""" - - __tablename__ = "notes_evaluation" - - id = db.Column(db.Integer, primary_key=True) - evaluation_id = db.synonym("id") - moduleimpl_id = db.Column( - db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True - ) - jour = db.Column(db.Date) - heure_debut = db.Column(db.Time) - heure_fin = db.Column(db.Time) - description = db.Column(db.Text) - note_max = db.Column(db.Float) - coefficient = db.Column(db.Float) - visibulletin = db.Column( - db.Boolean, nullable=False, default=True, server_default="true" - ) - publish_incomplete = db.Column( - db.Boolean, nullable=False, default=False, server_default="false" - ) - # type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session" - evaluation_type = db.Column( - db.Integer, nullable=False, default=0, server_default="0" - ) - # ordre de presentation (par défaut, le plus petit numero - # est la plus ancienne eval): - numero = db.Column(db.Integer) - - class NotesSemSet(db.Model): """semsets: ensemble de formsemestres pour exports Apogée""" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py new file mode 100644 index 00000000..172fe076 --- /dev/null +++ b/app/models/moduleimpls.py @@ -0,0 +1,126 @@ +# -*- coding: UTF-8 -* +"""ScoDoc models: moduleimpls +""" +import pandas as pd + +from app import db +from app.comp import df_cache +from app.models import UniteEns, Identite + +import app.scodoc.notesdb as ndb +from app.scodoc import sco_utils as scu + + +class ModuleImpl(db.Model): + """Mise en oeuvre d'un module pour une annee/semestre""" + + __tablename__ = "notes_moduleimpl" + __table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),) + + id = db.Column(db.Integer, primary_key=True) + moduleimpl_id = db.synonym("id") + module_id = db.Column( + db.Integer, + db.ForeignKey("notes_modules.id"), + ) + formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + index=True, + ) + responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")) + # formule de calcul moyenne: + computation_expr = db.Column(db.Text()) + + evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl") + enseignants = db.relationship( + "User", + secondary="notes_modules_enseignants", + lazy="dynamic", + backref="moduleimpl", + viewonly=True, + ) + + def __init__(self, **kwargs): + super(ModuleImpl, self).__init__(**kwargs) + + def get_evaluations_poids(self) -> pd.DataFrame: + """Les poids des évaluations vers les UE (accès via cache)""" + evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) + if evaluations_poids is None: + from app.comp import moy_mod + + evaluations_poids, _ = moy_mod.df_load_evaluations_poids(self.id) + df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) + return evaluations_poids + + def invalidate_evaluations_poids(self): + """Invalide poids cachés""" + df_cache.EvaluationsPoidsCache.delete(self.id) + + def check_apc_conformity(self) -> bool: + """true si les poids des évaluations du module permettent de satisfaire + les coefficients du PN. + """ + if not self.module.formation.get_parcours().APC_SAE or ( + self.module.module_type != scu.ModuleType.RESSOURCE + and self.module.module_type != scu.ModuleType.SAE + ): + return True + from app.comp import moy_mod + + return moy_mod.check_moduleimpl_conformity( + self, + self.get_evaluations_poids(), + self.module.formation.get_module_coefs(self.module.semestre_id), + ) + + def to_dict(self): + """as a dict, with the same conversions as in ScoDoc7""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["moduleimpl_id"] = self.id + e["ens"] = [ + {"moduleimpl_id": self.id, "ens_id": e.id} for e in self.enseignants + ] + e["module"] = self.module.to_dict() + return e + + +# Enseignants (chargés de TD ou TP) d'un moduleimpl +notes_modules_enseignants = db.Table( + "notes_modules_enseignants", + db.Column( + "moduleimpl_id", + db.Integer, + db.ForeignKey("notes_moduleimpl.id"), + ), + db.Column("ens_id", db.Integer, db.ForeignKey("user.id")), + # ? db.UniqueConstraint("moduleimpl_id", "ens_id"), +) +# XXX il manque probablement une relation pour gérer cela + + +class ModuleImplInscription(db.Model): + """Inscription à un module (etudiants,moduleimpl)""" + + __tablename__ = "notes_moduleimpl_inscription" + __table_args__ = (db.UniqueConstraint("moduleimpl_id", "etudid"),) + + id = db.Column(db.Integer, primary_key=True) + moduleimpl_inscription_id = db.synonym("id") + moduleimpl_id = db.Column( + db.Integer, + db.ForeignKey("notes_moduleimpl.id"), + index=True, + ) + etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True) + etud = db.relationship( + Identite, + backref=db.backref("moduleimpl_inscriptions", cascade="all, delete-orphan"), + ) + modimpl = db.relationship( + ModuleImpl, + backref=db.backref("inscriptions", cascade="all, delete-orphan"), + ) diff --git a/app/models/modules.py b/app/models/modules.py new file mode 100644 index 00000000..00a6d4c9 --- /dev/null +++ b/app/models/modules.py @@ -0,0 +1,204 @@ +"""ScoDoc 9 models : Modules +""" + +from app import db +from app.models import APO_CODE_STR_LEN +from app.scodoc import sco_utils as scu +from app.scodoc.sco_utils import ModuleType + + +class Module(db.Model): + """Module""" + + __tablename__ = "notes_modules" + + id = db.Column(db.Integer, primary_key=True) + module_id = db.synonym("id") + titre = db.Column(db.Text()) + abbrev = db.Column(db.Text()) # nom court + # certains départements ont des codes infiniment longs: donc Text ! + code = db.Column(db.Text(), nullable=False) + heures_cours = db.Column(db.Float) + heures_td = db.Column(db.Float) + heures_tp = db.Column(db.Float) + coefficient = db.Column(db.Float) # coef PPN (sauf en APC) + ects = db.Column(db.Float) # Crédits ECTS + ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), index=True) + formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) + matiere_id = db.Column(db.Integer, db.ForeignKey("notes_matieres.id")) + # pas un id mais le numéro du semestre: 1, 2, ... + # note: en APC, le semestre qui fait autorité est celui de l'UE + semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") + numero = db.Column(db.Integer) # ordre de présentation + # id de l'element pedagogique Apogee correspondant: + code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) + # Type: ModuleType: DEFAULT, MALUS, RESSOURCE, MODULE_SAE (enum) + module_type = db.Column(db.Integer) + # Relations: + modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic") + ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True) + tags = db.relationship( + "NotesTag", + secondary="notes_modules_tags", + lazy=True, + backref=db.backref("modules", lazy=True), + ) + + def __init__(self, **kwargs): + self.ue_coefs = [] + super(Module, self).__init__(**kwargs) + + def __repr__(self): + return ( + f"" + ) + + def to_dict(self): + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["module_id"] = self.id + e["heures_cours"] = 0.0 if self.heures_cours is None else self.heures_cours + e["heures_td"] = 0.0 if self.heures_td is None else self.heures_td + e["heures_tp"] = 0.0 if self.heures_tp is None else self.heures_tp + e["numero"] = 0 if self.numero is None else self.numero + e["coefficient"] = 0.0 if self.coefficient is None else self.coefficient + e["module_type"] = 0 if self.module_type is None else self.module_type + return e + + def is_apc(self): + "True si module SAÉ ou Ressource" + return self.module_type and scu.ModuleType(self.module_type) in { + scu.ModuleType.RESSOURCE, + scu.ModuleType.SAE, + } + + def type_name(self): + return scu.MODULE_TYPE_NAMES[self.module_type] + + def set_ue_coef(self, ue, coef: float) -> None: + """Set coef module vers cette UE""" + self.update_ue_coef_dict({ue.id: coef}) + + def set_ue_coef_dict(self, ue_coef_dict: dict) -> None: + """set coefs vers les UE (remplace existants) + ue_coef_dict = { ue_id : coef } + Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée. + """ + changed = False + for ue_id, coef in ue_coef_dict.items(): + # Existant ? + coefs = [c for c in self.ue_coefs if c.ue_id == ue_id] + if coefs: + ue_coef = coefs[0] + if coef == 0.0: # supprime ce coef + db.session.delete(ue_coef) + changed = True + elif coef != ue_coef.coef: + ue_coef.coef = coef + db.session.add(ue_coef) + changed = True + else: + # crée nouveau coef: + if coef != 0.0: + ue = UniteEns.query.get(ue_id) + ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef) + self.ue_coefs.append(ue_coef) + changed = True + if changed: + self.formation.invalidate_module_coefs() + + def update_ue_coef_dict(self, ue_coef_dict: dict): + """update coefs vers UE (ajoute aux existants)""" + current = self.get_ue_coef_dict() + current.update(ue_coef_dict) + self.set_ue_coef_dict(current) + + def get_ue_coef_dict(self): + """returns { ue_id : coef }""" + return {p.ue.id: p.coef for p in self.ue_coefs} + + def delete_ue_coef(self, ue): + """delete coef""" + ue_coef = ModuleUECoef.query.get((self.id, ue.id)) + if ue_coef: + db.session.delete(ue_coef) + self.formation.invalidate_module_coefs() + + def get_ue_coefs_sorted(self): + "les coefs d'UE, trié par numéro d'UE" + # je n'ai pas su mettre un order_by sur le backref sans avoir + # à redéfinir les relationships... + return sorted(self.ue_coefs, key=lambda x: x.ue.numero) + + def ue_coefs_descr(self): + """List of tuples [ (ue_acronyme, coef) ]""" + return [(c.ue.acronyme, c.coef) for c in self.get_ue_coefs_sorted()] + + +class ModuleUECoef(db.Model): + """Coefficients des modules vers les UE (APC, BUT) + En mode APC, ces coefs remplacent le coefficient "PPN" du module. + """ + + __tablename__ = "module_ue_coef" + + module_id = db.Column( + db.Integer, + db.ForeignKey("notes_modules.id", ondelete="CASCADE"), + primary_key=True, + ) + ue_id = db.Column( + db.Integer, + db.ForeignKey("notes_ue.id", ondelete="CASCADE"), + primary_key=True, + ) + coef = db.Column( + db.Float, + nullable=False, + ) + module = db.relationship( + Module, + backref=db.backref( + "ue_coefs", + passive_deletes=True, + cascade="save-update, merge, delete, delete-orphan", + ), + ) + ue = db.relationship( + "UniteEns", + backref=db.backref( + "module_ue_coefs", + passive_deletes=True, + cascade="save-update, merge, delete, delete-orphan", + ), + ) + + +class NotesTag(db.Model): + """Tag sur un module""" + + __tablename__ = "notes_tags" + __table_args__ = (db.UniqueConstraint("title", "dept_id"),) + + id = db.Column(db.Integer, primary_key=True) + tag_id = db.synonym("id") + + dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) + title = db.Column(db.Text(), nullable=False) + + +# Association tag <-> module +notes_modules_tags = db.Table( + "notes_modules_tags", + db.Column( + "tag_id", + db.Integer, + db.ForeignKey("notes_tags.id", ondelete="CASCADE"), + ), + db.Column( + "module_id", db.Integer, db.ForeignKey("notes_modules.id", ondelete="CASCADE") + ), +) + +from app.models.ues import UniteEns diff --git a/app/models/notes.py b/app/models/notes.py index 0d0be637..df2766d0 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -40,7 +40,7 @@ class ScolarEvent(db.Model): ) -class ScolarFormsemestreValidation(db.Model): +class ScolarFormSemestreValidation(db.Model): """Décisions de jury""" __tablename__ = "scolar_formsemestre_validation" @@ -52,16 +52,19 @@ class ScolarFormsemestreValidation(db.Model): etudid = db.Column( db.Integer, db.ForeignKey("identite.id"), + index=True, ) formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), + index=True, ) ue_id = db.Column( db.Integer, db.ForeignKey("notes_ue.id"), + index=True, ) - code = db.Column(db.String(CODE_STR_LEN), nullable=False) + code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) # NULL pour les UE, True|False pour les semestres: assidu = db.Column(db.Boolean) event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) @@ -100,9 +103,10 @@ class ScolarAutorisationInscription(db.Model): ) -class NotesAppreciations(db.Model): +class BulAppreciations(db.Model): """Appréciations sur bulletins""" + __tablename__ = "notes_appreciations" id = db.Column(db.Integer, primary_key=True) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) etudid = db.Column( diff --git a/app/models/preferences.py b/app/models/preferences.py index b04ad0da..59c82ec8 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -4,6 +4,7 @@ """ from app import db, log from app.scodoc import bonus_sport +from app.scodoc.sco_exceptions import ScoValueError class ScoPreference(db.Model): @@ -94,7 +95,7 @@ class ScoDocSiteConfig(db.Model): """returns bonus func with specified name. If name not specified, return the configured function. None if no bonus function configured. - Raises NameError if func_name not found in module bonus_sport. + Raises ScoValueError if func_name not found in module bonus_sport. """ if func_name is None: c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() @@ -103,7 +104,13 @@ class ScoDocSiteConfig(db.Model): func_name = c.value if func_name == "": # pas de bonus défini return None - return getattr(bonus_sport, func_name) + try: + return getattr(bonus_sport, func_name) + except AttributeError: + raise ScoValueError( + f"""Fonction de calcul maison inexistante: {func_name}. + (contacter votre administrateur local).""" + ) @classmethod def get_bonus_sport_func_names(cls): diff --git a/app/models/ues.py b/app/models/ues.py new file mode 100644 index 00000000..5f99bdc2 --- /dev/null +++ b/app/models/ues.py @@ -0,0 +1,99 @@ +"""ScoDoc 9 models : Unités d'Enseignement (UE) +""" + +from app import db +from app.models import APO_CODE_STR_LEN +from app.models import SHORT_STR_LEN +from app.scodoc import notesdb as ndb +from app.scodoc import sco_utils as scu + + +class UniteEns(db.Model): + """Unité d'Enseignement (UE)""" + + __tablename__ = "notes_ue" + + id = db.Column(db.Integer, primary_key=True) + ue_id = db.synonym("id") + formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) + acronyme = db.Column(db.Text(), nullable=False) + numero = db.Column(db.Integer) # ordre de présentation + titre = db.Column(db.Text()) + # Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ... + # En ScoDoc7 et pour les formations classiques, il est NULL + # (le numéro du semestre étant alors déterminé par celui des modules de l'UE) + # Pour les formations APC, il est obligatoire (de 1 à 6 pour le BUT): + semestre_idx = db.Column(db.Integer, nullable=True, index=True) + # Type d'UE: 0 normal ("fondamentale"), 1 "sport", 2 "projet et stage (LP)", + # 4 "élective" + type = db.Column(db.Integer, default=0, server_default="0") + # Les UE sont "compatibles" (pour la capitalisation) ssi elles ont ^m code + # note: la fonction SQL notes_newid_ucod doit être créée à part + ue_code = db.Column( + db.String(SHORT_STR_LEN), + server_default=db.text("notes_newid_ucod()"), + nullable=False, + ) + ects = db.Column(db.Float) # nombre de credits ECTS + is_external = db.Column(db.Boolean(), default=False, server_default="false") + # id de l'element pedagogique Apogee correspondant: + code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) + # coef UE, utilise seulement si l'option use_ue_coefs est activée: + coefficient = db.Column(db.Float) + + # relations + matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") + modules = db.relationship("Module", lazy="dynamic", backref="ue") + + def __repr__(self): + return f"<{self.__class__.__name__}(id={self.id}, formation_id={self.formation_id}, acronyme='{self.acronyme}')>" + + def to_dict(self): + """as a dict, with the same conversions as in ScoDoc7""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators + e["ue_id"] = self.id + e["numero"] = e["numero"] if e["numero"] else 0 + e["ects"] = e["ects"] if e["ects"] else 0.0 + e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 + return e + + def is_locked(self): + """True if UE should not be modified + (contains modules used in a locked formsemestre) + """ + # XXX todo : à ré-écrire avec SQLAlchemy + from app.scodoc import sco_edit_ue + + return sco_edit_ue.ue_is_locked(self.id) + + def guess_semestre_idx(self) -> None: + """Lorsqu'on prend une ancienne formation non APC, + les UE n'ont pas d'indication de semestre. + Cette méthode fixe le semestre en prenant celui du premier module, + ou à défaut le met à 1. + """ + if self.semestre_idx is None: + module = self.modules.first() + if module is None: + self.semestre_idx = 1 + else: + self.semestre_idx = module.semestre_id + db.session.add(self) + db.session.commit() + + def get_ressources(self): + "Liste des modules ressources rattachés à cette UE" + return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all() + + def get_saes(self): + "Liste des modules SAE rattachés à cette UE" + return self.modules.filter_by(module_type=scu.ModuleType.SAE).all() + + def get_modules_not_apc(self): + "Listes des modules non SAE et non ressource (standards, mais aussi bonus...)" + return self.modules.filter( + (Module.module_type != scu.ModuleType.SAE), + (Module.module_type != scu.ModuleType.RESSOURCE), + ).all() diff --git a/app/pe/pe_tools.py b/app/pe/pe_tools.py index 46e706ee..99adbedd 100644 --- a/app/pe/pe_tools.py +++ b/app/pe/pe_tools.py @@ -44,6 +44,7 @@ import unicodedata import app.scodoc.sco_utils as scu from app import log +from app.scodoc.sco_logos import find_logo PE_DEBUG = 0 @@ -201,11 +202,11 @@ def add_pe_stuff_to_zip(zipfile, ziproot): add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename) # Logos: (add to logos/ directory in zip) - logos_names = ["logo_header.jpg", "logo_footer.jpg"] - for f in logos_names: - logo = os.path.join(scu.SCODOC_LOGOS_DIR, f) - if os.path.isfile(logo): - add_local_file_to_zip(zipfile, ziproot, logo, "avis/logos/" + f) + logos_names = ["header", "footer"] + for name in logos_names: + logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) + if logo is not None: + add_local_file_to_zip(zipfile, ziproot, logo, "avis/logos/" + logo.filename) # ---------------------------------------------------------------------------------------- diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index e324742c..acbe8d8c 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -51,7 +51,7 @@ def TrivialFormulator( allow_null : if true, field can be left empty (default true) type : 'string', 'int', 'float' (default to string), 'list' (only for hidden) readonly : default False. if True, no form element, display current value. - convert_numbers: covert int and float values (from string) + convert_numbers: convert int and float values (from string) allowed_values : list of possible values (default: any value) validator : function validating the field (called with (value,field)). min_value : minimum value (for floats and ints) @@ -334,7 +334,7 @@ class TF(object): buttons_markup = "" if self.submitbutton: buttons_markup += ( - '' + '' % ( self.formid, self.formid, @@ -344,7 +344,7 @@ class TF(object): ) if self.cancelbutton: buttons_markup += ( - ' ' + ' ' % (self.formid, self.formid, self.cancelbutton) ) @@ -364,7 +364,7 @@ class TF(object): '
' % (self.form_url, self.method, self.formid, enctype, name, klass) ) - R.append('' % self.formid) + R.append('' % self.formid) if self.top_buttons: R.append(buttons_markup + "

") R.append('') @@ -406,7 +406,7 @@ class TF(object): else: checked = "" lab.append( - '' + '' % ("tf-checked", field, checked) ) if title_bubble: @@ -439,13 +439,13 @@ class TF(object): add_no_enter_js = True # lem.append('onchange="document.%s.%s.focus()"'%(name,nextitemname)) # lem.append('onblur="document.%s.%s.focus()"'%(name,nextitemname)) - lem.append(('value="%(' + field + ')s" />') % values) + lem.append(('value="%(' + field + ')s" >') % values) elif input_type == "password": lem.append( '') % values) + lem.append(('value="%(' + field + ')s" >') % values) elif input_type == "radio": labels = descr.get("labels", descr["allowed_values"]) for i in range(len(labels)): @@ -549,24 +549,24 @@ class TF(object): if descr.get("type", "") == "list": for v in values[field]: lem.append( - '' + '' % (field, v, attribs) ) else: lem.append( - '' + '' % (field, wid, values[field], attribs) ) elif input_type == "separator": pass elif input_type == "file": lem.append( - '' + '' % (field, size, values[field], attribs) ) elif input_type == "date": # JavaScript widget for date input lem.append( - '' + '' % (field, values[field]) ) elif input_type == "text_suggest": @@ -716,7 +716,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); bool_val = 0 R.append(labels[bool_val]) if bool_val: - R.append('' % field) + R.append('' % field) else: labels = descr.get("labels", descr["allowed_values"]) for i in range(len(labels)): diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py index a986494e..78a0f6a2 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -498,7 +498,7 @@ class GenTable(object): headline = [] return "\n".join( [ - self.text_fields_separator.join([x for x in line]) + self.text_fields_separator.join([str(x) for x in line]) for line in headline + self.get_data_list() ] ) diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 7e35c3b3..faf1fcf3 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -87,15 +87,18 @@ Problème de connexion (identifiant, mot de passe): contacter votre responsa ) -_HTML_BEGIN = """ - - +_HTML_BEGIN = """ + + -%(page_title)s + + + +%(page_title)s @@ -125,7 +128,7 @@ _HTML_BEGIN = """ def scodoc_top_html_header(page_title="ScoDoc: bienvenue"): H = [ _HTML_BEGIN % {"page_title": page_title, "encoding": scu.SCO_ENCODING}, - """""", + """""", scu.CUSTOM_HTML_HEADER_CNX, ] return "\n".join(H) @@ -140,8 +143,6 @@ def sco_header( javascripts=[], # additionals JS filenames to load scripts=[], # script to put in page header bodyOnLoad="", # JS - init_jquery=True, # load and init jQuery - init_jquery_ui=True, # include all stuff for jquery-ui and initialize scripts init_qtip=False, # include qTip init_google_maps=False, # Google maps init_datatables=True, @@ -176,9 +177,6 @@ def sco_header( else: params["margin_left"] = "140px" - if init_jquery_ui or init_qtip or init_datatables: - init_jquery = True - H = [ """ @@ -191,11 +189,10 @@ def sco_header( % params ] # jQuery UI - if init_jquery_ui: - # can modify loaded theme here - H.append( - '\n' - ) + # can modify loaded theme here + H.append( + '\n' + ) if init_google_maps: # It may be necessary to add an API key: H.append('') @@ -224,12 +221,11 @@ def sco_header( ) # jQuery - if init_jquery: - H.append( - """ - """ - ) - H.append('') + H.append( + """ + """ + ) + H.append('') # qTip if init_qtip: H.append( @@ -239,12 +235,11 @@ def sco_header( '' ) - if init_jquery_ui: - H.append( - '' - ) - # H.append('') - H.append('') + H.append( + '' + ) + + H.append('') if init_google_maps: H.append( '' @@ -260,7 +255,7 @@ def sco_header( H.append( """