diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..b7f214681 --- /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 948964126..62e9be5d4 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/README.md b/README.md index 6c79f8bba..d0194b45e 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ puis snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof -# Paquet Debian 11 +## Paquet Debian 12 Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus important est `postinst`qui se charge de configurer le système (install ou diff --git a/app/__init__.py b/app/__init__.py old mode 100644 new mode 100755 index c2b789597..f1fba060a --- a/app/__init__.py +++ b/app/__init__.py @@ -148,7 +148,7 @@ def handle_invalid_usage(error): # JSON ENCODING # used by some internal finctions -# the API is now using flask_son, NOT THIS ENCODER +# the API is now using flask_json, NOT THIS ENCODER class ScoDocJSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=E0202 if isinstance(o, (datetime.date, datetime.datetime)): @@ -260,7 +260,13 @@ def create_app(config_class=DevConfig): CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration) app.wsgi_app = ReverseProxied(app.wsgi_app) - FlaskJSON(app) + app_json = FlaskJSON(app) + + @app_json.encoder + def scodoc_json_encoder(o): + "Overide default date encoding (RFC 822) and use ISO" + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() # Pour conserver l'ordre des objets dans les JSON: # e.g. l'ordre des UE dans les bulletins @@ -322,6 +328,7 @@ def create_app(config_class=DevConfig): from app.views import notes_bp from app.views import users_bp from app.views import absences_bp + from app.views import assiduites_bp from app.api import api_bp from app.api import api_web_bp @@ -340,6 +347,9 @@ def create_app(config_class=DevConfig): app.register_blueprint( absences_bp, url_prefix="/ScoDoc//Scolarite/Absences" ) + app.register_blueprint( + assiduites_bp, url_prefix="/ScoDoc//Scolarite/Assiduites" + ) app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") app.register_blueprint(api_web_bp, url_prefix="/ScoDoc//api") diff --git a/app/api/__init__.py b/app/api/__init__.py index d5b436881..b94cf855d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,10 +1,11 @@ """api.__init__ """ - +from flask_json import as_json from flask import Blueprint -from flask import request +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__) @@ -14,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 @@ -34,9 +47,26 @@ def requested_format(default_format="json", allowed_formats=None): return None +@as_json +def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): + """ + Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" + + Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls + + exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py + """ + query = model_cls.query.filter_by(id=model_id) + if g.scodoc_dept and join_cls is not None: + query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id) + unique: model_cls = query.first_or_404() + + return unique.to_dict(format_api=True) + + from app.api import tokens from app.api import ( - absences, + assiduites, billets_absences, departements, etudiants, @@ -44,7 +74,9 @@ from app.api import ( formations, formsemestres, jury, + justificatifs, logos, + moduleimpl, partitions, semset, users, diff --git a/app/api/absences.py b/app/api/absences.py deleted file mode 100644 index acd690ffc..000000000 --- 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 new file mode 100644 index 000000000..d3f216c04 --- /dev/null +++ b/app/api/assiduites.py @@ -0,0 +1,1043 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 API : Assiduités +""" +from datetime import datetime + +from flask import g, request +from flask_json import as_json +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 +from app.models import ( + Assiduite, + FormSemestre, + Identite, + ModuleImpl, + 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 +from app.scodoc.sco_utils import json_error + + +@bp.route("/assiduite/") +@api_web_bp.route("/assiduite/") +@scodoc +@permission_required(Permission.ScoView) +def assiduite(assiduite_id: int = None): + """Retourne un objet assiduité à partir de son id + + Exemple de résultat: + { + "assiduite_id": 1, + "etudid": 2, + "moduleimpl_id": 3, + "date_debut": "2022-10-31T08:00+01:00", + "date_fin": "2022-10-31T10:00+01:00", + "etat": "retard", + "desc": "une description", + "user_id: 1 or null, + "est_just": False or True, + } + """ + + return get_model_api_object(Assiduite, assiduite_id, Identite) + + +@bp.route("/assiduite//justificatifs", defaults={"long": False}) +@api_web_bp.route( + "/assiduite//justificatifs", defaults={"long": False} +) +@bp.route("/assiduite//justificatifs/long", defaults={"long": True}) +@api_web_bp.route( + "/assiduite//justificatifs/long", defaults={"long": True} +) +@scodoc +@permission_required(Permission.ScoView) +@as_json +def assiduite_justificatifs(assiduite_id: int = None, long: bool = False): + """Retourne la liste des justificatifs qui justifie cette assiduitée + + Exemple de résultat: + [ + 1, + 2, + 3, + ... + ] + """ + + return get_assiduites_justif(assiduite_id, True) + + +# etudid +@bp.route("/assiduites//count", defaults={"with_query": False}) +@api_web_bp.route("/assiduites//count", defaults={"with_query": False}) +@bp.route("/assiduites//count/query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites//count/query", defaults={"with_query": True}) +@bp.route("/assiduites/etudid//count", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/etudid//count", defaults={"with_query": False}) +@bp.route("/assiduites/etudid//count/query", defaults={"with_query": True}) +@api_web_bp.route( + "/assiduites/etudid//count/query", defaults={"with_query": True} +) +# nip +@bp.route("/assiduites/nip//count", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/nip//count", defaults={"with_query": False}) +@bp.route("/assiduites/nip//count/query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/nip//count/query", defaults={"with_query": True}) +# ine +@bp.route("/assiduites/ine//count", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/ine//count", defaults={"with_query": False}) +@bp.route("/assiduites/ine//count/query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/ine//count/query", defaults={"with_query": True}) +# +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def count_assiduites( + etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False +): + """ + Retourne le nombre d'assiduités d'un étudiant + chemin : /assiduites//count + + Un filtrage peut être donné avec une query + chemin : /assiduites//count/query? + + Les différents filtres : + Type (type de comptage -> journee, demi, heure, nombre d'assiduite): + query?type=(journee, demi, heure) -> une seule valeur parmis les trois + ex: .../query?type=heure + Comportement par défaut : compte le nombre d'assiduité enregistrée + + Etat (etat de l'étudiant -> absent, present ou retard): + query?etat=[- liste des états séparé par une virgule -] + ex: .../query?etat=present,retard + Date debut + (date de début de l'assiduité, sont affichés les assiduités + dont la date de début est supérieur ou égale à la valeur donnée): + query?date_debut=[- date au format iso -] + ex: query?date_debut=2022-11-03T08:00+01:00 + Date fin + (date de fin de l'assiduité, sont affichés les assiduités + dont la date de fin est inférieure ou égale à la valeur donnée): + query?date_fin=[- date au format iso -] + ex: query?date_fin=2022-11-03T10:00+01:00 + Moduleimpl_id (l'id du module concerné par l'assiduité): + query?moduleimpl_id=[- int ou vide -] + ex: query?moduleimpl_id=1234 + query?moduleimpl_od= + Formsemstre_id (l'id du formsemestre concerné par l'assiduité) + query?formsemestre_id=[int] + ex query?formsemestre_id=3 + user_id (l'id de l'auteur de l'assiduité) + query?user_id=[int] + ex query?user_id=3 + est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard)) + query?est_just=[bool] + query?est_just=f + query?est_just=t + + + + + """ + # query = Identite.query.filter_by(id=etudid) + # if g.scodoc_dept: + # query = query.filter_by(dept_id=g.scodoc_dept_id) + + # etud: Identite = query.first_or_404(etudid) + + etud: Identite = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + + filtered: dict[str, object] = {} + metric: str = "all" + + if with_query: + metric, filtered = _count_manager(request) + + return scass.get_assiduites_stats( + assiduites=etud.assiduites, metric=metric, filtered=filtered + ) + + +# etudid +@bp.route("/assiduites/", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/", defaults={"with_query": False}) +@bp.route("/assiduites//query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites//query", defaults={"with_query": True}) +@bp.route("/assiduites/etudid/", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/etudid/", defaults={"with_query": False}) +@bp.route("/assiduites/etudid//query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/etudid//query", defaults={"with_query": True}) +# nip +@bp.route("/assiduites/nip/", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/nip/", defaults={"with_query": False}) +@bp.route("/assiduites/nip//query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/nip//query", defaults={"with_query": True}) +# ine +@bp.route("/assiduites/ine/", defaults={"with_query": False}) +@api_web_bp.route("/assiduites/ine/", defaults={"with_query": False}) +@bp.route("/assiduites/ine//query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/ine//query", defaults={"with_query": True}) +# +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False): + """ + Retourne toutes les assiduités d'un étudiant + chemin : /assiduites/ + + Un filtrage peut être donné avec une query + chemin : /assiduites//query? + + Les différents filtres : + Etat (etat de l'étudiant -> absent, present ou retard): + query?etat=[- liste des états séparé par une virgule -] + ex: .../query?etat=present,retard + Date debut + (date de début de l'assiduité, sont affichés les assiduités + dont la date de début est supérieur ou égale à la valeur donnée): + query?date_debut=[- date au format iso -] + ex: query?date_debut=2022-11-03T08:00+01:00 + Date fin + (date de fin de l'assiduité, sont affichés les assiduités + dont la date de fin est inférieure ou égale à la valeur donnée): + query?date_fin=[- date au format iso -] + ex: query?date_fin=2022-11-03T10:00+01:00 + Moduleimpl_id (l'id du module concerné par l'assiduité): + query?moduleimpl_id=[- int ou vide -] + ex: query?moduleimpl_id=1234 + query?moduleimpl_od= + Formsemstre_id (l'id du formsemestre concerné par l'assiduité) + query?formsemstre_id=[int] + ex query?formsemestre_id=3 + user_id (l'id de l'auteur de l'assiduité) + query?user_id=[int] + ex query?user_id=3 + est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard)) + query?est_just=[bool] + query?est_just=f + query?est_just=t + + + """ + + # query = Identite.query.filter_by(id=etudid) + # if g.scodoc_dept: + # query = query.filter_by(dept_id=g.scodoc_dept_id) + + # etud: Identite = query.first_or_404(etudid) + etud: Identite = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + assiduites_query: Query = etud.assiduites + + if with_query: + assiduites_query = _filter_manager(request, assiduites_query) + + data_set: list[dict] = [] + for ass in assiduites_query.all(): + data = ass.to_dict(format_api=True) + data = _with_justifs(data) + data_set.append(data) + + return data_set + + +@bp.route("/assiduites/group/query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True}) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def assiduites_group(with_query: bool = False): + """ + Retourne toutes les assiduités d'un groupe d'étudiants + chemin : /assiduites/group/query?etudids=1,2,3 + + Un filtrage peut être donné avec une query + chemin : /assiduites/group/query?etudids=1,2,3 + + Les différents filtres : + Etat (etat de l'étudiant -> absent, present ou retard): + query?etat=[- liste des états séparé par une virgule -] + ex: .../query?etat=present,retard + Date debut + (date de début de l'assiduité, sont affichés les assiduités + dont la date de début est supérieur ou égale à la valeur donnée): + query?date_debut=[- date au format iso -] + ex: query?date_debut=2022-11-03T08:00+01:00 + Date fin + (date de fin de l'assiduité, sont affichés les assiduités + dont la date de fin est inférieure ou égale à la valeur donnée): + query?date_fin=[- date au format iso -] + ex: query?date_fin=2022-11-03T10:00+01:00 + Moduleimpl_id (l'id du module concerné par l'assiduité): + query?moduleimpl_id=[- int ou vide -] + ex: query?moduleimpl_id=1234 + query?moduleimpl_od= + Formsemstre_id (l'id du formsemestre concerné par l'assiduité) + query?formsemstre_id=[int] + ex query?formsemestre_id=3 + user_id (l'id de l'auteur de l'assiduité) + query?user_id=[int] + ex query?user_id=3 + est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard)) + query?est_just=[bool] + query?est_just=f + query?est_just=t + + + """ + + etuds = request.args.get("etudids", "") + etuds = etuds.split(",") + try: + etuds = [int(etu) for etu in etuds] + except ValueError: + return json_error(404, "Le champs etudids n'est pas correctement formé") + + query = Identite.query.filter(Identite.id.in_(etuds)) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + + if len(etuds) != query.count() or len(etuds) == 0: + return json_error( + 404, + "Tous les étudiants ne sont pas dans le même département et/ou n'existe pas.", + ) + assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds)) + + if with_query: + assiduites_query = _filter_manager(request, assiduites_query) + + data_set: dict[list[dict]] = {str(key): [] for key in etuds} + for ass in assiduites_query.all(): + data = ass.to_dict(format_api=True) + data = _with_justifs(data) + data_set.get(str(data["etudid"])).append(data) + return data_set + + +@bp.route( + "/assiduites/formsemestre/", defaults={"with_query": False} +) +@api_web_bp.route( + "/assiduites/formsemestre/", defaults={"with_query": False} +) +@bp.route( + "/assiduites/formsemestre//query", + defaults={"with_query": True}, +) +@api_web_bp.route( + "/assiduites/formsemestre//query", + defaults={"with_query": True}, +) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): + """Retourne toutes les assiduités du formsemestre""" + formsemestre: FormSemestre = None + formsemestre_id = int(formsemestre_id) + formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + + 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 + ) + + if with_query: + assiduites_query = _filter_manager(request, assiduites_query) + + data_set: list[dict] = [] + for ass in assiduites_query.all(): + data = ass.to_dict(format_api=True) + data = _with_justifs(data) + data_set.append(data) + + return data_set + + +@bp.route( + "/assiduites/formsemestre//count", + defaults={"with_query": False}, +) +@api_web_bp.route( + "/assiduites/formsemestre//count", + defaults={"with_query": False}, +) +@bp.route( + "/assiduites/formsemestre//count/query", + defaults={"with_query": True}, +) +@api_web_bp.route( + "/assiduites/formsemestre//count/query", + defaults={"with_query": True}, +) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def count_assiduites_formsemestre( + formsemestre_id: int = None, with_query: bool = False +): + """Comptage des assiduités du formsemestre""" + formsemestre: FormSemestre = None + formsemestre_id = int(formsemestre_id) + formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + + if formsemestre is None: + return json_error(404, "le paramètre 'formsemestre_id' n'existe pas") + + etuds = formsemestre.etuds.all() + etuds_id = [etud.id for etud in etuds] + + assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id)) + assiduites_query = scass.filter_by_formsemestre( + assiduites_query, Assiduite, formsemestre + ) + metric: str = "all" + filtered: dict = {} + if with_query: + metric, filtered = _count_manager(request) + + return scass.get_assiduites_stats(assiduites_query, metric, filtered) + + +# etudid +@bp.route("/assiduite//create", methods=["POST"]) +@api_web_bp.route("/assiduite//create", methods=["POST"]) +@bp.route("/assiduite/etudid//create", methods=["POST"]) +@api_web_bp.route("/assiduite/etudid//create", methods=["POST"]) +# nip +@bp.route("/assiduite/nip//create", methods=["POST"]) +@api_web_bp.route("/assiduite/nip//create", methods=["POST"]) +# ine +@bp.route("/assiduite/ine//create", methods=["POST"]) +@api_web_bp.route("/assiduite/ine//create", methods=["POST"]) +# +@scodoc +@as_json +@login_required +@permission_required(Permission.ScoAbsChange) +def assiduite_create(etudid: int = None, nip=None, ine=None): + """ + Création d'une assiduité pour l'étudiant (etudid) + La requête doit avoir un content type "application/json": + [ + { + "date_debut": str, + "date_fin": str, + "etat": str, + }, + { + "date_debut": str, + "date_fin": str, + "etat": str, + "moduleimpl_id": int, + "desc":str, + } + ... + ] + + """ + etud: Identite = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + + create_list: list[object] = request.get_json(force=True) + + if not isinstance(create_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + errors: list = [] + success: list = [] + for i, data in enumerate(create_list): + code, obj = _create_singular(data, etud) + if code == 404: + errors.append({"indice": i, "message": obj}) + else: + success.append({"indice": i, "message": obj}) + scass.simple_invalidate_cache(data, etud.id) + + db.session.commit() + + return {"errors": errors, "success": success} + + +@bp.route("/assiduites/create", methods=["POST"]) +@api_web_bp.route("/assiduites/create", methods=["POST"]) +@scodoc +@as_json +@login_required +@permission_required(Permission.ScoAbsChange) +def assiduites_create(): + """ + Création d'une assiduité ou plusieurs assiduites + La requête doit avoir un content type "application/json": + [ + { + "date_debut": str, + "date_fin": str, + "etat": str, + "etudid":int, + }, + { + "date_debut": str, + "date_fin": str, + "etat": str, + "etudid":int, + + "moduleimpl_id": int, + "desc":str, + } + ... + ] + + """ + + create_list: list[object] = request.get_json(force=True) + + if not isinstance(create_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + errors: list = [] + success: list = [] + for i, data in enumerate(create_list): + etud: Identite = Identite.query.filter_by(id=data["etudid"]).first() + if etud is None: + errors.append({"indice": i, "message": "Cet étudiant n'existe pas."}) + continue + + code, obj = _create_singular(data, etud) + if code == 404: + errors.append({"indice": i, "message": obj}) + else: + success.append({"indice": i, "message": obj}) + scass.simple_invalidate_cache(data) + + return {"errors": errors, "success": success} + + +def _create_singular( + data: dict, + etud: Identite, +) -> tuple[int, object]: + errors: list[str] = [] + + # -- vérifications de l'objet json -- + # cas 1 : ETAT + etat = data.get("etat", None) + if etat is None: + errors.append("param 'etat': manquant") + elif not scu.EtatAssiduite.contains(etat): + errors.append("param 'etat': invalide") + + etat = scu.EtatAssiduite.get(etat) + + # cas 2 : date_debut + date_debut = data.get("date_debut", None) + if date_debut is None: + errors.append("param 'date_debut': manquant") + deb = scu.is_iso_formated(date_debut, convert=True) + if deb is None: + errors.append("param 'date_debut': format invalide") + + # cas 3 : date_fin + date_fin = data.get("date_fin", None) + if date_fin is None: + errors.append("param 'date_fin': manquant") + fin = scu.is_iso_formated(date_fin, convert=True) + if fin is None: + errors.append("param 'date_fin': format invalide") + + # cas 4 : moduleimpl_id + + moduleimpl_id = data.get("moduleimpl_id", False) + moduleimpl: ModuleImpl = None + + if moduleimpl_id not in [False, None]: + moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first() + if moduleimpl is None: + errors.append("param 'moduleimpl_id': invalide") + + # cas 5 : desc + + desc: str = data.get("desc", None) + + 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") + + if errors: + err: str = ", ".join(errors) + return (404, err) + + # TOUT EST OK + try: + nouv_assiduite: Assiduite = Assiduite.create_assiduite( + date_debut=deb, + date_fin=fin, + etat=etat, + etud=etud, + moduleimpl=moduleimpl, + description=desc, + user_id=current_user.id, + external_data=external_data, + ) + + db.session.add(nouv_assiduite) + db.session.commit() + + return (200, {"assiduite_id": nouv_assiduite.id}) + except ScoValueError as excp: + return ( + 404, + excp.args[0], + ) + + +@bp.route("/assiduite/delete", methods=["POST"]) +@api_web_bp.route("/assiduite/delete", methods=["POST"]) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoAbsChange) +def assiduite_delete(): + """ + Suppression d'une assiduité à partir de son id + + Forme des données envoyées : + + [ + , + ... + ] + + + """ + assiduites_list: list[int] = request.get_json(force=True) + if not isinstance(assiduites_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + output = {"errors": [], "success": []} + + for i, ass in enumerate(assiduites_list): + code, msg = _delete_singular(ass, db) + if code == 404: + output["errors"].append({"indice": i, "message": msg}) + else: + output["success"].append({"indice": i, "message": "OK"}) + + db.session.commit() + return output + + +def _delete_singular(assiduite_id: int, database): + assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first() + if assiduite_unique is None: + return (404, "Assiduite non existante") + ass_dict = assiduite_unique.to_dict() + log(f"delete_assiduite: {assiduite_unique.etudiant.id} {assiduite_unique}") + Scolog.logdb( + method="delete_assiduite", + etudid=assiduite_unique.etudiant.id, + msg=f"assiduité: {assiduite_unique}", + ) + database.session.delete(assiduite_unique) + scass.simple_invalidate_cache(ass_dict) + return (200, "OK") + + +@bp.route("/assiduite//edit", methods=["POST"]) +@api_web_bp.route("/assiduite//edit", methods=["POST"]) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoAbsChange) +def assiduite_edit(assiduite_id: int): + """ + Edition d'une assiduité à partir de son id + La requête doit avoir un content type "application/json": + { + "etat"?: str, + "moduleimpl_id"?: int + "desc"?: str + "est_just"?: bool + } + """ + assiduite_unique: Assiduite = Assiduite.query.filter_by( + id=assiduite_id + ).first_or_404() + errors: list[str] = [] + data = request.get_json(force=True) + + # Vérifications de data + + # Cas 1 : Etat + if data.get("etat") is not None: + etat = scu.EtatAssiduite.get(data.get("etat")) + if etat is None: + errors.append("param 'etat': invalide") + else: + assiduite_unique.etat = etat + + # Cas 2 : Moduleimpl_id + moduleimpl_id = data.get("moduleimpl_id", False) + moduleimpl: ModuleImpl = None + + if moduleimpl_id is not False: + if moduleimpl_id is not None and moduleimpl_id != "": + moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first() + if moduleimpl is None: + errors.append("param 'moduleimpl_id': invalide") + else: + if not moduleimpl.est_inscrit( + Identite.query.filter_by(id=assiduite_unique.etudid).first() + ): + errors.append("param 'moduleimpl_id': etud non inscrit") + else: + assiduite_unique.moduleimpl_id = moduleimpl_id + else: + assiduite_unique.moduleimpl_id = None + + # Cas 3 : desc + desc = data.get("desc", False) + if desc is not False: + assiduite_unique.description = desc + + # Cas 4 : est_just + est_just = data.get("est_just") + if est_just is not None: + if not isinstance(est_just, bool): + errors.append("param 'est_just' : booléen non reconnu") + else: + assiduite_unique.est_just = est_just + + external_data = data.get("external_data") + if external_data is not None: + if not isinstance(external_data, dict): + errors.append("param 'external_data' : n'est pas un objet JSON") + else: + assiduite_unique.external_data = external_data + + if errors: + err: str = ", ".join(errors) + return json_error(404, err) + + log(f"assiduite_edit: {assiduite_unique.etudiant.id} {assiduite_unique}") + Scolog.logdb( + "assiduite_edit", + assiduite_unique.etudiant.id, + msg=f"assiduite: modif {assiduite_unique}", + ) + db.session.add(assiduite_unique) + db.session.commit() + scass.simple_invalidate_cache(assiduite_unique.to_dict()) + + return {"OK": True} + + +@bp.route("/assiduites/edit", methods=["POST"]) +@api_web_bp.route("/assiduites/edit", methods=["POST"]) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoAbsChange) +def assiduites_edit(): + """ + Edition de plusieurs assiduités + La requête doit avoir un content type "application/json": + [ + { + "assiduite_id" : int, + "etat"?: str, + "moduleimpl_id"?: int + "desc"?: str + "est_just"?: bool + } + ] + """ + edit_list: list[object] = request.get_json(force=True) + + if not isinstance(edit_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + errors: list[dict] = [] + success: list[dict] = [] + for i, data in enumerate(edit_list): + assi: Identite = Assiduite.query.filter_by(id=data["assiduite_id"]).first() + if assi is None: + errors.append( + { + "indice": i, + "message": f"assiduité {data['assiduite_id']} n'existe pas.", + } + ) + continue + + code, obj = _edit_singular(assi, data) + obj_retour = { + "indice": i, + "message": obj, + } + if code == 404: + errors.append(obj_retour) + else: + success.append(obj_retour) + + db.session.commit() + + return {"errors": errors, "success": success} + + +def _edit_singular(assiduite_unique, data): + errors: list[str] = [] + + # Vérifications de data + + # Cas 1 : Etat + if data.get("etat") is not None: + etat = scu.EtatAssiduite.get(data.get("etat")) + if etat is None: + errors.append("param 'etat': invalide") + else: + assiduite_unique.etat = etat + + # Cas 2 : Moduleimpl_id + moduleimpl_id = data.get("moduleimpl_id", False) + moduleimpl: ModuleImpl = None + + if moduleimpl_id is not False: + if moduleimpl_id is not None: + moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first() + if moduleimpl is None: + errors.append("param 'moduleimpl_id': invalide") + else: + if not moduleimpl.est_inscrit( + Identite.query.filter_by(id=assiduite_unique.etudid).first() + ): + errors.append("param 'moduleimpl_id': etud non inscrit") + else: + assiduite_unique.moduleimpl_id = moduleimpl_id + else: + assiduite_unique.moduleimpl_id = moduleimpl_id + + # Cas 3 : desc + desc = data.get("desc", False) + if desc is not False: + assiduite_unique.desc = desc + + # Cas 4 : est_just + est_just = data.get("est_just") + if est_just is not None: + if not isinstance(est_just, bool): + errors.append("param 'est_just' : booléen non reconnu") + else: + assiduite_unique.est_just = est_just + + if errors: + err: str = ", ".join(errors) + return (404, err) + + log(f"_edit_singular: {assiduite_unique.etudiant.id} {assiduite_unique}") + Scolog.logdb( + "assiduite_edit", + assiduite_unique.etudiant.id, + msg=f"assiduite: modif {assiduite_unique}", + ) + db.session.add(assiduite_unique) + scass.simple_invalidate_cache(assiduite_unique.to_dict()) + + return (200, "OK") + + +# -- Utils -- + + +def _count_manager(requested) -> tuple[str, dict]: + """ + Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête + """ + filtered: dict = {} + # cas 1 : etat assiduite + etat = requested.args.get("etat") + if etat is not None: + filtered["etat"] = etat + + # cas 2 : date de début + deb = requested.args.get("date_debut", "").replace(" ", "+") + deb: datetime = scu.is_iso_formated(deb, True) + if deb is not None: + filtered["date_debut"] = deb + + # cas 3 : date de fin + fin = requested.args.get("date_fin", "").replace(" ", "+") + fin = scu.is_iso_formated(fin, True) + + if fin is not None: + filtered["date_fin"] = fin + + # cas 4 : moduleimpl_id + module = requested.args.get("moduleimpl_id", False) + try: + if module is False: + raise ValueError + if module != "": + module = int(module) + else: + module = None + except ValueError: + module = False + + if module is not False: + filtered["moduleimpl_id"] = module + + # cas 5 : formsemestre_id + formsemestre_id = requested.args.get("formsemestre_id") + + if formsemestre_id is not None: + formsemestre: FormSemestre = None + formsemestre_id = int(formsemestre_id) + formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + filtered["formsemestre"] = formsemestre + + # cas 6 : type + metric = requested.args.get("metric", "all") + + # cas 7 : est_just + + est_just: str = requested.args.get("est_just") + if est_just is not None: + trues: tuple[str] = ("v", "t", "vrai", "true") + falses: tuple[str] = ("f", "faux", "false") + + if est_just.lower() in trues: + filtered["est_just"] = True + elif est_just.lower() in falses: + filtered["est_just"] = False + + # cas 8 : user_id + + user_id = requested.args.get("user_id", False) + if user_id is not False: + filtered["user_id"] = user_id + + return (metric, filtered) + + +def _filter_manager(requested, assiduites_query: Query) -> Query: + """ + Retourne les assiduites entrées filtrées en fonction de la request + """ + # cas 1 : etat assiduite + etat = requested.args.get("etat") + if etat is not None: + assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat) + + # cas 2 : date de début + deb = requested.args.get("date_debut", "").replace(" ", "+") + deb: datetime = scu.is_iso_formated(deb, True) + + # cas 3 : date de fin + fin = requested.args.get("date_fin", "").replace(" ", "+") + fin = scu.is_iso_formated(fin, True) + + if (deb, fin) != (None, None): + assiduites_query: Query = scass.filter_by_date( + assiduites_query, Assiduite, deb, fin + ) + + # cas 4 : moduleimpl_id + module = requested.args.get("moduleimpl_id", False) + try: + if module is False: + raise ValueError + if module != "": + module = int(module) + else: + module = None + except ValueError: + module = False + + if module is not False: + assiduites_query = scass.filter_by_module_impl(assiduites_query, module) + + # cas 5 : formsemestre_id + formsemestre_id = requested.args.get("formsemestre_id") + + if formsemestre_id is not None: + formsemestre: FormSemestre = None + formsemestre_id = int(formsemestre_id) + formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + assiduites_query = scass.filter_by_formsemestre( + assiduites_query, Assiduite, formsemestre + ) + + # cas 6 : est_just + + est_just: str = requested.args.get("est_just") + if est_just is not None: + trues: tuple[str] = ("v", "t", "vrai", "true") + falses: tuple[str] = ("f", "faux", "false") + + if est_just.lower() in trues: + assiduites_query: Query = scass.filter_assiduites_by_est_just( + assiduites_query, True + ) + elif est_just.lower() in falses: + assiduites_query: Query = scass.filter_assiduites_by_est_just( + assiduites_query, False + ) + + # cas 8 : user_id + + user_id = requested.args.get("user_id", False) + if user_id is not False: + assiduites_query: Query = scass.filter_by_user_id(assiduites_query, user_id) + + return assiduites_query + + +def _with_justifs(assi): + if request.args.get("with_justifs") is None: + return assi + assi["justificatifs"] = get_assiduites_justif(assi["assiduite_id"], True) + return assi diff --git a/app/api/departements.py b/app/api/departements.py index a5d87bb5b..95a9c4e9a 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 old mode 100644 new mode 100755 index e8030b019..572f77ca4 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -34,6 +34,7 @@ from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import json_error, suppress_accents +import app.scodoc.sco_photos as sco_photos # Un exemple: # @bp.route("/api_function/") @@ -136,6 +137,81 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): return etud.to_dict_api() +@bp.route("/etudiant/etudid//photo") +@bp.route("/etudiant/nip//photo") +@bp.route("/etudiant/ine//photo") +@api_web_bp.route("/etudiant/etudid//photo") +@api_web_bp.route("/etudiant/nip//photo") +@api_web_bp.route("/etudiant/ine//photo") +@login_required +@scodoc +@permission_required(Permission.ScoView) +def get_photo_image(etudid: int = None, nip: str = None, ine: str = None): + """ + Retourne la photo de l'étudiant + correspondant ou un placeholder si non existant. + + etudid : l'etudid de l'étudiant + nip : le code nip de l'étudiant + ine : le code ine de l'étudiant + """ + + etud = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + if not etudid: + filename = sco_photos.UNKNOWN_IMAGE_PATH + + size = request.args.get("size", "orig") + filename = sco_photos.photo_pathname(etud.photo_filename, size=size) + if not filename: + filename = sco_photos.UNKNOWN_IMAGE_PATH + res = sco_photos.build_image_response(filename) + 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 af1c40af3..6643ea844 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 2712a2c28..e8a145a5a 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 18b987387..40fc81bb7 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 294eef2f6..9d4aad56b 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 new file mode 100644 index 000000000..0fd59b975 --- /dev/null +++ b/app/api/justificatifs.py @@ -0,0 +1,699 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 API : Assiduités +""" +from datetime import datetime + +from flask_json import as_json +from flask import g, jsonify, request +from flask_login import login_required, current_user + +import app.scodoc.sco_assiduites as scass +import app.scodoc.sco_utils as scu +from app import db +from app.api import api_bp as bp +from app.api import api_web_bp +from app.api import get_model_api_object, tools +from app.decorators import permission_required, scodoc +from app.models import Identite, Justificatif, Departement, FormSemestre +from app.models.assiduites import ( + compute_assiduites_justified, +) +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 +@bp.route("/justificatif/") +@api_web_bp.route("/justificatif/") +@scodoc +@permission_required(Permission.ScoView) +def justificatif(justif_id: int = None): + """Retourne un objet justificatif à partir de son id + + Exemple de résultat: + { + "justif_id": 1, + "etudid": 2, + "date_debut": "2022-10-31T08:00+01:00", + "date_fin": "2022-10-31T10:00+01:00", + "etat": "valide", + "fichier": "archive_id", + "raison": "une raison", + "entry_date": "2022-10-31T08:00+01:00", + "user_id": 1 or null, + } + + """ + + return get_model_api_object(Justificatif, justif_id, Identite) + + +# etudid +@bp.route("/justificatifs/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs/", defaults={"with_query": False}) +@bp.route("/justificatifs//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs//query", defaults={"with_query": True}) +@bp.route("/justificatifs/etudid/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs/etudid/", defaults={"with_query": False}) +@bp.route("/justificatifs/etudid//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/etudid//query", defaults={"with_query": True}) +# nip +@bp.route("/justificatifs/nip/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs/nip/", defaults={"with_query": False}) +@bp.route("/justificatifs/nip//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/nip//query", defaults={"with_query": True}) +# ine +@bp.route("/justificatifs/ine/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs/ine/", defaults={"with_query": False}) +@bp.route("/justificatifs/ine//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/ine//query", defaults={"with_query": True}) +# +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False): + """ + Retourne toutes les assiduités d'un étudiant + chemin : /justificatifs/ + + Un filtrage peut être donné avec une query + chemin : /justificatifs//query? + + Les différents filtres : + Etat (etat du justificatif -> validé, non validé, modifé, en attente): + query?etat=[- liste des états séparé par une virgule -] + ex: .../query?etat=validé,modifié + Date debut + (date de début du justificatif, sont affichés les justificatifs + dont la date de début est supérieur ou égale à la valeur donnée): + query?date_debut=[- date au format iso -] + ex: query?date_debut=2022-11-03T08:00+01:00 + Date fin + (date de fin du justificatif, sont affichés les justificatifs + dont la date de fin est inférieure ou égale à la valeur donnée): + query?date_fin=[- date au format iso -] + ex: query?date_fin=2022-11-03T10:00+01:00 + user_id (l'id de l'auteur du justificatif) + query?user_id=[int] + ex query?user_id=3 + """ + + etud: Identite = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + justificatifs_query = etud.justificatifs + + if with_query: + justificatifs_query = _filter_manager(request, justificatifs_query) + + data_set: list[dict] = [] + for just in justificatifs_query.all(): + data = just.to_dict(format_api=True) + data_set.append(data) + + return data_set + + +@api_web_bp.route("/justificatifs/dept/", defaults={"with_query": False}) +@api_web_bp.route( + "/justificatifs/dept//query", defaults={"with_query": True} +) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def justificatifs_dept(dept_id: int = None, with_query: bool = False): + """ """ + dept = Departement.query.get_or_404(dept_id) + etuds = [etud.id for etud in dept.etudiants] + + justificatifs_query = Justificatif.query.filter(Justificatif.etudid.in_(etuds)) + + if with_query: + justificatifs_query = _filter_manager(request, justificatifs_query) + data_set: list[dict] = [] + for just in justificatifs_query.all(): + data = just.to_dict(format_api=True) + data_set.append(data) + + return data_set + + +@bp.route("/justificatif//create", methods=["POST"]) +@api_web_bp.route("/justificatif//create", methods=["POST"]) +@bp.route("/justificatif/etudid//create", methods=["POST"]) +@api_web_bp.route("/justificatif/etudid//create", methods=["POST"]) +# nip +@bp.route("/justificatif/nip//create", methods=["POST"]) +@api_web_bp.route("/justificatif/nip//create", methods=["POST"]) +# ine +@bp.route("/justificatif/ine//create", methods=["POST"]) +@api_web_bp.route("/justificatif/ine//create", methods=["POST"]) +@scodoc +@login_required +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_create(etudid: int = None, nip=None, ine=None): + """ + Création d'un justificatif pour l'étudiant (etudid) + La requête doit avoir un content type "application/json": + [ + { + "date_debut": str, + "date_fin": str, + "etat": str, + }, + { + "date_debut": str, + "date_fin": str, + "etat": str, + "raison":str, + } + ... + ] + + """ + etud: Identite = tools.get_etud(etudid, nip, ine) + + if etud is None: + return json_error( + 404, + message="étudiant inconnu", + ) + + create_list: list[object] = request.get_json(force=True) + + if not isinstance(create_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + errors: list = [] + success: list = [] + justifs: list = [] + for i, data in enumerate(create_list): + code, obj, justi = _create_singular(data, etud) + if code == 404: + errors.append({"indice": i, "message": obj}) + else: + success.append({"indice": i, "message": obj}) + justifs.append(justi) + scass.simple_invalidate_cache(data, etud.id) + + compute_assiduites_justified(etud.etudid, justifs) + return {"errors": errors, "success": success} + + +def _create_singular( + data: dict, + etud: Identite, +) -> tuple[int, object]: + errors: list[str] = [] + + # -- vérifications de l'objet json -- + # cas 1 : ETAT + etat = data.get("etat", None) + if etat is None: + errors.append("param 'etat': manquant") + elif not scu.EtatJustificatif.contains(etat): + errors.append("param 'etat': invalide") + + etat = scu.EtatJustificatif.get(etat) + + # cas 2 : date_debut + date_debut = data.get("date_debut", None) + if date_debut is None: + errors.append("param 'date_debut': manquant") + deb = scu.is_iso_formated(date_debut, convert=True) + if deb is None: + errors.append("param 'date_debut': format invalide") + + # cas 3 : date_fin + date_fin = data.get("date_fin", None) + if date_fin is None: + errors.append("param 'date_fin': manquant") + fin = scu.is_iso_formated(date_fin, convert=True) + if fin is None: + errors.append("param 'date_fin': format invalide") + + # cas 4 : raison + + raison: str = data.get("raison", None) + + external_data = data.get("external_data") + if external_data is not None: + if not isinstance(external_data, dict): + errors.append("param 'external_data' : n'est pas un objet JSON") + + if errors: + err: str = ", ".join(errors) + return (404, err, None) + + # TOUT EST OK + + try: + nouv_justificatif: Query = Justificatif.create_justificatif( + date_debut=deb, + date_fin=fin, + etat=etat, + etud=etud, + raison=raison, + user_id=current_user.id, + external_data=external_data, + ) + + db.session.add(nouv_justificatif) + db.session.commit() + + return ( + 200, + { + "justif_id": nouv_justificatif.id, + "couverture": scass.justifies(nouv_justificatif), + }, + nouv_justificatif, + ) + except ScoValueError as excp: + return ( + 404, + excp.args[0], + ) + + +@bp.route("/justificatif//edit", methods=["POST"]) +@api_web_bp.route("/justificatif//edit", methods=["POST"]) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_edit(justif_id: int): + """ + Edition d'un justificatif à partir de son id + La requête doit avoir un content type "application/json": + + { + "etat"?: str, + "raison"?: str + "date_debut"?: str + "date_fin"?: str + } + """ + justificatif_unique: Query = Justificatif.query.filter_by( + id=justif_id + ).first_or_404() + + errors: list[str] = [] + data = request.get_json(force=True) + avant_ids: list[int] = scass.justifies(justificatif_unique) + # Vérifications de data + + # Cas 1 : Etat + if data.get("etat") is not None: + etat = scu.EtatJustificatif.get(data.get("etat")) + if etat is None: + errors.append("param 'etat': invalide") + else: + justificatif_unique.etat = etat + + # Cas 2 : raison + raison = data.get("raison", False) + if raison is not False: + justificatif_unique.raison = raison + + deb, fin = None, None + + # cas 3 : date_debut + date_debut = data.get("date_debut", False) + if date_debut is not False: + if date_debut is None: + errors.append("param 'date_debut': manquant") + deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True) + if deb is None: + errors.append("param 'date_debut': format invalide") + + # cas 4 : date_fin + date_fin = data.get("date_fin", False) + if date_fin is not False: + if date_fin is None: + errors.append("param 'date_fin': manquant") + fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True) + if fin is None: + errors.append("param 'date_fin': format invalide") + + # Mise à jour des dates + deb = deb if deb is not None else justificatif_unique.date_debut + fin = fin if fin is not None else justificatif_unique.date_fin + + external_data = data.get("external_data") + if external_data is not None: + if not isinstance(external_data, dict): + errors.append("param 'external_data' : n'est pas un objet JSON") + else: + justificatif_unique.external_data = external_data + + if fin <= deb: + errors.append("param 'dates' : Date de début après date de fin") + + justificatif_unique.date_debut = deb + justificatif_unique.date_fin = fin + + if errors: + err: str = ", ".join(errors) + return json_error(404, err) + + db.session.add(justificatif_unique) + db.session.commit() + + retour = { + "couverture": { + "avant": avant_ids, + "après": compute_assiduites_justified( + justificatif_unique.etudid, + [justificatif_unique], + False, + ), + } + } + + scass.simple_invalidate_cache(justificatif_unique.to_dict()) + return retour + + +@bp.route("/justificatif/delete", methods=["POST"]) +@api_web_bp.route("/justificatif/delete", methods=["POST"]) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_delete(): + """ + Suppression d'un justificatif à partir de son id + + Forme des données envoyées : + + [ + , + ... + ] + + + """ + justificatifs_list: list[int] = request.get_json(force=True) + if not isinstance(justificatifs_list, list): + return json_error(404, "Le contenu envoyé n'est pas une liste") + + output = {"errors": [], "success": []} + + for i, ass in enumerate(justificatifs_list): + code, msg = _delete_singular(ass, db) + if code == 404: + output["errors"].append({"indice": i, "message": msg}) + else: + output["success"].append({"indice": i, "message": "OK"}) + + db.session.commit() + + return output + + +def _delete_singular(justif_id: int, database): + justificatif_unique: Query = Justificatif.query.filter_by(id=justif_id).first() + if justificatif_unique is None: + return (404, "Justificatif non existant") + + archive_name: str = justificatif_unique.fichier + + if archive_name is not None: + archiver: JustificatifArchiver = JustificatifArchiver() + try: + archiver.delete_justificatif(justificatif_unique.etudid, archive_name) + except ValueError: + pass + + scass.simple_invalidate_cache(justificatif_unique.to_dict()) + database.session.delete(justificatif_unique) + compute_assiduites_justified( + justificatif_unique.etudid, + Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(), + True, + ) + + return (200, "OK") + + +# Partie archivage +@bp.route("/justificatif//import", methods=["POST"]) +@api_web_bp.route("/justificatif//import", methods=["POST"]) +@scodoc +@login_required +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_import(justif_id: int = None): + """ + Importation d'un fichier (création d'archive) + """ + if len(request.files) == 0: + return json_error(404, "Il n'y a pas de fichier joint") + + file = list(request.files.values())[0] + if file.filename == "": + return json_error(404, "Il n'y a pas de fichier joint") + + 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() + + archive_name: str = justificatif_unique.fichier + + archiver: JustificatifArchiver = JustificatifArchiver() + try: + fname: str + archive_name, fname = archiver.save_justificatif( + etudid=justificatif_unique.etudid, + filename=file.filename, + data=file.stream.read(), + archive_name=archive_name, + user_id=current_user.id, + ) + + justificatif_unique.fichier = archive_name + + db.session.add(justificatif_unique) + db.session.commit() + + return {"filename": fname} + except ScoValueError as err: + return json_error(404, err.args[0]) + + +@bp.route("/justificatif//export/", methods=["POST"]) +@api_web_bp.route("/justificatif//export/", methods=["POST"]) +@scodoc +@login_required +@permission_required(Permission.ScoAbsChange) +def justif_export(justif_id: int = None, filename: str = None): + """ + Retourne un fichier d'une archive d'un justificatif + """ + + 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: Justificaitf = query.first_or_404() + + archive_name: str = justificatif_unique.fichier + if archive_name is None: + return json_error(404, "le justificatif ne possède pas de fichier") + + archiver: JustificatifArchiver = JustificatifArchiver() + + try: + return archiver.get_justificatif_file( + archive_name, justificatif_unique.etudid, filename + ) + except ScoValueError as err: + return json_error(404, err.args[0]) + + +@bp.route("/justificatif//remove", methods=["POST"]) +@api_web_bp.route("/justificatif//remove", methods=["POST"]) +@scodoc +@login_required +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_remove(justif_id: int = None): + """ + Supression d'un fichier ou d'une archive + # TOTALK: Doc, expliquer les noms coté server + { + "remove": <"all"/"list"> + + "filenames"?: [ + , + ... + ] + } + """ + + data: dict = request.get_json(force=True) + + 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() + + archive_name: str = justificatif_unique.fichier + if archive_name is None: + return json_error(404, "le justificatif ne possède pas de fichier") + + remove: str = data.get("remove") + if remove is None or remove not in ("all", "list"): + return json_error(404, "param 'remove': Valeur invalide") + archiver: JustificatifArchiver = JustificatifArchiver() + etudid: int = justificatif_unique.etudid + try: + if remove == "all": + archiver.delete_justificatif(etudid=etudid, archive_name=archive_name) + justificatif_unique.fichier = None + db.session.add(justificatif_unique) + db.session.commit() + + else: + for fname in data.get("filenames", []): + archiver.delete_justificatif( + etudid=etudid, + archive_name=archive_name, + filename=fname, + ) + + if len(archiver.list_justificatifs(archive_name, etudid)) == 0: + archiver.delete_justificatif(etudid, archive_name) + justificatif_unique.fichier = None + db.session.add(justificatif_unique) + db.session.commit() + + except ScoValueError as err: + return json_error(404, err.args[0]) + + return {"response": "removed"} + + +@bp.route("/justificatif//list", methods=["GET"]) +@api_web_bp.route("/justificatif//list", methods=["GET"]) +@scodoc +@login_required +@as_json +@permission_required(Permission.ScoView) +def justif_list(justif_id: int = None): + """ + Liste les fichiers du justificatif + """ + + 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() + + archive_name: str = justificatif_unique.fichier + + filenames: list[str] = [] + + archiver: JustificatifArchiver = JustificatifArchiver() + if archive_name is not None: + filenames = archiver.list_justificatifs( + archive_name, justificatif_unique.etudid + ) + + retour = {"total": len(filenames), "filenames": []} + + for fi in filenames: + if int(fi[1]) == current_user.id or current_user.has_permission( + Permission.ScoJustifView + ): + retour["filenames"].append(fi[0]) + return retour + + +# Partie justification +@bp.route("/justificatif//justifies", methods=["GET"]) +@api_web_bp.route("/justificatif//justifies", methods=["GET"]) +@scodoc +@login_required +@as_json +@permission_required(Permission.ScoAbsChange) +def justif_justifies(justif_id: int = None): + """ + Liste assiduite_id justifiées par le justificatif + """ + + 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() + + assiduites_list: list[int] = scass.justifies(justificatif_unique) + + return assiduites_list + + +# -- Utils -- + + +def _filter_manager(requested, justificatifs_query): + """ + Retourne les justificatifs entrés filtrés en fonction de la request + """ + # cas 1 : etat justificatif + etat = requested.args.get("etat") + if etat is not None: + justificatifs_query = scass.filter_justificatifs_by_etat( + justificatifs_query, etat + ) + + # cas 2 : date de début + deb = requested.args.get("date_debut", "").replace(" ", "+") + deb: datetime = scu.is_iso_formated(deb, True) + + # cas 3 : date de fin + fin = requested.args.get("date_fin", "").replace(" ", "+") + fin = scu.is_iso_formated(fin, True) + + if (deb, fin) != (None, None): + 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: Query = scass.filter_by_user_id( + justificatifs_query, user_id + ) + + # cas 5 : formsemestre_id + formsemestre_id = requested.args.get("formsemestre_id") + + if formsemestre_id is not None: + formsemestre: FormSemestre = None + formsemestre_id = int(formsemestre_id) + formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + justificatifs_query = scass.filter_by_formsemestre( + justificatifs_query, Justificatif, formsemestre + ) + + return justificatifs_query diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py new file mode 100644 index 000000000..b8e87c052 --- /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/api/partitions.py b/app/api/partitions.py index 2be45abc3..5d7f56421 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -110,7 +110,7 @@ def formsemestre_partitions(formsemestre_id: int): def etud_in_group(group_id: int): """ Retourne la liste des étudiants dans un groupe - + (inscrits au groupe et inscrits au semestre). group_id : l'id d'un groupe Exemple de résultat : @@ -133,7 +133,15 @@ def etud_in_group(group_id: int): query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) ) group = query.first_or_404() - return [etud.to_dict_short() for etud in group.etuds] + + query = ( + Identite.query.join(group_membership) + .filter_by(group_id=group_id) + .join(FormSemestreInscription) + .filter_by(formsemestre_id=group.partition.formsemestre_id) + ) + + return [etud.to_dict_short() for etud in query] @bp.route("/group//etudiants/query") @@ -161,7 +169,6 @@ def etud_in_group_query(group_id: int): query = query.filter_by(etat=etat) query = query.join(group_membership).filter_by(group_id=group_id) - return [etud.to_dict_short() for etud in query] @@ -223,7 +230,9 @@ def group_remove_etud(group_id: int, etudid: int): commit=True, ) # Update parcours - group.partition.formsemestre.update_inscriptions_parcours_from_groups() + group.partition.formsemestre.update_inscriptions_parcours_from_groups( + etudid=etudid + ) sco_cache.invalidate_formsemestre(group.partition.formsemestre_id) return {"group_id": group_id, "etudid": etudid} @@ -270,7 +279,7 @@ def partition_remove_etud(partition_id: int, etudid: int): ) db.session.commit() # Update parcours - partition.formsemestre.update_inscriptions_parcours_from_groups() + partition.formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid) app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) return {"partition_id": partition_id, "etudid": etudid} diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 9bac0703a..2e556908f 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 @@ -387,6 +392,11 @@ class BulletinBUT: semestre_infos["absences"] = { "injustifie": nbabs - nbabsjust, "total": nbabs, + "metrique": { + "H.": "Heure(s)", + "J.": "Journée(s)", + "1/2 J.": "1/2 Jour.", + }.get(sco_preferences.get_preference("assi_metrique")), } decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {} if self.prefs["bul_show_ects"]: diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py index 0d5b6b2df..ce215c14f 100644 --- a/app/but/bulletin_but_pdf.py +++ b/app/but/bulletin_but_pdf.py @@ -212,6 +212,34 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): else: self.ue_std_rows(rows, ue, title_bg) + @staticmethod + def affichage_bonus_malus(ue: dict) -> list: + fields_bmr = [] + # lecture des bonus sport culture et malus (ou bonus autre) (0 si valeur non numérique) + try: + bonus_sc = float(ue.get("bonus", 0.0)) or 0 + except ValueError: + bonus_sc = 0 + try: + malus = float(ue.get("malus", 0.0)) or 0 + except ValueError: + malus = 0 + # Calcul de l affichage + if malus < 0: + if bonus_sc > 0: + fields_bmr.append(f"Bonus sport/culture: {bonus_sc}") + fields_bmr.append(f"Bonus autres: {-malus}") + else: + fields_bmr.append(f"Bonus: {-malus}") + elif malus > 0: + if bonus_sc > 0: + fields_bmr.append(f"Bonus: {bonus_sc}") + fields_bmr.append(f"Malus: {malus}") + else: + if bonus_sc > 0: + fields_bmr.append(f"Bonus: {bonus_sc}") + return fields_bmr + def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple): "Lignes décrivant une UE standard dans la table de synthèse" # 2eme ligne titre UE (bonus/malus/ects) @@ -220,20 +248,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): else: ects_txt = "" # case Bonus/Malus/Rang "bmr" - fields_bmr = [] - try: - value = float(ue.get("bonus", 0.0)) - if value != 0: - fields_bmr.append(f"Bonus: {ue['bonus']}") - except ValueError: - pass - try: - value = float(ue.get("malus", 0.0)) - if value != 0: - fields_bmr.append(f"Malus: {ue['malus']}") - except ValueError: - pass - + fields_bmr = BulletinGeneratorStandardBUT.affichage_bonus_malus(ue) moy_ue = ue.get("moyenne", "-") if isinstance(moy_ue, dict): # UE non capitalisées if self.preferences["bul_show_ue_rangs"]: diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 0bb1b9021..45668ac59 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 f2ead5ac2..3ae00b2fa 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 11dd05f62..4180c35e3 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 a1fe0104a..15313550a 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 30d8466b4..071d72e81 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 f79341725..238f38d5a 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 268ea0fb8..50976668d 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 03b91e0cc..175e38df8 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 88fcd095f..fe4116309 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 a75b2def3..a983ef2df 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/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py new file mode 100644 index 000000000..1c18135d5 --- /dev/null +++ b/app/forms/main/config_assiduites.py @@ -0,0 +1,88 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +""" +Formulaire configuration Module Assiduités +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField, DecimalField +from wtforms.fields.simple import StringField +from wtforms.widgets import TimeInput +import datetime + + +class TimeField(StringField): + """HTML5 time input.""" + + widget = TimeInput() + + def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs): + super(TimeField, self).__init__(label, validators, **kwargs) + self.fmt = fmt + self.data = None + + def _value(self): + if self.raw_data: + return " ".join(self.raw_data) + if self.data and isinstance(self.data, str): + self.data = datetime.time(*map(int, self.data.split(":"))) + return self.data and self.data.strftime(self.fmt) or "" + + def process_formdata(self, valuelist): + if valuelist: + time_str = " ".join(valuelist) + try: + components = time_str.split(":") + hour = 0 + minutes = 0 + seconds = 0 + if len(components) in range(2, 4): + hour = int(components[0]) + minutes = int(components[1]) + + if len(components) == 3: + seconds = int(components[2]) + else: + raise ValueError + self.data = datetime.time(hour, minutes, seconds) + except ValueError: + self.data = None + raise ValueError(self.gettext("Not a valid time string")) + + +class ConfigAssiduitesForm(FlaskForm): + "Formulaire paramétrage Module Assiduités" + + morning_time = TimeField("Début de la journée") + lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)") + afternoon_time = TimeField("Fin de la journée") + + tick_time = DecimalField("Granularité de la Time Line (temps en minutes)", places=0) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/main/config_personalized_links.py b/app/forms/main/config_personalized_links.py new file mode 100644 index 000000000..1aed31302 --- /dev/null +++ b/app/forms/main/config_personalized_links.py @@ -0,0 +1,72 @@ +""" +Formulaire configuration liens personalisés (menu "Liens") +""" + +from flask import g, url_for +from flask_wtf import FlaskForm +from wtforms import FieldList, Form, validators +from wtforms.fields.simple import BooleanField, StringField, SubmitField + +from app.models import ScoDocSiteConfig + + +class _PersonalizedLinksForm(FlaskForm): + "form. définition des liens personnalisés" + # construit dynamiquement ci-dessous + + +def PersonalizedLinksForm() -> _PersonalizedLinksForm: + "Création d'un formulaire pour éditer les liens" + + # Formulaire dynamique, on créé une classe ad-hoc + class F(_PersonalizedLinksForm): + pass + + F.links_by_id = dict(enumerate(ScoDocSiteConfig.get_perso_links())) + + def _gen_link_form(idx): + setattr( + F, + f"link_{idx}", + StringField( + f"Titre", + validators=[ + validators.Optional(), + validators.Length(min=1, max=80), + ], + default="", + render_kw={"size": 6}, + ), + ) + setattr( + F, + f"link_url_{idx}", + StringField( + f"URL", + description="adresse, incluant le http.", + validators=[ + validators.Optional(), + validators.URL(), + validators.Length(min=1, max=256), + ], + default="", + ), + ) + setattr( + F, + f"link_with_args_{idx}", + BooleanField( + f"ajouter arguments", + description="query string avec ids", + ), + ) + + # Initialise un champ de saisie par lien + for idx in F.links_by_id: + _gen_link_form(idx) + _gen_link_form("new") + + F.submit = SubmitField("Valider") + F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) + + return F() diff --git a/app/models/__init__.py b/app/models/__init__.py index 39a8d3e29..032ddc861 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -81,3 +81,5 @@ from app.models.but_refcomp import ( from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.config import ScoDocSiteConfig + +from app.models.assiduites import Assiduite, Justificatif diff --git a/app/models/assiduites.py b/app/models/assiduites.py new file mode 100644 index 000000000..7f5520df7 --- /dev/null +++ b/app/models/assiduites.py @@ -0,0 +1,386 @@ +# -*- coding: UTF-8 -* +"""Gestion de l'assiduité (assiduités + justificatifs) +""" +from datetime import datetime + +from app import db, log +from app.models import ModuleImpl, Scolog +from app.models.etudiants import Identite +from app.auth.models import User +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + localize_datetime, +) + +from flask_sqlalchemy.query import Query + + +class Assiduite(db.Model): + """ + Représente une assiduité: + - une plage horaire lié à un état et un étudiant + - un module si spécifiée + - une description si spécifiée + """ + + __tablename__ = "assiduites" + + id = db.Column(db.Integer, primary_key=True, nullable=False) + assiduite_id = db.synonym("id") + + date_debut = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + date_fin = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + + moduleimpl_id = db.Column( + db.Integer, + db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"), + ) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + etat = db.Column(db.Integer, nullable=False) + + description = db.Column(db.Text) + + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + ) + + est_just = db.Column(db.Boolean, server_default="false", nullable=False) + + external_data = db.Column(db.JSON, nullable=True) + + # Déclare la relation "joined" car on va très souvent vouloir récupérer + # l'étudiant en même tant que l'assiduité (perf.: évite nouvelle requete SQL) + etudiant = db.relationship("Identite", back_populates="assiduites", lazy="joined") + + def to_dict(self, format_api=True) -> dict: + """Retourne la représentation json de l'assiduité""" + etat = self.etat + username = self.user_id + if format_api: + etat = EtatAssiduite.inverse().get(self.etat).name + if self.user_id is not None: + user: User = db.session.get(User, self.user_id) + + if user is None: + username = "Non renseigné" + else: + username = user.get_prenomnom() + data = { + "assiduite_id": self.id, + "etudid": self.etudid, + "code_nip": self.etudiant.code_nip, + "moduleimpl_id": self.moduleimpl_id, + "date_debut": self.date_debut, + "date_fin": self.date_fin, + "etat": etat, + "desc": self.description, + "entry_date": self.entry_date, + "user_id": username, + "est_just": self.est_just, + "external_data": self.external_data, + } + return data + + def __str__(self) -> str: + "chaine pour journaux et debug (lisible par humain français)" + try: + etat_str = EtatAssiduite(self.etat).name.lower().capitalize() + except ValueError: + etat_str = "Invalide" + return f"""{etat_str} { + "just." if self.est_just else "non just." + } de { + self.date_debut.strftime("%d/%m/%Y %Hh%M") + } à { + self.date_fin.strftime("%d/%m/%Y %Hh%M") + }""" + + @classmethod + def create_assiduite( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatAssiduite, + moduleimpl: ModuleImpl = None, + description: str = None, + entry_date: datetime = None, + user_id: int = None, + est_just: bool = False, + external_data: dict = None, + ) -> object or int: + """Créer une nouvelle assiduité pour l'étudiant""" + # Vérification de non duplication des périodes + 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)" + ) + + if not est_just: + est_just = ( + len(_get_assiduites_justif(etud.etudid, date_debut, date_fin)) > 0 + ) + + if moduleimpl is not None: + # Vérification de l'existence du module pour l'étudiant + if moduleimpl.est_inscrit(etud): + nouv_assiduite = Assiduite( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + moduleimpl_id=moduleimpl.id, + description=description, + entry_date=entry_date, + user_id=user_id, + est_just=est_just, + external_data=external_data, + ) + else: + raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl") + else: + nouv_assiduite = Assiduite( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + description=description, + entry_date=entry_date, + user_id=user_id, + est_just=est_just, + external_data=external_data, + ) + db.session.add(nouv_assiduite) + log(f"create_assiduite: {etud.id} {nouv_assiduite}") + Scolog.logdb( + method="create_assiduite", + etudid=etud.id, + msg=f"assiduité: {nouv_assiduite}", + ) + return nouv_assiduite + + +class Justificatif(db.Model): + """ + Représente un justificatif: + - une plage horaire lié à un état et un étudiant + - une raison si spécifiée + - un fichier si spécifié + """ + + __tablename__ = "justificatifs" + + id = db.Column(db.Integer, primary_key=True) + justif_id = db.synonym("id") + + date_debut = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + date_fin = db.Column( + db.DateTime(timezone=True), server_default=db.func.now(), nullable=False + ) + + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + etat = db.Column( + db.Integer, + nullable=False, + ) + + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + raison = db.Column(db.Text()) + + # Archive_id -> sco_archives_justificatifs.py + fichier = db.Column(db.Text()) + + # Déclare la relation "joined" car on va très souvent vouloir récupérer + # l'étudiant en même tant que le justificatif (perf.: évite nouvelle requete SQL) + etudiant = db.relationship( + "Identite", back_populates="justificatifs", lazy="joined" + ) + + external_data = db.Column(db.JSON, nullable=True) + + def to_dict(self, format_api: bool = False) -> dict: + """transformation de l'objet en dictionnaire sérialisable""" + + etat = self.etat + username = self.user_id + + if format_api: + etat = EtatJustificatif.inverse().get(self.etat).name + if self.user_id is not None: + user: User = db.session.get(User, self.user_id) + if user is None: + username = "Non renseigné" + else: + username = user.get_prenomnom() + + data = { + "justif_id": self.justif_id, + "etudid": self.etudid, + "code_nip": self.etudiant.code_nip, + "date_debut": self.date_debut, + "date_fin": self.date_fin, + "etat": etat, + "raison": self.raison, + "fichier": self.fichier, + "entry_date": self.entry_date, + "user_id": username, + "external_data": self.external_data, + } + return data + + def __str__(self) -> str: + "chaine pour journaux et debug (lisible par humain français)" + try: + etat_str = EtatJustificatif(self.etat).name + except ValueError: + etat_str = "Invalide" + return f"""Justificatif {etat_str} de { + self.date_debut.strftime("%d/%m/%Y %Hh%M") + } à { + self.date_fin.strftime("%d/%m/%Y %Hh%M") + }""" + + @classmethod + def create_justificatif( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatJustificatif, + raison: str = None, + entry_date: datetime = None, + user_id: int = None, + external_data: dict = None, + ) -> object or int: + """Créer un nouveau justificatif pour l'étudiant""" + nouv_justificatif = Justificatif( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + raison=raison, + entry_date=entry_date, + user_id=user_id, + external_data=external_data, + ) + + db.session.add(nouv_justificatif) + + log(f"create_justificatif: {etud.id} {nouv_justificatif}") + Scolog.logdb( + method="create_justificatif", + etudid=etud.id, + msg=f"justificatif: {nouv_justificatif}", + ) + return nouv_justificatif + + +def is_period_conflicting( + date_debut: datetime, + date_fin: datetime, + collection: Query, + collection_cls: Assiduite or Justificatif, +) -> bool: + """ + Vérifie si une date n'entre pas en collision + avec les justificatifs ou assiduites déjà présentes + """ + + date_debut = localize_datetime(date_debut) + date_fin = localize_datetime(date_fin) + + if ( + collection.filter_by(date_debut=date_debut, date_fin=date_fin).first() + is not None + ): + return True + + count: int = collection.filter( + collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut + ).count() + + return count > 0 + + +def compute_assiduites_justified( + etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False +) -> list[int]: + """ + compute_assiduites_justified_faster + + Args: + etudid (int): l'identifiant de l'étudiant + justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés + reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False. + + Returns: + list[int]: la liste des assiduités qui ont été justifiées. + """ + if justificatifs is None: + justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid).all() + + assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid) + + assiduites_justifiees: list[int] = [] + + for assi in assiduites: + if any( + assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin + for j in justificatifs + ): + assi.est_just = True + assiduites_justifiees.append(assi.assiduite_id) + db.session.add(assi) + elif reset: + assi.est_just = False + db.session.add(assi) + db.session.commit() + return assiduites_justifiees + + +def get_assiduites_justif(assiduite_id: int, long: bool): + assi: Assiduite = Assiduite.query.get_or_404(assiduite_id) + return _get_assiduites_justif(assi.etudid, assi.date_debut, assi.date_fin, long) + + +def _get_assiduites_justif( + etudid: int, date_debut: datetime, date_fin: datetime, long: bool = False +): + justifs: Justificatif = Justificatif.query.filter( + Justificatif.etudid == etudid, + Justificatif.date_debut <= date_debut, + Justificatif.date_fin >= date_fin, + ) + + return [j.justif_id if not long else j.to_dict(True) for j in justifs] diff --git a/app/models/config.py b/app/models/config.py index 5212ff38a..c436248fc 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3,11 +3,17 @@ """Model : site config WORK IN PROGRESS #WIP """ +import json +import urllib.parse + from flask import flash from app import current_app, db, log from app.comp import bonus_spo +from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_utils as scu +from datetime import time + from app.scodoc.codes_cursus import ( ABAN, ABL, @@ -96,6 +102,10 @@ class ScoDocSiteConfig(db.Model): "cas_logout_route": str, "cas_validate_route": str, "cas_attribute_id": str, + # Assiduités + "morning_time": str, + "lunch_time": str, + "afternoon_time": str, } def __init__(self, name, value): @@ -247,7 +257,7 @@ class ScoDocSiteConfig(db.Model): cfg = ScoDocSiteConfig.query.filter_by(name=name).first() if cfg is None: return default - return cfg.value or "" + return cls.NAMES.get(name, lambda x: x)(cfg.value or "") @classmethod def set(cls, name: str, value: str) -> bool: @@ -336,3 +346,47 @@ class ScoDocSiteConfig(db.Model): log(f"set_month_debut_periode2({month})") return True return False + + @classmethod + def get_perso_links(cls) -> list["PersonalizedLink"]: + "Return links" + data_links = cls.get("personalized_links") + if not data_links: + return [] + try: + links_dict = json.loads(data_links) + except json.decoder.JSONDecodeError as exc: + # Corrupted data ? erase content + cls.set("personalized_links", "") + raise ScoValueError( + "Attention: liens personnalisés erronés: ils ont été effacés." + ) + return [PersonalizedLink(**item) for item in links_dict] + + @classmethod + def set_perso_links(cls, links: list["PersonalizedLink"] = None): + "Store all links" + if not links: + links = [] + links_dict = [link.to_dict() for link in links] + data_links = json.dumps(links_dict) + cls.set("personalized_links", data_links) + + +class PersonalizedLink: + def __init__(self, title: str = "", url: str = "", with_args: bool = False): + self.title = str(title or "") + self.url = str(url or "") + self.with_args = bool(with_args) + + def get_url(self, params: dict = {}) -> str: + if not self.with_args: + return self.url + query_string = urllib.parse.urlencode(params) + if "?" in self.url: + return self.url + "&" + query_string + return self.url + "?" + query_string + + def to_dict(self) -> dict: + "as dict" + return {"title": self.title, "url": self.url, "with_args": self.with_args} diff --git a/app/models/etudiants.py b/app/models/etudiants.py index d67a84828..5c4ea31e7 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -73,6 +73,12 @@ class Identite(db.Model): passive_deletes=True, ) + # Relations avec les assiduites et les justificatifs + assiduites = db.relationship("Assiduite", back_populates="etudiant", lazy="dynamic") + justificatifs = db.relationship( + "Justificatif", back_populates="etudiant", lazy="dynamic" + ) + def __repr__(self): return ( f"" diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 987b51706..235b7f74d 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 b5e21b252..677bfb178 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 @@ -39,9 +40,11 @@ from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus, sco_preferences from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_utils import MONTH_NAMES_ABBREV +from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric from app.scodoc.sco_vdi import ApoEtapeVDI +from app.scodoc.sco_utils import translate_assiduites_metric + GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes @@ -348,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) @@ -712,10 +730,14 @@ class FormSemestre(db.Model): tuple (nb abs, nb abs justifiées) Utilise un cache. """ - from app.scodoc import sco_abs + from app.scodoc import sco_assiduites - return sco_abs.get_abs_count_in_interval( - etudid, self.date_debut.isoformat(), self.date_fin.isoformat() + metrique = sco_preferences.get_preference("assi_metrique", self.id) + return sco_assiduites.get_assiduites_count_in_interval( + etudid, + self.date_debut.isoformat(), + self.date_fin.isoformat(), + translate_assiduites_metric(metrique), ) def get_codes_apogee(self, category=None) -> set[str]: @@ -812,11 +834,15 @@ class FormSemestre(db.Model): db.session.commit() - def update_inscriptions_parcours_from_groups(self) -> None: + def update_inscriptions_parcours_from_groups(self, etudid: int = None) -> None: """Met à jour les inscriptions dans les parcours du semestres en fonction des groupes de parcours. + Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS et leur nom est le code du parcours (eg "Cyber"). + + Si etudid est sépcifié, n'affecte que cet étudiant, + sinon traite tous les inscrits du semestre. """ if self.formation.referentiel_competence_id is None: return # safety net @@ -827,17 +853,32 @@ class FormSemestre(db.Model): return # Efface les inscriptions aux parcours: - db.session.execute( - text( - """UPDATE notes_formsemestre_inscription - SET parcour_id=NULL - WHERE formsemestre_id=:formsemestre_id - """ - ), - { - "formsemestre_id": self.id, - }, - ) + if etudid: + db.session.execute( + text( + """UPDATE notes_formsemestre_inscription + SET parcour_id=NULL + WHERE formsemestre_id=:formsemestre_id + AND etudid=:etudid + """ + ), + { + "formsemestre_id": self.id, + "etudid": etudid, + }, + ) + else: + db.session.execute( + text( + """UPDATE notes_formsemestre_inscription + SET parcour_id=NULL + WHERE formsemestre_id=:formsemestre_id + """ + ), + { + "formsemestre_id": self.id, + }, + ) # Inscrit les étudiants des groupes de parcours: for group in partition.groups: query = ( @@ -855,22 +896,42 @@ class FormSemestre(db.Model): ) continue parcour = query.first() - db.session.execute( - text( - """UPDATE notes_formsemestre_inscription ins - SET parcour_id=:parcour_id - FROM group_membership gm - WHERE formsemestre_id=:formsemestre_id - AND gm.etudid = ins.etudid - AND gm.group_id = :group_id - """ - ), - { - "formsemestre_id": self.id, - "parcour_id": parcour.id, - "group_id": group.id, - }, - ) + if etudid: + db.session.execute( + text( + """UPDATE notes_formsemestre_inscription ins + SET parcour_id=:parcour_id + FROM group_membership gm + WHERE formsemestre_id=:formsemestre_id + AND ins.etudid = :etudid + AND gm.etudid = :etudid + AND gm.group_id = :group_id + """ + ), + { + "etudid": etudid, + "formsemestre_id": self.id, + "parcour_id": parcour.id, + "group_id": group.id, + }, + ) + else: + db.session.execute( + text( + """UPDATE notes_formsemestre_inscription ins + SET parcour_id=:parcour_id + FROM group_membership gm + WHERE formsemestre_id=:formsemestre_id + AND gm.etudid = ins.etudid + AND gm.group_id = :group_id + """ + ), + { + "formsemestre_id": self.id, + "parcour_id": parcour.id, + "group_id": group.id, + }, + ) db.session.commit() def etud_validations_description_html(self, etudid: int) -> str: diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 8a7dcb017..e84fe82b3 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. @@ -122,6 +139,22 @@ class ModuleImpl(db.Model): raise AccessDenied(f"Modification impossible pour {user}") return False + def est_inscrit(self, etud: Identite) -> bool: + """ + Vérifie si l'étudiant est bien inscrit au moduleimpl + + Retourne Vrai si c'est le cas, faux sinon + """ + + is_module: int = ( + ModuleImplInscription.query.filter_by( + etudid=etud.id, moduleimpl_id=self.id + ).count() + > 0 + ) + + return is_module + # Enseignants (chargés de TD ou TP) d'un moduleimpl notes_modules_enseignants = db.Table( diff --git a/app/models/modules.py b/app/models/modules.py index 6aaaef198..9fafe5cb3 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 bb5f386f1..8c2a40b76 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/profiler.py b/app/profiler.py new file mode 100644 index 000000000..0e61d3856 --- /dev/null +++ b/app/profiler.py @@ -0,0 +1,43 @@ +from time import time +from datetime import datetime + + +class Profiler: + OUTPUT: str = "/tmp/scodoc.profiler.csv" + + def __init__(self, tag: str) -> None: + self.tag: str = tag + self.start_time: time = None + self.stop_time: time = None + + def start(self): + self.start_time = time() + return self + + def stop(self): + self.stop_time = time() + return self + + def elapsed(self) -> float: + return self.stop_time - self.start_time + + def dates(self) -> tuple[datetime, datetime]: + return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp( + self.stop_time + ) + + def write(self): + with open(Profiler.OUTPUT, "a") as file: + dates: tuple = self.dates() + date_str = (dates[0].isoformat(), dates[1].isoformat()) + file.write(f"\n{self.tag},{self.elapsed() : .2}") + + @classmethod + def write_in(cls, msg: str): + with open(cls.OUTPUT, "a") as file: + file.write(f"\n# {msg}") + + @classmethod + def clear(cls): + with open(cls.OUTPUT, "w") as file: + file.write("") diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 76e8727ab..fd6273709 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -30,7 +30,7 @@ import html -from flask import render_template +from flask import g, render_template from flask import request from flask_login import current_user @@ -148,6 +148,8 @@ def sco_header( "Main HTML page header for ScoDoc" from app.scodoc.sco_formsemestre_status import formsemestre_page_title + if etudid is not None: + g.current_etudid = etudid scodoc_flash_status_messages() # Get head message from http request: diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py old mode 100644 new mode 100755 index 33132a056..6e483df3e --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -54,9 +54,12 @@ def sidebar_common():

Scolarité

Semestres
Programmes
- Absences
""" ] + if current_user.has_permission(Permission.ScoAbsChange): + H.append( + f""" Assiduités
""" + ) if current_user.has_permission( Permission.ScoUsersAdmin ) or current_user.has_permission(Permission.ScoUsersView): @@ -76,7 +79,7 @@ def sidebar_common(): def sidebar(etudid: int = None): "Main HTML page sidebar" # rewritten from legacy DTML code - from app.scodoc import sco_abs + from app.scodoc import sco_assiduites from app.scodoc import sco_etud params = {} @@ -116,19 +119,18 @@ def sidebar(etudid: int = None): ) if etud["cursem"]: cur_sem = etud["cursem"] - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, cur_sem) + nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, cur_sem) nbabsnj = nbabs - nbabsjust H.append( - f"""(1/2 j.) + f"""({sco_preferences.get_preference("assi_metrique", None)})
{ nbabsjust } J., { nbabsnj } N.J.
""" ) H.append(" """ ) @@ -149,8 +152,10 @@ def sidebar(etudid: int = None): # Logo H.append( f"""
- diff --git a/app/templates/scolar/partition_editor.j2 b/app/templates/scolar/partition_editor.j2 index b9a95eb1f..bdd900d92 100644 --- a/app/templates/scolar/partition_editor.j2 +++ b/app/templates/scolar/partition_editor.j2 @@ -47,9 +47,13 @@ vers le groupe
Valider
-
+ +
+
+
+
- +
@@ -430,6 +434,8 @@ /****************************/ /* Affectation à un groupe */ /****************************/ + var progressNb = 0; + var progressRef = 0; function affectationGo() { let from = document.querySelector("#affectationFrom").value; let to = document.querySelector("#affectationTo").value; @@ -450,7 +456,13 @@ }) } - console.log(elements); + let progress = document.querySelector("#zoneChoix .autoAffectation .progress"); + if (elements.length > 1) { + progress.style.setProperty('--reference', elements.length); + progress.style.setProperty('--nombre', 0); + progressRef = elements.length; + progressNb = 0; + } elements.forEach(groupeSelected => { if (to[0] != "n") { @@ -502,6 +514,13 @@ this.classList.remove("saving"); this.classList.add("saved"); setTimeout(() => { this.classList.remove("saved") }, 800); + + let progress = document.querySelector("#zoneChoix .autoAffectation .progress"); + progress.style.setProperty('--nombre', ++progressNb); + + if (progressNb == progressRef) { + sco_message("Tous les étudiants sont affectés"); + } return; } throw 'Les données retournées ne sont pas valides'; diff --git a/app/templates/scolar/photos_import_files.j2 b/app/templates/scolar/photos_import_files.j2 index a3c0c942b..2961d7d73 100644 --- a/app/templates/scolar/photos_import_files.j2 +++ b/app/templates/scolar/photos_import_files.j2 @@ -28,7 +28,7 @@

Fichiers chargés:

    {% for (etud, name) in stored_etud_filename %} -
  • {{etud["nomprenom"]}}: {{name}}
  • +
  • {{etud.nomprenom}}: {{name}}
  • {% endfor %}
{% endif %} diff --git a/app/templates/scolar/photos_import_files.txt b/app/templates/scolar/photos_import_files.txt index cb6777b5c..c47e271fc 100755 --- a/app/templates/scolar/photos_import_files.txt +++ b/app/templates/scolar/photos_import_files.txt @@ -18,6 +18,6 @@ Importation des photo effectuée {% if stored_etud_filename %} # Fichiers chargés: {% for (etud, name) in stored_etud_filename %} - - {{etud["nomprenom"]}}: {{name}} + - {{etud.nomprenom}}: {{name}} {% endfor %} {% endif %} diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 old mode 100644 new mode 100755 index ba8513774..762b73be1 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -24,8 +24,10 @@

Scolarité

Semestres
Programmes
- Absences
+ {% if current_user.has_permission(sco.Permission.ScoAbsChange)%} + Assiduités
+ {% endif %} {% if current_user.has_permission(sco.Permission.ScoUsersAdmin) or current_user.has_permission(sco.Permission.ScoUsersView) %} @@ -55,26 +57,26 @@ Absences {% if sco.etud_cur_sem %} (1/2 j.) + au {{ sco.etud_cur_sem['date_fin'] }}">({{sco.prefs["assi_metrique"]}})
{{sco.nbabsjust}} J., {{sco.nbabsnj}} N.J.
{% endif %} {% endif %} {# /etud-insidebar #} @@ -84,7 +86,8 @@