diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..b7f21468 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] + max-line-length = 88 + ignore = E203,W503 \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 94896412..62e9be5d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,10 +1,24 @@ - [MASTER] -load-plugins=pylint_flask_sqlalchemy,pylint_flask -[MESSAGES CONTROL] -# pylint and black disagree... -disable=bad-continuation +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins=pylint_flask [TYPECHECK] -ignored-classes=Permission,SQLObject,Registrant,scoped_session +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=Permission, + SQLObject, + Registrant, + scoped_session, + func + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules=entreprises + +good-names=d,df,e,f,i,j,k,n,nt,t,u,ue,v,x,y,z,H,F + diff --git a/app/api/__init__.py b/app/api/__init__.py index bb8f6cc5..b94cf855 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -5,7 +5,7 @@ from flask import Blueprint from flask import request, g from app import db from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import ScoException +from app.scodoc.sco_exceptions import AccessDenied, ScoException api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -15,12 +15,24 @@ API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client @api_bp.errorhandler(ScoException) +@api_web_bp.errorhandler(ScoException) @api_bp.errorhandler(404) def api_error_handler(e): "erreurs API => json" return scu.json_error(404, message=str(e)) +@api_bp.errorhandler(AccessDenied) +@api_web_bp.errorhandler(AccessDenied) +def permission_denied_error_handler(exc): + """ + Renvoie message d'erreur pour l'erreur 403 + """ + return scu.json_error( + 403, f"operation non autorisee ({exc.args[0] if exc.args else ''})" + ) + + 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 @@ -54,7 +66,6 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model from app.api import tokens from app.api import ( - absences, assiduites, billets_absences, departements, @@ -65,6 +76,7 @@ from app.api import ( jury, justificatifs, logos, + moduleimpl, partitions, semset, users, diff --git a/app/api/absences.py b/app/api/absences.py deleted file mode 100644 index acd690ff..00000000 --- a/app/api/absences.py +++ /dev/null @@ -1,263 +0,0 @@ -############################################################################## -# ScoDoc -# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. -# See LICENSE -############################################################################## -"""ScoDoc 9 API : Absences -""" - -from flask_json import as_json - -from app import db -from app.api import api_bp as bp, API_CLIENT_ERROR -from app.scodoc.sco_utils import json_error -from app.decorators import scodoc, permission_required -from app.models import Identite - -from app.scodoc import notesdb as ndb -from app.scodoc import sco_abs - -from app.scodoc.sco_groups import get_group_members -from app.scodoc.sco_permissions import Permission - - -# TODO XXX revoir routes web API et calcul des droits -@bp.route("/absences/etudid/", methods=["GET"]) -@scodoc -@permission_required(Permission.ScoView) -@as_json -def absences(etudid: int = None): - """ - Liste des absences de cet étudiant - - Exemple de résultat: - [ - { - "jour": "2022-04-15", - "matin": true, - "estabs": true, - "estjust": true, - "description": "", - "begin": "2022-04-15 08:00:00", - "end": "2022-04-15 11:59:59" - }, - { - "jour": "2022-04-15", - "matin": false, - "estabs": true, - "estjust": false, - "description": "", - "begin": "2022-04-15 12:00:00", - "end": "2022-04-15 17:59:59" - } - ] - """ - etud = db.session.get(Identite, etudid) - if etud is None: - return json_error(404, message="etudiant inexistant") - # Absences de l'étudiant - ndb.open_db_connection() - abs_list = sco_abs.list_abs_date(etud.id) - for absence in abs_list: - absence["jour"] = absence["jour"].isoformat() - return abs_list - - -@bp.route("/absences/etudid//just", methods=["GET"]) -@scodoc -@permission_required(Permission.ScoView) -@as_json -def absences_just(etudid: int = None): - """ - Retourne la liste des absences justifiées d'un étudiant donné - - etudid : l'etudid d'un étudiant - nip: le code nip d'un étudiant - ine : le code ine d'un étudiant - - Exemple de résultat : - [ - { - "jour": "2022-04-15", - "matin": true, - "estabs": true, - "estjust": true, - "description": "", - "begin": "2022-04-15 08:00:00", - "end": "2022-04-15 11:59:59" - }, - { - "jour": "Fri, 15 Apr 2022 00:00:00 GMT", - "matin": false, - "estabs": true, - "estjust": true, - "description": "", - "begin": "2022-04-15 12:00:00", - "end": "2022-04-15 17:59:59" - } - ] - """ - etud = db.session.get(Identite, etudid) - if etud is None: - return json_error(404, message="etudiant inexistant") - - # Absences justifiées de l'étudiant - abs_just = [ - absence for absence in sco_abs.list_abs_date(etud.id) if absence["estjust"] - ] - for absence in abs_just: - absence["jour"] = absence["jour"].isoformat() - return abs_just - - -@bp.route( - "/absences/abs_group_etat/", - methods=["GET"], -) -@bp.route( - "/absences/abs_group_etat/group_id//date_debut//date_fin/", - methods=["GET"], -) -@scodoc -@permission_required(Permission.ScoView) -@as_json -def abs_groupe_etat(group_id: int, date_debut=None, date_fin=None): - """ - Liste des absences d'un groupe (possibilité de choisir entre deux dates) - - group_id = l'id du groupe - date_debut = None par défaut, sinon la date ISO du début de notre filtre - date_fin = None par défaut, sinon la date ISO de la fin de notre filtre - - Exemple de résultat : - [ - { - "etudid": 1, - "list_abs": [] - }, - { - "etudid": 2, - "list_abs": [ - { - "jour": "Fri, 15 Apr 2022 00:00:00 GMT", - "matin": true, - "estabs": true, - "estjust": true, - "description": "", - "begin": "2022-04-15 08:00:00", - "end": "2022-04-15 11:59:59" - }, - { - "jour": "Fri, 15 Apr 2022 00:00:00 GMT", - "matin": false, - "estabs": true, - "estjust": false, - "description": "", - "begin": "2022-04-15 12:00:00", - "end": "2022-04-15 17:59:59" - }, - ] - }, - ... - ] - """ - members = get_group_members(group_id) - - data = [] - # Filtre entre les deux dates renseignées - for member in members: - absence = { - "etudid": member["etudid"], - "list_abs": sco_abs.list_abs_date(member["etudid"], date_debut, date_fin), - } - data.append(absence) - - return data - - -# XXX TODO EV: A REVOIR (data json dans le POST + modifier les routes) -# @bp.route( -# "/absences/etudid//list_abs//reset_etud_abs", -# methods=["POST"], -# defaults={"just_or_not": 0}, -# ) -# @bp.route( -# "/absences/etudid//list_abs//reset_etud_abs/only_not_just", -# methods=["POST"], -# defaults={"just_or_not": 1}, -# ) -# @bp.route( -# "/absences/etudid//list_abs//reset_etud_abs/only_just", -# methods=["POST"], -# defaults={"just_or_not": 2}, -# ) -# @token_auth.login_required -# @token_permission_required(Permission.APIAbsChange) -# def reset_etud_abs(etudid: int, list_abs: str, just_or_not: int = 0): -# """ -# Set la liste des absences d'un étudiant sur tout un semestre. -# (les absences existant pour cet étudiant sur cette période sont effacées) - -# etudid : l'id d'un étudiant -# list_abs : json d'absences -# just_or_not : 0 (pour les absences justifiées et non justifiées), -# 1 (pour les absences justifiées), -# 2 (pour les absences non justifiées) -# """ -# # Toutes les absences -# if just_or_not == 0: -# # suppression des absences et justificatif déjà existant pour éviter les doublons -# for abs in list_abs: -# # Récupération de la date au format iso -# jour = abs["jour"].isoformat() -# if abs["matin"] is True: -# annule_absence(etudid, jour, True) -# annule_justif(etudid, jour, True) -# else: -# annule_absence(etudid, jour, False) -# annule_justif(etudid, jour, False) - -# # Ajout de la liste d'absences en base -# add_abslist(list_abs) - -# # Uniquement les absences justifiées -# elif just_or_not == 1: -# list_abs_not_just = [] -# # Trie des absences justifiées -# for abs in list_abs: -# if abs["estjust"] is False: -# list_abs_not_just.append(abs) -# # suppression des absences et justificatif déjà existant pour éviter les doublons -# for abs in list_abs: -# # Récupération de la date au format iso -# jour = abs["jour"].isoformat() -# if abs["matin"] is True: -# annule_absence(etudid, jour, True) -# annule_justif(etudid, jour, True) -# else: -# annule_absence(etudid, jour, False) -# annule_justif(etudid, jour, False) - -# # Ajout de la liste d'absences en base -# add_abslist(list_abs_not_just) - -# # Uniquement les absences non justifiées -# elif just_or_not == 2: -# list_abs_just = [] -# # Trie des absences non justifiées -# for abs in list_abs: -# if abs["estjust"] is True: -# list_abs_just.append(abs) -# # suppression des absences et justificatif déjà existant pour éviter les doublons -# for abs in list_abs: -# # Récupération de la date au format iso -# jour = abs["jour"].isoformat() -# if abs["matin"] is True: -# annule_absence(etudid, jour, True) -# annule_justif(etudid, jour, True) -# else: -# annule_absence(etudid, jour, False) -# annule_justif(etudid, jour, False) - -# # Ajout de la liste d'absences en base -# add_abslist(list_abs_just) diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 48f54db6..d3f216c0 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -14,6 +14,7 @@ from flask_login import current_user, login_required from app import db, log import app.scodoc.sco_assiduites as scass import app.scodoc.sco_utils as scu +from app.scodoc import sco_preferences from app.api import api_bp as bp from app.api import api_web_bp, get_model_api_object, tools from app.decorators import permission_required, scodoc @@ -25,6 +26,7 @@ from app.models import ( Scolog, Justificatif, ) +from flask_sqlalchemy.query import Query from app.models.assiduites import get_assiduites_justif from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission @@ -256,7 +258,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) 404, message="étudiant inconnu", ) - assiduites_query = etud.assiduites + assiduites_query: Query = etud.assiduites if with_query: assiduites_query = _filter_manager(request, assiduites_query) @@ -372,7 +374,9 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): if formsemestre is None: return json_error(404, "le paramètre 'formsemestre_id' n'existe pas") - assiduites_query = scass.filter_by_formsemestre(Assiduite.query,Assiduite, formsemestre) + assiduites_query = scass.filter_by_formsemestre( + Assiduite.query, Assiduite, formsemestre + ) if with_query: assiduites_query = _filter_manager(request, assiduites_query) @@ -597,8 +601,8 @@ def _create_singular( desc: str = data.get("desc", None) - external_data = data.get("external_data", False) - if external_data is not False: + external_data = data.get("external_data", None) + if external_data is not None: if not isinstance(external_data, dict): errors.append("param 'external_data' : n'est pas un objet JSON") @@ -959,7 +963,7 @@ def _count_manager(requested) -> tuple[str, dict]: return (metric, filtered) -def _filter_manager(requested, assiduites_query: Assiduite): +def _filter_manager(requested, assiduites_query: Query) -> Query: """ Retourne les assiduites entrées filtrées en fonction de la request """ @@ -977,7 +981,7 @@ def _filter_manager(requested, assiduites_query: Assiduite): fin = scu.is_iso_formated(fin, True) if (deb, fin) != (None, None): - assiduites_query: Assiduite = scass.filter_by_date( + assiduites_query: Query = scass.filter_by_date( assiduites_query, Assiduite, deb, fin ) @@ -1015,11 +1019,11 @@ def _filter_manager(requested, assiduites_query: Assiduite): falses: tuple[str] = ("f", "faux", "false") if est_just.lower() in trues: - assiduites_query: Assiduite = scass.filter_assiduites_by_est_just( + assiduites_query: Query = scass.filter_assiduites_by_est_just( assiduites_query, True ) elif est_just.lower() in falses: - assiduites_query: Assiduite = scass.filter_assiduites_by_est_just( + assiduites_query: Query = scass.filter_assiduites_by_est_just( assiduites_query, False ) @@ -1027,7 +1031,7 @@ def _filter_manager(requested, assiduites_query: Assiduite): user_id = requested.args.get("user_id", False) if user_id is not False: - assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id) + assiduites_query: Query = scass.filter_by_user_id(assiduites_query, user_id) return assiduites_query diff --git a/app/api/departements.py b/app/api/departements.py index a5d87bb5..95a9c4e9 100644 --- a/app/api/departements.py +++ b/app/api/departements.py @@ -281,7 +281,15 @@ def dept_formsemestres_courants(acronym: str): FormSemestre.date_debut <= test_date, FormSemestre.date_fin >= test_date, ) - return [d.to_dict_api() for d in formsemestres] + return [ + d.to_dict_api() + for d in formsemestres.order_by( + FormSemestre.date_debut.desc(), + FormSemestre.modalite, + FormSemestre.semestre_id, + FormSemestre.titre, + ) + ] @bp.route("/departement/id//formsemestres_courants") diff --git a/app/api/etudiants.py b/app/api/etudiants.py index fd1acdf6..572f77ca 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -154,8 +154,6 @@ def get_photo_image(etudid: int = None, nip: str = None, ine: str = None): etudid : l'etudid de l'étudiant nip : le code nip de l'étudiant ine : le code ine de l'étudiant - - Attention : Ne peut être qu'utilisée en tant que route de département """ etud = tools.get_etud(etudid, nip, ine) @@ -176,6 +174,44 @@ def get_photo_image(etudid: int = None, nip: str = None, ine: str = None): return res +@bp.route("/etudiant/etudid//photo", methods=["POST"]) +@api_web_bp.route("/etudiant/etudid//photo", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeAdr) +@as_json +def set_photo_image(etudid: int = None): + """Enregistre la photo de l'étudiant.""" + allowed_depts = current_user.get_depts_with_permission(Permission.ScoEtudChangeAdr) + query = Identite.query.filter_by(id=etudid) + if not None in allowed_depts: + # restreint aux départements autorisés: + query = query.join(Departement).filter( + or_(Departement.acronym == acronym for acronym in allowed_depts) + ) + if g.scodoc_dept is not None: + query = query.filter_by(dept_id=g.scodoc_dept_id) + etud: Identite = query.first() + if etud is None: + return json_error(404, message="etudiant inexistant") + # Récupère l'image + if len(request.files) == 0: + return json_error(404, "Il n'y a pas de fichier joint") + + file = list(request.files.values())[0] + if not file.filename: + return json_error(404, "Il n'y a pas de fichier joint") + data = file.stream.read() + + status, err_msg = sco_photos.store_photo(etud, data, file.filename) + if status: + return {"etudid": etud.id, "message": "recorded photo"} + return json_error( + 404, + message=f"Erreur: {err_msg}", + ) + + @bp.route("/etudiants/etudid/", methods=["GET"]) @bp.route("/etudiants/nip/", methods=["GET"]) @bp.route("/etudiants/ine/", methods=["GET"]) diff --git a/app/api/evaluations.py b/app/api/evaluations.py index af1c40af..6643ea84 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -7,17 +7,17 @@ """ ScoDoc 9 API : accès aux évaluations """ - from flask import g, request from flask_json import as_json -from flask_login import login_required +from flask_login import current_user, login_required import app - +from app import log, db from app.api import api_bp as bp, api_web_bp from app.decorators import scodoc, permission_required from app.models import Evaluation, ModuleImpl, FormSemestre from app.scodoc import sco_evaluation_db, sco_saisie_notes +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu @@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu @scodoc @permission_required(Permission.ScoView) @as_json -def evaluation(evaluation_id: int): +def get_evaluation(evaluation_id: int): """Description d'une évaluation. { @@ -47,7 +47,7 @@ def evaluation(evaluation_id: int): 'UE1.3': 1.0 }, 'publish_incomplete': False, - 'visi_bulletin': True + 'visibulletin': True } """ query = Evaluation.query.filter_by(id=evaluation_id) @@ -181,3 +181,97 @@ def evaluation_set_notes(evaluation_id: int): return sco_saisie_notes.save_notes( evaluation, notes, comment=data.get("comment", "") ) + + +@bp.route("/moduleimpl//evaluation/create", methods=["POST"]) +@api_web_bp.route("/moduleimpl//evaluation/create", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEnsView) # permission gérée dans la fonction +@as_json +def evaluation_create(moduleimpl_id: int): + """Création d'une évaluation. + The request content type should be "application/json", + and contains: + { + "description" : str, + "evaluation_type" : int, // {0,1,2} default 0 (normale) + "date_debut" : date_iso, // optionnel + "date_fin" : date_iso, // optionnel + "note_max" : float, // si non spécifié, 20.0 + "numero" : int, // ordre de présentation, default tri sur date + "visibulletin" : boolean , //default true + "publish_incomplete" : boolean , //default false + "coefficient" : float, // si non spécifié, 1.0 + "poids" : { ue_id : poids } // optionnel + } + Result: l'évaluation créée. + """ + moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id) + if not moduleimpl.can_edit_evaluation(current_user): + return scu.json_error(403, "opération non autorisée") + data = request.get_json(force=True) # may raise 400 Bad Request + + try: + evaluation = Evaluation.create(moduleimpl=moduleimpl, **data) + except ValueError: + return scu.json_error(400, "paramètre incorrect") + except ScoValueError as exc: + return scu.json_error( + 400, f"paramètre de type incorrect ({exc.args[0] if exc.args else ''})" + ) + + db.session.add(evaluation) + db.session.commit() + # Les poids vers les UEs: + poids = data.get("poids") + if poids is not None: + if not isinstance(poids, dict): + log("API error: canceling evaluation creation") + db.session.delete(evaluation) + db.session.commit() + return scu.json_error( + 400, "paramètre de type incorrect (poids must be a dict)" + ) + try: + evaluation.set_ue_poids_dict(data["poids"]) + except ScoValueError as exc: + log("API error: canceling evaluation creation") + db.session.delete(evaluation) + db.session.commit() + return scu.json_error( + 400, + f"erreur enregistrement des poids ({exc.args[0] if exc.args else ''})", + ) + db.session.commit() + return evaluation.to_dict_api() + + +@bp.route("/evaluation//delete", methods=["POST"]) +@api_web_bp.route("/evaluation//delete", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEnsView) # permission gérée dans la fonction +@as_json +def evaluation_delete(evaluation_id: int): + """Suppression d'une évaluation. + Efface aussi toutes ses notes + """ + query = Evaluation.query.filter_by(id=evaluation_id) + if g.scodoc_dept: + query = ( + query.join(ModuleImpl) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement + app.set_sco_dept(dept.acronym) + if not evaluation.moduleimpl.can_edit_evaluation(current_user): + raise AccessDenied("evaluation_delete") + + sco_saisie_notes.evaluation_suppress_alln( + evaluation_id=evaluation_id, dialog_confirmed=True + ) + sco_evaluation_db.do_evaluation_delete(evaluation_id) + return "ok" diff --git a/app/api/formations.py b/app/api/formations.py index 2712a2c2..e8a145a5 100644 --- a/app/api/formations.py +++ b/app/api/formations.py @@ -21,8 +21,6 @@ from app.models import ( ApcNiveau, ApcParcours, Formation, - FormSemestre, - ModuleImpl, UniteEns, ) from app.scodoc import sco_formations @@ -249,54 +247,6 @@ def referentiel_competences(formation_id: int): return formation.referentiel_competence.to_dict() -@bp.route("/moduleimpl/") -@api_web_bp.route("/moduleimpl/") -@login_required -@scodoc -@permission_required(Permission.ScoView) -@as_json -def moduleimpl(moduleimpl_id: int): - """ - Retourne un moduleimpl en fonction de son id - - moduleimpl_id : l'id d'un moduleimpl - - Exemple de résultat : - { - "id": 1, - "formsemestre_id": 1, - "module_id": 1, - "responsable_id": 2, - "moduleimpl_id": 1, - "ens": [], - "module": { - "heures_tp": 0, - "code_apogee": "", - "titre": "Initiation aux réseaux informatiques", - "coefficient": 1, - "module_type": 2, - "id": 1, - "ects": null, - "abbrev": "Init aux réseaux informatiques", - "ue_id": 1, - "code": "R101", - "formation_id": 1, - "heures_cours": 0, - "matiere_id": 1, - "heures_td": 0, - "semestre_id": 1, - "numero": 10, - "module_id": 1 - } - } - """ - query = ModuleImpl.query.filter_by(id=moduleimpl_id) - if g.scodoc_dept: - query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) - modimpl: ModuleImpl = query.first_or_404() - return modimpl.to_dict(convert_objects=True) - - @bp.route("/set_ue_parcours/", methods=["POST"]) @api_web_bp.route("/set_ue_parcours/", methods=["POST"]) @login_required diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 18b98738..40fc81bb 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -99,18 +99,20 @@ def formsemestre_infos(formsemestre_id: int): def formsemestres_query(): """ Retourne les formsemestres filtrés par - étape Apogée ou année scolaire ou département (acronyme ou id) + étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant etape_apo : un code étape apogée annee_scolaire : année de début de l'année scolaire dept_acronym : acronyme du département (eg "RT") dept_id : id du département ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit. + etat: 0 si verrouillé, 1 sinon """ etape_apo = request.args.get("etape_apo") annee_scolaire = request.args.get("annee_scolaire") dept_acronym = request.args.get("dept_acronym") dept_id = request.args.get("dept_id") + etat = request.args.get("etat") nip = request.args.get("nip") ine = request.args.get("ine") formsemestres = FormSemestre.query @@ -126,6 +128,12 @@ def formsemestres_query(): formsemestres = formsemestres.filter( FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee ) + if etat is not None: + try: + etat = bool(int(etat)) + except ValueError: + return json_error(404, "invalid etat: integer expected") + formsemestres = formsemestres.filter_by(etat=etat) if dept_acronym is not None: formsemestres = formsemestres.join(Departement).filter_by(acronym=dept_acronym) if dept_id is not None: @@ -151,7 +159,15 @@ def formsemestres_query(): formsemestres = formsemestres.join(FormSemestreInscription).join(Identite) formsemestres = formsemestres.filter_by(code_ine=ine) - return [formsemestre.to_dict_api() for formsemestre in formsemestres] + return [ + formsemestre.to_dict_api() + for formsemestre in formsemestres.order_by( + FormSemestre.date_debut.desc(), + FormSemestre.modalite, + FormSemestre.semestre_id, + FormSemestre.titre, + ) + ] @bp.route("/formsemestre//bulletins") @@ -196,7 +212,7 @@ def bulletins(formsemestre_id: int, version: str = "long"): @as_json def formsemestre_programme(formsemestre_id: int): """ - Retourne la liste des Ues, ressources et SAE d'un semestre + Retourne la liste des UEs, ressources et SAEs d'un semestre formsemestre_id : l'id d'un formsemestre diff --git a/app/api/jury.py b/app/api/jury.py index 294eef2f..9d4aad56 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -10,7 +10,7 @@ import datetime -from flask import flash, g, request, url_for +from flask import g, request, url_for from flask_json import as_json from flask_login import current_user, login_required diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 30357d81..0fd59b97 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -26,6 +26,7 @@ from app.scodoc.sco_archives_justificatifs import JustificatifArchiver from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import json_error +from flask_sqlalchemy.query import Query # Partie Modèle @@ -261,7 +262,7 @@ def _create_singular( # TOUT EST OK try: - nouv_justificatif: Justificatif = Justificatif.create_justificatif( + nouv_justificatif: Query = Justificatif.create_justificatif( date_debut=deb, date_fin=fin, etat=etat, @@ -307,7 +308,7 @@ def justif_edit(justif_id: int): "date_fin"?: str } """ - justificatif_unique: Justificatif = Justificatif.query.filter_by( + justificatif_unique: Query = Justificatif.query.filter_by( id=justif_id ).first_or_404() @@ -426,9 +427,7 @@ def justif_delete(): def _delete_singular(justif_id: int, database): - justificatif_unique: Justificatif = Justificatif.query.filter_by( - id=justif_id - ).first() + justificatif_unique: Query = Justificatif.query.filter_by(id=justif_id).first() if justificatif_unique is None: return (404, "Justificatif non existant") @@ -470,7 +469,7 @@ def justif_import(justif_id: int = None): if file.filename == "": return json_error(404, "Il n'y a pas de fichier joint") - query = Justificatif.query.filter_by(id=justif_id) + query: Query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) @@ -509,11 +508,11 @@ def justif_export(justif_id: int = None, filename: str = None): Retourne un fichier d'une archive d'un justificatif """ - query = Justificatif.query.filter_by(id=justif_id) + query: Query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) - justificatif_unique: Justificatif = query.first_or_404() + justificatif_unique: Justificaitf = query.first_or_404() archive_name: str = justificatif_unique.fichier if archive_name is None: @@ -551,7 +550,7 @@ def justif_remove(justif_id: int = None): data: dict = request.get_json(force=True) - query = Justificatif.query.filter_by(id=justif_id) + query: Query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) @@ -604,7 +603,7 @@ def justif_list(justif_id: int = None): Liste les fichiers du justificatif """ - query = Justificatif.query.filter_by(id=justif_id) + query: Query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) @@ -642,7 +641,7 @@ def justif_justifies(justif_id: int = None): Liste assiduite_id justifiées par le justificatif """ - query = Justificatif.query.filter_by(id=justif_id) + query: Query = Justificatif.query.filter_by(id=justif_id) if g.scodoc_dept: query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) @@ -676,13 +675,13 @@ def _filter_manager(requested, justificatifs_query): fin = scu.is_iso_formated(fin, True) if (deb, fin) != (None, None): - justificatifs_query: Justificatif = scass.filter_by_date( + justificatifs_query: Query = scass.filter_by_date( justificatifs_query, Justificatif, deb, fin ) user_id = requested.args.get("user_id", False) if user_id is not False: - justificatifs_query: Justificatif = scass.filter_by_user_id( + justificatifs_query: Query = scass.filter_by_user_id( justificatifs_query, user_id ) diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py new file mode 100644 index 00000000..b8e87c05 --- /dev/null +++ b/app/api/moduleimpl.py @@ -0,0 +1,69 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +""" + ScoDoc 9 API : accès aux moduleimpl +""" + +from flask import g +from flask_json import as_json +from flask_login import login_required + +from app.api import api_bp as bp, api_web_bp +from app.decorators import scodoc, permission_required +from app.models import ( + FormSemestre, + ModuleImpl, +) +from app.scodoc.sco_permissions import Permission + + +@bp.route("/moduleimpl/") +@api_web_bp.route("/moduleimpl/") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def moduleimpl(moduleimpl_id: int): + """ + Retourne un moduleimpl en fonction de son id + + moduleimpl_id : l'id d'un moduleimpl + + Exemple de résultat : + { + "id": 1, + "formsemestre_id": 1, + "module_id": 1, + "responsable_id": 2, + "moduleimpl_id": 1, + "ens": [], + "module": { + "heures_tp": 0, + "code_apogee": "", + "titre": "Initiation aux réseaux informatiques", + "coefficient": 1, + "module_type": 2, + "id": 1, + "ects": null, + "abbrev": "Init aux réseaux informatiques", + "ue_id": 1, + "code": "R101", + "formation_id": 1, + "heures_cours": 0, + "matiere_id": 1, + "heures_td": 0, + "semestre_id": 1, + "numero": 10, + "module_id": 1 + } + } + """ + query = ModuleImpl.query.filter_by(id=moduleimpl_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + modimpl: ModuleImpl = query.first_or_404() + return modimpl.to_dict(convert_objects=True) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 3231cc88..2e556908 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -276,11 +276,10 @@ class BulletinBUT: "coef": fmt_note(e.coefficient) if e.evaluation_type == scu.EVALUATION_NORMALE else None, - "date": e.jour.isoformat() if e.jour else None, + "date_debut": e.date_debut.isoformat() if e.date_debut else None, + "date_fin": e.date_fin.isoformat() if e.date_fin else None, "description": e.description, "evaluation_type": e.evaluation_type, - "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, "note": { "value": fmt_note( eval_notes[etud.id], @@ -298,6 +297,12 @@ class BulletinBUT: ) if has_request_context() else "na", + # deprecated (supprimer avant #sco9.7) + "date": e.date_debut.isoformat() if e.date_debut else None, + "heure_debut": e.date_debut.time().isoformat("minutes") + if e.date_debut + else None, + "heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None, } return d diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 0bb1b902..45668ac5 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -202,12 +202,11 @@ def bulletin_but_xml_compat( 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 + date_debut=e.date_debut.isoformat() + if e.date_debut else "", - heure_fin=e.heure_fin.isoformat() - if e.heure_debut + date_fin=e.date_fin.isoformat() + if e.date_debut else "", coefficient=str(e.coefficient), # pas les poids en XML compat @@ -215,6 +214,12 @@ def bulletin_but_xml_compat( description=quote_xml_attr(e.description), # notes envoyées sur 20, ceci juste pour garder trace: note_max_origin=str(e.note_max), + # --- deprecated + jour=e.date_debut.isoformat() + if e.date_debut + else "", + heure_debut=e.heure_debut(), + heure_fin=e.heure_fin(), ) x_mod.append(x_eval) try: diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index f2ead5ac..3ae00b2f 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -76,6 +76,13 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str: f"""
Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but} + visualiser son cursus
{deca.explanation}
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 11dd05f6..4180c35e 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -250,7 +250,7 @@ class ModuleImplResults: ).reshape(-1, 1) # was _list_notes_evals_titles - def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list: + def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]: "Liste des évaluations complètes" return [ e for e in moduleimpl.evaluations if self.evaluations_completes_dict[e.id] diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index a1fe0104..15313550 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -78,7 +78,11 @@ def compute_sem_moys_apc_using_ects( else: ects = ects_df.to_numpy() # ects est maintenant un array nb_etuds x nb_ues + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1) + except ZeroDivisionError: + # peut arriver si aucun module... on ignore + moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index) except TypeError: if None in ects: formation = db.session.get(Formation, formation_id) diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 30d8466b..071d72e8 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -394,7 +394,10 @@ def compute_ue_moys_classic( if sco_preferences.get_preference("use_ue_coefs", formsemestre.id): # Cas avec coefficients d'UE forcés: (on met à zéro l'UE bonus) etud_coef_ue_df = pd.DataFrame( - {ue.id: ue.coefficient if ue.type != UE_SPORT else 0.0 for ue in ues}, + { + ue.id: (ue.coefficient or 0.0) if ue.type != UE_SPORT else 0.0 + for ue in ues + }, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues], ) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index f7934172..238f38d5 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -53,8 +53,8 @@ class ResultatsSemestreBUT(NotesTableCompat): self.store() t2 = time.time() log( - f"""ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id - } ({(t1-t0):g}s +{(t2-t1):g}s)""" + f"""+++ ResultatsSemestreBUT: cached [{formsemestre.id + }] ({(t1-t0):g}s +{(t2-t1):g}s) +++""" ) def compute(self): diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 268ea0fb..50976668 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -50,8 +50,8 @@ class ResultatsSemestreClassic(NotesTableCompat): self.store() t2 = time.time() log( - f"""ResultatsSemestreClassic: cached formsemestre_id={ - formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)""" + f"""+++ ResultatsSemestreClassic: cached formsemestre_id={ + formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s) +++""" ) # recalculé (aussi rapide que de les cacher) self.moy_min = self.etud_moy_gen.min() diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 03b91e0c..175e38df 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -80,8 +80,8 @@ class ResultatsSemestre(ResultatsCache): self.moy_gen_rangs_by_group = None # virtual self.modimpl_inscr_df: pd.DataFrame = None "Inscriptions: row etudid, col modimlpl_id" - self.modimpls_results: ModuleImplResults = None - "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" + self.modimpls_results: dict[int, ModuleImplResults] = None + "Résultats de chaque modimpl (classique ou BUT)" self.etud_coef_ue_df = None """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" self.modimpl_coefs_df: pd.DataFrame = None @@ -192,6 +192,17 @@ class ResultatsSemestre(ResultatsCache): *[mr.etudids_attente for mr in self.modimpls_results.values()] ) + # # Etat des évaluations + # # (se substitue à do_evaluation_etat, sans les moyennes par groupes) + # def get_evaluations_etats(evaluation_id: int) -> dict: + # """Renvoie dict avec les clés: + # last_modif + # nb_evals_completes + # nb_evals_en_cours + # nb_evals_vides + # attente + # """ + # --- JURY... def get_formsemestre_validations(self) -> ValidationsSemestre: """Load validations if not already stored, set attribute and return value""" diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py index 88fcd095..fe411630 100644 --- a/app/comp/res_compat.py +++ b/app/comp/res_compat.py @@ -16,7 +16,13 @@ from app import db, log from app.comp import moy_sem from app.comp.aux_stats import StatsMoyenne from app.comp.res_common import ResultatsSemestre -from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription +from app.models import ( + Evaluation, + Identite, + FormSemestre, + ModuleImpl, + ScolarAutorisationInscription, +) from app.scodoc.codes_cursus import UE_SPORT, DEF from app.scodoc import sco_utils as scu @@ -389,7 +395,7 @@ class NotesTableCompat(ResultatsSemestre): "ects_total": ects_total, } - def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: + def get_modimpl_evaluations_completes(self, moduleimpl_id: int) -> list[Evaluation]: """Liste d'informations (compat NotesTable) sur évaluations completes de ce module. Évaluation "complete" ssi toutes notes saisies ou en attente. @@ -398,34 +404,24 @@ class NotesTableCompat(ResultatsSemestre): modimpl_results = self.modimpls_results.get(moduleimpl_id) if not modimpl_results: return [] # safeguard - evals_results = [] + evaluations = [] for e in modimpl.evaluations: if modimpl_results.evaluations_completes_dict.get(e.id, False): - 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": modimpl_results.evals_notes[e.id][etud.id], - } - for etud in self.etuds - } - d["etat"] = { - "evalattente": modimpl_results.evaluations_etat[e.id].nb_attente, - } - evals_results.append(d) + evaluations.append(e) elif e.id not in modimpl_results.evaluations_completes_dict: # ne devrait pas arriver ? XXX log( - f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?" + f"Warning: 220213 get_modimpl_evaluations_completes {e.id} not in mod {moduleimpl_id} ?" ) - return evals_results + return evaluations + + def get_evaluations_etats(self) -> list[dict]: + """Liste de toutes les évaluations du semestre + [ {...evaluation et son etat...} ]""" + # TODO: à moderniser (voir dans ResultatsSemestre) + # utilisé par + # do_evaluation_etat_in_sem - def get_evaluations_etats(self): - """[ {...evaluation et son etat...} ]""" - # TODO: à moderniser from app.scodoc import sco_evaluations if not hasattr(self, "_evaluations_etats"): diff --git a/app/email.py b/app/email.py index a75b2def..a983ef2d 100644 --- a/app/email.py +++ b/app/email.py @@ -79,13 +79,15 @@ Adresses d'origine: to : {orig_to} cc : {orig_cc} bcc: {orig_bcc} ---- +--- \n\n""" + msg.body ) current_app.logger.info( - f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients} + f"""email sent to{ + ' (mode test)' if email_test_mode_address else '' + }: {msg.recipients} from sender {msg.sender} """ ) @@ -98,7 +100,8 @@ def get_from_addr(dept_acronym: str = None): """L'adresse "from" à utiliser pour envoyer un mail Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe, - prend le `email_from_addr` des préférences de ce département si ce champ est non vide. + prend le `email_from_addr` des préférences de ce département si ce champ + est non vide. Sinon, utilise le paramètre global `email_from_addr`. Sinon, la variable de config `SCODOC_MAIL_FROM`. """ diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 0cea4781..7f5520df 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -14,6 +14,8 @@ from app.scodoc.sco_utils import ( localize_datetime, ) +from flask_sqlalchemy.query import Query + class Assiduite(db.Model): """ @@ -124,7 +126,7 @@ class Assiduite(db.Model): ) -> object or int: """Créer une nouvelle assiduité pour l'étudiant""" # Vérification de non duplication des périodes - assiduites: list[Assiduite] = etud.assiduites + assiduites: Query = etud.assiduites if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite): raise ScoValueError( "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)" @@ -307,7 +309,7 @@ class Justificatif(db.Model): def is_period_conflicting( date_debut: datetime, date_fin: datetime, - collection: list[Assiduite or Justificatif], + collection: Query, collection_cls: Assiduite or Justificatif, ) -> bool: """ diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 987b5170..235b7f74 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -5,17 +5,28 @@ import datetime from operator import attrgetter -from app import db +from flask import g, url_for +from flask_login import current_user +import sqlalchemy as sa + +from app import db, log from app.models.etudiants import Identite +from app.models.events import ScolarNews from app.models.moduleimpls import ModuleImpl from app.models.notes import NotesNotes from app.models.ues import UniteEns -from app.scodoc.sco_exceptions import ScoValueError -import app.scodoc.notesdb as ndb +from app.scodoc import sco_cache +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +import app.scodoc.sco_utils as scu +from app.scodoc.sco_xml import quote_xml_attr +MAX_EVALUATION_DURATION = datetime.timedelta(days=365) +NOON = datetime.time(12, 00) DEFAULT_EVALUATION_TIME = datetime.time(8, 0) +VALID_EVALUATION_TYPES = {0, 1, 2} + class Evaluation(db.Model): """Evaluation (contrôle, examen, ...)""" @@ -27,15 +38,15 @@ class Evaluation(db.Model): 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) + date_debut = db.Column(db.DateTime(timezone=True), nullable=True) + date_fin = db.Column(db.DateTime(timezone=True), nullable=True) 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" ) + "visible sur les bulletins version intermédiaire" publish_incomplete = db.Column( db.Boolean, nullable=False, default=False, server_default="false" ) @@ -50,47 +61,108 @@ class Evaluation(db.Model): def __repr__(self): return f"""""" + @classmethod + def create( + cls, + moduleimpl: ModuleImpl = None, + date_debut: datetime.datetime = None, + date_fin: datetime.datetime = None, + description=None, + note_max=None, + coefficient=None, + visibulletin=None, + publish_incomplete=None, + evaluation_type=None, + numero=None, + **kw, # ceci pour absorber les éventuel arguments excedentaires + ): + """Create an evaluation. Check permission and all arguments. + Ne crée pas les poids vers les UEs. + """ + if not moduleimpl.can_edit_evaluation(current_user): + raise AccessDenied( + f"Modification évaluation impossible pour {current_user.get_nomplogin()}" + ) + args = locals() + del args["cls"] + del args["kw"] + check_convert_evaluation_args(moduleimpl, args) + # Check numeros + Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True) + if not "numero" in args or args["numero"] is None: + args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"]) + # + evaluation = Evaluation(**args) + sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id) + url = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl.id, + ) + log(f"created evaluation in {moduleimpl.module.titre_str()}") + ScolarNews.add( + typ=ScolarNews.NEWS_NOTE, + obj=moduleimpl.id, + text=f"""Création d'une évaluation dans { + moduleimpl.module.titre_str()}""", + url=url, + ) + return evaluation + + @classmethod + def get_new_numero( + cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime + ) -> int: + """Get a new numero for an evaluation in this moduleimpl + If necessary, renumber existing evals to make room for a new one. + """ + n = None + # Détermine le numero grâce à la date + # Liste des eval existantes triées par date, la plus ancienne en tete + evaluations = moduleimpl.evaluations.order_by(Evaluation.date_debut).all() + if date_debut is not None: + next_eval = None + t = date_debut + for e in evaluations: + if e.date_debut and e.date_debut > t: + next_eval = e + break + if next_eval: + n = _moduleimpl_evaluation_insert_before(evaluations, next_eval) + else: + n = None # à placer en fin + if n is None: # pas de date ou en fin: + if evaluations: + n = evaluations[-1].numero + 1 + else: + n = 0 # the only one + return n + def to_dict(self) -> dict: "Représentation dict (riche, compat ScoDoc 7)" - e = dict(self.__dict__) - e.pop("_sa_instance_state", None) + e_dict = dict(self.__dict__) + e_dict.pop("_sa_instance_state", None) # ScoDoc7 output_formators - e["evaluation_id"] = self.id - e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else "" - if self.jour is None: - e["date_debut"] = None - e["date_fin"] = None - else: - e["date_debut"] = datetime.datetime.combine( - self.jour, self.heure_debut or datetime.time(0, 0) - ).isoformat() - e["date_fin"] = datetime.datetime.combine( - self.jour, self.heure_fin or datetime.time(0, 0) - ).isoformat() - e["numero"] = ndb.int_null_is_zero(e["numero"]) - e["poids"] = self.get_ue_poids_dict() # { ue_id : poids } - return evaluation_enrich_dict(e) + e_dict["evaluation_id"] = self.id + e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None + e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None + e_dict["numero"] = self.numero or 0 + e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids } + + # Deprecated + e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else "" + + return evaluation_enrich_dict(self, e_dict) def to_dict_api(self) -> dict: "Représentation dict pour API JSON" - if self.jour is None: - date_debut = None - date_fin = None - else: - date_debut = datetime.datetime.combine( - self.jour, self.heure_debut or datetime.time(0, 0) - ).isoformat() - date_fin = datetime.datetime.combine( - self.jour, self.heure_fin or datetime.time(0, 0) - ).isoformat() - return { "coefficient": self.coefficient, - "date_debut": date_debut, - "date_fin": date_fin, + "date_debut": self.date_debut.isoformat() if self.date_debut else "", + "date_fin": self.date_fin.isoformat() if self.date_fin else "", "description": self.description, "evaluation_type": self.evaluation_type, "id": self.id, @@ -99,39 +171,135 @@ class Evaluation(db.Model): "numero": self.numero, "poids": self.get_ue_poids_dict(), "publish_incomplete": self.publish_incomplete, - "visi_bulletin": self.visibulletin, + "visibulletin": self.visibulletin, + # Deprecated (supprimer avant #sco9.7) + "date": self.date_debut.date().isoformat() if self.date_debut else "", + "heure_debut": self.date_debut.time().isoformat() + if self.date_debut + else "", + "heure_fin": self.date_fin.time().isoformat() if self.date_fin else "", } + def to_dict_bul(self) -> dict: + "dict pour les bulletins json" + # c'est la version API avec quelques champs legacy en plus + e_dict = self.to_dict_api() + # Pour les bulletins (json ou xml), quote toujours la description + e_dict["description"] = quote_xml_attr(self.description or "") + # deprecated fields: + e_dict["evaluation_id"] = self.id + e_dict["jour"] = e_dict["date_debut"] # chaine iso + e_dict["heure_debut"] = ( + self.date_debut.time().isoformat() if self.date_debut else "" + ) + e_dict["heure_fin"] = self.date_fin.time().isoformat() if self.date_fin else "" + + return e_dict + def from_dict(self, data): """Set evaluation attributes from given dict values.""" - check_evaluation_args(data) + check_convert_evaluation_args(self.moduleimpl, data) + if data.get("numero") is None: + data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1 for k in self.__dict__.keys(): if k != "_sa_instance_state" and k != "id" and k in data: setattr(self, k, data[k]) + @classmethod + def get_max_numero(cls, moduleimpl_id: int) -> int: + """Return max numero among evaluations in this + moduleimpl (0 if None) + """ + max_num = ( + db.session.query(sa.sql.functions.max(Evaluation.numero)) + .filter_by(moduleimpl_id=moduleimpl_id) + .first()[0] + ) + return max_num or 0 + + @classmethod + def moduleimpl_evaluation_renumber( + cls, moduleimpl: ModuleImpl, only_if_unumbered=False + ): + """Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one) + Needed because previous versions of ScoDoc did not have eval numeros + Note: existing numeros are ignored + """ + # Liste des eval existantes triées par date, la plus ancienne en tete + evaluations = moduleimpl.evaluations.order_by( + Evaluation.date_debut, Evaluation.numero + ).all() + all_numbered = all(e.numero is not None for e in evaluations) + if all_numbered and only_if_unumbered: + return # all ok + + # Reset all numeros: + i = 1 + for e in evaluations: + e.numero = i + db.session.add(e) + i += 1 + db.session.commit() + def descr_heure(self) -> str: - "Description de la plage horaire pour affichages" - if self.heure_debut and ( - not self.heure_fin or self.heure_fin == self.heure_debut - ): - return f"""à {self.heure_debut.strftime("%Hh%M")}""" - elif self.heure_debut and self.heure_fin: - return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}""" + "Description de la plage horaire pour affichages ('de 13h00 à 14h00')" + if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut): + return f"""à {self.date_debut.strftime("%Hh%M")}""" + elif self.date_debut and self.date_fin: + return f"""de {self.date_debut.strftime("%Hh%M") + } à {self.date_fin.strftime("%Hh%M")}""" else: return "" def descr_duree(self) -> str: - "Description de la durée pour affichages" - if self.heure_debut is None and self.heure_fin is None: + "Description de la durée pour affichages ('3h' ou '2h30')" + if self.date_debut is None or self.date_fin is None: return "" - debut = self.heure_debut or DEFAULT_EVALUATION_TIME - fin = self.heure_fin or DEFAULT_EVALUATION_TIME - d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute) - duree = f"{d//60}h" - if d % 60: - duree += f"{d%60:02d}" + minutes = (self.date_fin - self.date_debut).seconds // 60 + duree = f"{minutes // 60}h" + minutes = minutes % 60 + if minutes != 0: + duree += f"{minutes:02d}" return duree + def descr_date(self) -> str: + """Description de la date pour affichages + 'sans date' + 'le 21/9/2021 à 13h' + 'le 21/9/2021 de 13h à 14h30' + 'du 21/9/2021 à 13h30 au 23/9/2021 à 15h' + """ + if self.date_debut is None: + return "sans date" + + def _h(dt: datetime.datetime) -> str: + if dt.minute: + return dt.strftime("%Hh%M") + return f"{dt.hour}h" + + if self.date_fin is None: + return f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" + if self.date_debut.date() == self.date_fin.date(): # même jour + if self.date_debut.time() == self.date_fin.time(): + return ( + f"le {self.date_debut.strftime('%d/%m/%Y')} à {_h(self.date_debut)}" + ) + return f"""le {self.date_debut.strftime('%d/%m/%Y')} de { + _h(self.date_debut)} à {_h(self.date_fin)}""" + # évaluation sur plus d'une journée + return f"""du {self.date_debut.strftime('%d/%m/%Y')} à { + _h(self.date_debut)} au {self.date_fin.strftime('%d/%m/%Y')} à {_h(self.date_fin)}""" + + def heure_debut(self) -> str: + """L'heure de début (sans la date), en ISO. + Chaine vide si non renseignée.""" + return self.date_debut.time().isoformat("minutes") if self.date_debut else "" + + def heure_fin(self) -> str: + """L'heure de fin (sans la date), en ISO. + Chaine vide si non renseignée.""" + return self.date_fin.time().isoformat("minutes") if self.date_fin else "" + def clone(self, not_copying=()): """Clone, not copying the given attrs Attention: la copie n'a pas d'id avant le prochain commit @@ -146,19 +314,19 @@ class Evaluation(db.Model): return copy def is_matin(self) -> bool: - "Evaluation ayant lieu le matin (faux si pas de date)" - heure_debut_dt = self.heure_debut or datetime.time(8, 00) - # 8:00 au cas ou pas d'heure (note externe?) - return bool(self.jour) and heure_debut_dt < datetime.time(12, 00) + "Evaluation commençant le matin (faux si pas de date)" + if not self.date_debut: + return False + return self.date_debut.time() < NOON def is_apresmidi(self) -> bool: - "Evaluation ayant lieu l'après midi (faux si pas de date)" - heure_debut_dt = self.heure_debut or datetime.time(8, 00) - # 8:00 au cas ou pas d'heure (note externe?) - return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00) + "Evaluation commençant l'après midi (faux si pas de date)" + if not self.date_debut: + return False + return self.date_debut.time() >= NOON def set_default_poids(self) -> bool: - """Initialize les poids bvers les UE à leurs valeurs par défaut + """Initialize les poids vers les UE à leurs valeurs par défaut C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon. Les poids existants ne sont pas modifiés. Return True if (uncommited) modification, False otherwise. @@ -191,6 +359,8 @@ class Evaluation(db.Model): L = [] for ue_id, poids in ue_poids_dict.items(): ue = db.session.get(UniteEns, ue_id) + if ue is None: + raise ScoValueError("poids vers une UE inexistante") ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids) L.append(ue_poids) db.session.add(ue_poids) @@ -274,88 +444,158 @@ class EvaluationUEPoids(db.Model): return f"" -# Fonction héritée de ScoDoc7 à refactorer -def evaluation_enrich_dict(e: dict): +# Fonction héritée de ScoDoc7 +def evaluation_enrich_dict(e: Evaluation, e_dict: dict): """add or convert some fields in an evaluation dict""" # For ScoDoc7 compat - heure_debut_dt = e["heure_debut"] or datetime.time( - 8, 00 - ) # au cas ou pas d'heure (note externe?) - heure_fin_dt = e["heure_fin"] or datetime.time(8, 00) - e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"]) - e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"]) - e["jour_iso"] = ndb.DateDMYtoISO(e["jour"]) - heure_debut, heure_fin = e["heure_debut"], e["heure_fin"] - d = ndb.TimeDuration(heure_debut, heure_fin) - if d is not None: - m = d % 60 - e["duree"] = "%dh" % (d / 60) - if m != 0: - e["duree"] += "%02d" % m - else: - e["duree"] = "" - if heure_debut and (not heure_fin or heure_fin == heure_debut): - e["descrheure"] = " à " + heure_debut - elif heure_debut and heure_fin: - e["descrheure"] = " de %s à %s" % (heure_debut, heure_fin) - else: - e["descrheure"] = "" + e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else "" + e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else "" + e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else "" + # Calcule durée en minutes + e_dict["descrheure"] = e.descr_heure() + e_dict["descrduree"] = e.descr_duree() # matin, apresmidi: utile pour se referer aux absences: - - if e["jour"] and heure_debut_dt < datetime.time(12, 00): - e["matin"] = 1 + # note août 2023: si l'évaluation s'étend sur plusieurs jours, + # cet indicateur n'a pas grand sens + if e.date_debut and e.date_debut.time() < datetime.time(12, 00): + e_dict["matin"] = 1 else: - e["matin"] = 0 - if e["jour"] and heure_fin_dt > datetime.time(12, 00): - e["apresmidi"] = 1 + e_dict["matin"] = 0 + if e.date_fin and e.date_fin.time() > datetime.time(12, 00): + e_dict["apresmidi"] = 1 else: - e["apresmidi"] = 0 - return e + e_dict["apresmidi"] = 0 + return e_dict -def check_evaluation_args(args): - "Check coefficient, dates and duration, raises exception if invalid" - moduleimpl_id = args["moduleimpl_id"] - # check bareme - note_max = args.get("note_max", None) - if note_max is None: - raise ScoValueError("missing note_max") +def check_convert_evaluation_args(moduleimpl: ModuleImpl, data: dict): + """Check coefficient, dates and duration, raises exception if invalid. + Convert date and time strings to date and time objects. + + Set required default value for unspecified fields. + May raise ScoValueError. + """ + # --- description + data["description"] = data.get("description", "") or "" + if len(data["description"]) > scu.MAX_TEXT_LEN: + raise ScoValueError("description too large") + + # --- evaluation_type + try: + data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0) + if not data["evaluation_type"] in VALID_EVALUATION_TYPES: + raise ScoValueError("invalid evaluation_type value") + except ValueError as exc: + raise ScoValueError("invalid evaluation_type value") from exc + + # --- note_max (bareme) + note_max = data.get("note_max", 20.0) or 20.0 try: note_max = float(note_max) - except ValueError: - raise ScoValueError("Invalid note_max value") + except ValueError as exc: + raise ScoValueError("invalid note_max value") from exc if note_max < 0: - raise ScoValueError("Invalid note_max value (must be positive or null)") - # check coefficient - coef = args.get("coefficient", None) - if coef is None: - raise ScoValueError("missing coefficient") + raise ScoValueError("invalid note_max value (must be positive or null)") + data["note_max"] = note_max + # --- coefficient + coef = data.get("coefficient", 1.0) or 1.0 try: coef = float(coef) - except ValueError: - raise ScoValueError("Invalid coefficient value") + except ValueError as exc: + raise ScoValueError("invalid coefficient value") from exc if coef < 0: - raise ScoValueError("Invalid coefficient value (must be positive or null)") - # check date - jour = args.get("jour", None) - args["jour"] = jour - if jour: - modimpl = db.session.get(ModuleImpl, moduleimpl_id) - formsemestre = modimpl.formsemestre - y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")] - jour = datetime.date(y, m, d) - if (jour > formsemestre.date_fin) or (jour < formsemestre.date_debut): + raise ScoValueError("invalid coefficient value (must be positive or null)") + data["coefficient"] = coef + # --- date de l'évaluation + formsemestre = moduleimpl.formsemestre + date_debut = data.get("date_debut", None) + if date_debut: + if isinstance(date_debut, str): + data["date_debut"] = datetime.datetime.fromisoformat(date_debut) + if data["date_debut"].tzinfo is None: + data["date_debut"] = scu.TIME_ZONE.localize(data["date_debut"]) + if not ( + formsemestre.date_debut + <= data["date_debut"].date() + <= formsemestre.date_fin + ): raise ScoValueError( - "La date de l'évaluation (%s/%s/%s) n'est pas dans le semestre !" - % (d, m, y), + f"""La date de début de l'évaluation ({ + data["date_debut"].strftime("%d/%m/%Y") + }) n'est pas dans le semestre !""", dest_url="javascript:history.back();", ) - heure_debut = args.get("heure_debut", None) - args["heure_debut"] = heure_debut - heure_fin = args.get("heure_fin", None) - args["heure_fin"] = heure_fin - if jour and ((not heure_debut) or (not heure_fin)): - raise ScoValueError("Les heures doivent être précisées") - d = ndb.TimeDuration(heure_debut, heure_fin) - if d and ((d < 0) or (d > 60 * 12)): - raise ScoValueError("Heures de l'évaluation incohérentes !") + date_fin = data.get("date_fin", None) + if date_fin: + if isinstance(date_fin, str): + data["date_fin"] = datetime.datetime.fromisoformat(date_fin) + if data["date_fin"].tzinfo is None: + data["date_fin"] = scu.TIME_ZONE.localize(data["date_fin"]) + if not ( + formsemestre.date_debut <= data["date_fin"].date() <= formsemestre.date_fin + ): + raise ScoValueError( + f"""La date de fin de l'évaluation ({ + data["date_fin"].strftime("%d/%m/%Y") + }) n'est pas dans le semestre !""", + dest_url="javascript:history.back();", + ) + if date_debut and date_fin: + duration = data["date_fin"] - data["date_debut"] + if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION: + raise ScoValueError("Heures de l'évaluation incohérentes !") + # # --- heures + # heure_debut = data.get("heure_debut", None) + # if heure_debut and not isinstance(heure_debut, datetime.time): + # if date_format == "dmy": + # data["heure_debut"] = heure_to_time(heure_debut) + # else: # ISO + # data["heure_debut"] = datetime.time.fromisoformat(heure_debut) + # heure_fin = data.get("heure_fin", None) + # if heure_fin and not isinstance(heure_fin, datetime.time): + # if date_format == "dmy": + # data["heure_fin"] = heure_to_time(heure_fin) + # else: # ISO + # data["heure_fin"] = datetime.time.fromisoformat(heure_fin) + + +def heure_to_time(heure: str) -> datetime.time: + "Convert external heure ('10h22' or '10:22') to a time" + t = heure.strip().upper().replace("H", ":") + h, m = t.split(":")[:2] + return datetime.time(int(h), int(m)) + + +def _time_duration_HhM(heure_debut: str, heure_fin: str) -> int: + """duree (nb entier de minutes) entre deux heures a notre format + ie 12h23 + """ + if heure_debut and heure_fin: + h0, m0 = [int(x) for x in heure_debut.split("h")] + h1, m1 = [int(x) for x in heure_fin.split("h")] + d = (h1 - h0) * 60 + (m1 - m0) + return d + else: + return None + + +def _moduleimpl_evaluation_insert_before( + evaluations: list[Evaluation], next_eval: Evaluation +) -> int: + """Renumber evaluations such that an evaluation with can be inserted before next_eval + Returns numero suitable for the inserted evaluation + """ + if next_eval: + n = next_eval.numero + if n is None: + Evaluation.moduleimpl_evaluation_renumber(next_eval.moduleimpl) + n = next_eval.numero + else: + n = 1 + # all numeros >= n are incremented + for e in evaluations: + if e.numero >= n: + e.numero += 1 + db.session.add(e) + db.session.commit() + return n diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index e4690a06..677bfb17 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -30,6 +30,7 @@ from app.models.but_refcomp import ( ) from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite +from app.models.evaluations import Evaluation from app.models.formations import Formation from app.models.groups import GroupDescr, Partition from app.models.moduleimpls import ModuleImpl, ModuleImplInscription @@ -350,6 +351,21 @@ class FormSemestre(db.Model): _cache[key] = ues return ues + def get_evaluations(self) -> list[Evaluation]: + "Liste de toutes les évaluations du semestre, triées par module/numero" + return ( + Evaluation.query.join(ModuleImpl) + .filter_by(formsemestre_id=self.id) + .join(Module) + .order_by( + Module.numero, + Module.code, + Evaluation.numero, + Evaluation.date_debut.desc(), + ) + .all() + ) + @cached_property def modimpls_sorted(self) -> list[ModuleImpl]: """Liste des modimpls du semestre (y compris bonus) diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index c4d7c3fe..e84fe82b 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -101,6 +101,23 @@ class ModuleImpl(db.Model): d.pop("module", None) return d + def can_edit_evaluation(self, user) -> bool: + """True if this user can create, delete or edit and evaluation in this modimpl + (nb: n'implique pas le droit de saisir ou modifier des notes) + """ + # acces pour resp. moduleimpl et resp. form semestre (dir etud) + if ( + user.has_permission(Permission.ScoEditAllEvals) + or user.id == self.responsable_id + or user.id in (r.id for r in self.formsemestre.responsables) + ): + return True + elif self.formsemestre.ens_can_edit_eval: + if user.id in (e.id for e in self.enseignants): + return True + + return False + def can_change_ens_by(self, user: User, raise_exc=False) -> bool: """Check if user can modify module resp. If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not. diff --git a/app/models/modules.py b/app/models/modules.py index 6aaaef19..9fafe5cb 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -153,6 +153,10 @@ class Module(db.Model): """ return scu.ModuleType.get_abbrev(self.module_type) + def titre_str(self) -> str: + "Identifiant du module à afficher : abbrev ou titre ou code" + return self.abbrev or self.titre or self.code + def sort_key_apc(self) -> tuple: """Clé de tri pour avoir présentation par type (res, sae), parcours, type, numéro diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index bb5f386f..8c2a40b7 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -57,8 +57,10 @@ def _pe_view_sem_recap_form(formsemestre_id): poursuites d'études.
De nombreux aspects sont paramétrables: - - voir la documentation. + + voir la documentation + .

diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 820bf898..6e483df3 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -152,8 +152,10 @@ def sidebar(etudid: int = None): # Logo H.append( f"""
-