diff --git a/app/__init__.py b/app/__init__.py index 51e122cd5..72cc1cb72 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,6 +26,7 @@ from flask_mail import Mail from flask_bootstrap import Bootstrap from flask_moment import Moment from flask_caching import Cache +from jinja2 import select_autoescape import sqlalchemy from app.scodoc.sco_exceptions import ( @@ -61,11 +62,11 @@ cache = Cache( def handle_sco_value_error(exc): - return render_template("sco_value_error.html", exc=exc), 404 + return render_template("sco_value_error.j2", exc=exc), 404 def handle_access_denied(exc): - return render_template("error_access_denied.html", exc=exc), 403 + return render_template("error_access_denied.j2", exc=exc), 403 def internal_server_error(exc): @@ -75,7 +76,7 @@ def internal_server_error(exc): return ( render_template( - "error_500.html", + "error_500.j2", SCOVERSION=sco_version.SCOVERSION, date=datetime.datetime.now().isoformat(), exc=exc, @@ -146,7 +147,7 @@ def render_raw_html(template_filename: str, **args) -> str: def postgresql_server_error(e): """Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)""" - return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503 + return render_raw_html("error_503.j2", SCOVERSION=sco_version.SCOVERSION), 503 class LogRequestFormatter(logging.Formatter): @@ -275,6 +276,9 @@ def create_app(config_class=DevConfig): from app.api import api_bp from app.api import api_web_bp + # Enable autoescaping of all templates, including .j2 + app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True) + # https://scodoc.fr/ScoDoc app.register_blueprint(scodoc_bp) # https://scodoc.fr/ScoDoc/RT/Scolarite/... diff --git a/app/api/__init__.py b/app/api/__init__.py index 55c708a7a..7c544d631 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -42,6 +42,7 @@ from app.api import ( formations, formsemestres, jury, + justificatif, logos, partitions, users, diff --git a/app/api/assiduites.py b/app/api/assiduites.py index b479ab695..f067641fe 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -6,23 +6,19 @@ """ScoDoc 9 API : Assiduités """ from datetime import datetime - -from typing import List from flask import g, jsonify, request - -from app import db - -from app.api import api_bp as bp, api_web_bp -from app.scodoc.sco_utils import json_error -from app.decorators import scodoc, permission_required -from app.scodoc.sco_permissions import Permission from flask_login import login_required - -from app.models import Identite, Assiduite, FormSemestre, ModuleImpl -from app.scodoc.sco_exceptions import ScoValueError -import app.scodoc.sco_utils as scu 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.decorators import permission_required, scodoc +from app.models import Assiduite, FormSemestre, Identite, ModuleImpl +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/") @@ -47,12 +43,11 @@ def assiduite(assiduite_id: int = None): query = Assiduite.query.filter_by(id=assiduite_id) # if g.scodoc_dept: # query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) + assiduite_query = query.first_or_404() - assiduite = query.first_or_404() + data = assiduite_query.to_dict() - data = assiduite.to_dict() - - return jsonify(change_etat(data)) + return jsonify(_change_etat(data)) @bp.route("/assiduites//count", defaults={"with_query": False}) @@ -104,15 +99,15 @@ def count_assiduites(etudid: int = None, with_query: bool = False): query = query.filter_by(dept_id=g.scodoc_dept_id) etud: Identite = query.first_or_404(etudid) - filter: dict[str, object] = {} + filtered: dict[str, object] = {} metric: str = "all" if with_query: - metric, filter = count_manager(request) + metric, filtered = _count_manager(request) return jsonify( scass.get_assiduites_stats( - assiduites=etud.assiduites, metric=metric, filter=filter + assiduites=etud.assiduites, metric=metric, filtered=filtered ) ) @@ -162,15 +157,15 @@ def assiduites(etudid: int = None, with_query: bool = False): query = query.filter_by(dept_id=g.scodoc_dept_id) etud: Identite = query.first_or_404(etudid) - assiduites = etud.assiduites + assiduites_query = etud.assiduites if with_query: - assiduites = filter_manager(request, assiduites) + assiduites_query = _filter_manager(request, assiduites_query) - data_set: List[dict] = [] - for ass in assiduites.all(): + data_set: list[dict] = [] + for ass in assiduites_query.all(): data = ass.to_dict() - data_set.append(change_etat(data)) + data_set.append(_change_etat(data)) return jsonify(data_set) @@ -200,19 +195,15 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): if formsemestre is None: return json_error(404, "le paramètre 'formsemestre_id' n'existe pas") - etuds = formsemestre.etuds.all() - etuds_id = [etud.id for etud in etuds] - - assiduites = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id)) - assiduites = scass.filter_by_formsemstre(assiduites, formsemestre) + assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre) if with_query: - assiduites = filter_manager(request, assiduites) + assiduites_query = _filter_manager(request, assiduites_query) - data_set: List[dict] = [] - for ass in assiduites.all(): + data_set: list[dict] = [] + for ass in assiduites_query.all(): data = ass.to_dict() - data_set.append(change_etat(data)) + data_set.append(_change_etat(data)) return jsonify(data_set) @@ -249,14 +240,14 @@ def count_assiduites_formsemestre( etuds = formsemestre.etuds.all() etuds_id = [etud.id for etud in etuds] - assiduites = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id)) - assiduites = scass.filter_by_formsemstre(assiduites, formsemestre) + assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id)) + assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre) metric: str = "all" - filter: dict = {} + filtered: dict = {} if with_query: - metric, filter = count_manager(request) + metric, filtered = _count_manager(request) - return jsonify(scass.get_assiduites_stats(assiduites, metric, filter)) + return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered)) @bp.route("/assiduite//create", methods=["POST"]) @@ -265,31 +256,38 @@ def count_assiduites_formsemestre( @login_required @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def create(etudid: int = None): +def assiduite_create(etudid: int = 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, - } - ou - { - "date_debut": str, - "date_fin": str, - "etat": str, - "moduleimpl_id": int, - } - + [ + { + "date_debut": str, + "date_fin": str, + "etat": str, + }, + { + "date_debut": str, + "date_fin": str, + "etat": str, + "moduleimpl_id": int, + "desc":str, + } + ... + ] """ etud: Identite = Identite.query.filter_by(id=etudid).first_or_404() + 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: dict[int, str] = {} success: dict[int, object] = {} - for i, data in enumerate(request.get_json(force=True)): - code, obj = create_singular(data, etud) + for i, data in enumerate(create_list): + code, obj = _create_singular(data, etud) if code == 404: errors[i] = obj else: @@ -298,21 +296,21 @@ def create(etudid: int = None): return jsonify({"errors": errors, "success": success}) -def create_singular( +def _create_singular( data: dict, etud: Identite, ) -> tuple[int, object]: - errors: List[str] = [] + 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 etat not in scu.ETATS_ASSIDUITE.keys(): + elif etat not in scu.ETATS_ASSIDUITE: errors.append("param 'etat': invalide") - data = change_etat(data, False) + data = _change_etat(data, False) etat = data.get("etat", None) # cas 2 : date_debut @@ -329,7 +327,7 @@ def create_singular( errors.append("param 'date_fin': manquant") fin = scu.is_iso_formated(date_fin, convert=True) if fin is None: - errors.append(f"param 'date_fin': format invalide") + errors.append("param 'date_fin': format invalide") # cas 4 : moduleimpl_id @@ -343,9 +341,9 @@ def create_singular( # cas 5 : desc - desc:str = data.get("desc", None) + desc: str = data.get("desc", None) - if errors != []: + if errors: err: str = ", ".join(errors) return (404, err) @@ -358,6 +356,7 @@ def create_singular( etat=etat, etud=etud, moduleimpl=moduleimpl, + description=desc, ) db.session.add(nouv_assiduite) @@ -376,15 +375,27 @@ def create_singular( @scodoc @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def delete(): +def assiduite_cdelete(): """ Suppression d'une assiduité à partir de son id + + Forme des données envoyées : + + [ + , + ... + ] + + """ - assiduites: list[int] = request.get_json(force=True) + 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): - code, msg = delete_singular(ass, db) + for i, ass in enumerate(assiduites_list): + code, msg = _delete_singular(ass, db) if code == 404: output["errors"][f"{i}"] = msg else: @@ -393,11 +404,11 @@ def delete(): return jsonify(output) -def delete_singular(assiduite_id: int, db): - assiduite: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first() - if assiduite is None: +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") - db.session.delete(assiduite) + database.session.delete(assiduite_unique) return (200, "OK") @@ -407,28 +418,31 @@ def delete_singular(assiduite_id: int, db): @scodoc @permission_required(Permission.ScoView) # @permission_required(Permission.ScoAssiduiteChange) -def edit(assiduite_id: int): +def assiduite_cedit(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 + "etat"?: str, + "moduleimpl_id"?: int + "desc"?: str } """ - assiduite: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first_or_404() - errors: List[str] = [] + 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: - data = change_etat(data, False) + data = _change_etat(data, False) if data.get("etat") is None: errors.append("param 'etat': invalide") else: - assiduite.etat = data.get("etat") + assiduite_unique.etat = data.get("etat") # Cas 2 : Moduleimpl_id moduleimpl_id = data.get("moduleimpl_id", False) @@ -441,24 +455,30 @@ def edit(assiduite_id: int): errors.append("param 'moduleimpl_id': invalide") else: if not moduleimpl.est_inscrit( - Identite.query.filter_by(id=assiduite.etudid).first() + Identite.query.filter_by(id=assiduite_unique.etudid).first() ): errors.append("param 'moduleimpl_id': etud non inscrit") else: - assiduite.moduleimpl_id = moduleimpl_id + assiduite_unique.moduleimpl_id = moduleimpl_id else: - assiduite.moduleimpl_id = moduleimpl_id - if errors != []: + assiduite_unique.moduleimpl_id = moduleimpl_id + + # Cas 3 : desc + desc = data.get("desc", False) + if desc is not False: + assiduite_unique.desc = desc + + if errors: err: str = ", ".join(errors) return json_error(404, err) - db.session.add(assiduite) + db.session.add(assiduite_unique) db.session.commit() return jsonify({"OK": True}) # -- Utils -- -def change_etat(data: dict, from_int: bool = True): +def _change_etat(data: dict, from_int: bool = True): """change dans un json la valeur du champs état""" if from_int: data["etat"] = scu.ETAT_ASSIDUITE_NAME.get(data["etat"]) @@ -467,104 +487,108 @@ def change_etat(data: dict, from_int: bool = True): return data -def count_manager(request) -> tuple[str, dict]: +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 """ - filter: dict = {} + filtered: dict = {} # cas 1 : etat assiduite - etat = request.args.get("etat") + etat = requested.args.get("etat") if etat is not None: - filter["etat"] = etat + filtered["etat"] = etat # cas 2 : date de début - deb = request.args.get("date_debut") + deb = requested.args.get("date_debut") deb: datetime = scu.is_iso_formated(deb, True) if deb is not None: - filter["date_debut"] = deb + filtered["date_debut"] = deb # cas 3 : date de fin - fin = request.args.get("date_fin") + fin = requested.args.get("date_fin") fin = scu.is_iso_formated(fin, True) if fin is not None: - filter["date_fin"] = fin + filtered["date_fin"] = fin # cas 4 : moduleimpl_id - module = request.args.get("moduleimpl_id", False) + module = requested.args.get("moduleimpl_id", False) try: if module is False: - raise Exception + raise ValueError if module != "": module = int(module) else: module = None - except Exception: + except ValueError: module = False if module is not False: - filter["moduleimpl_id"] = module + filtered["moduleimpl_id"] = module # cas 5 : formsemestre_id - formsemestre_id = request.args.get("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() - filter["formsemestre"] = formsemestre + filtered["formsemestre"] = formsemestre # cas 6 : type - metric = request.args.get("metric", "all") + metric = requested.args.get("metric", "all") - return (metric, filter) + return (metric, filtered) -def filter_manager(request, assiduites): +def _filter_manager(requested, assiduites_query): """ Retourne les assiduites entrées filtrées en fonction de la request """ # cas 1 : etat assiduite - etat = request.args.get("etat") + etat = requested.args.get("etat") if etat is not None: - assiduites = scass.filter_by_etat(assiduites, etat) + assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat) # cas 2 : date de début - deb = request.args.get("date_debut") + deb = requested.args.get("date_debut") deb: datetime = scu.is_iso_formated(deb, True) if deb is not None: - assiduites = scass.filter_by_date(assiduites, deb, sup=True) + assiduites_query = scass.filter_assiduites_by_date( + assiduites_query, deb, sup=True + ) # cas 3 : date de fin - fin = request.args.get("date_fin") + fin = requested.args.get("date_fin") fin = scu.is_iso_formated(fin, True) if fin is not None: - assiduites = scass.filter_by_date(assiduites, fin, sup=False) + assiduites_query = scass.filter_assiduites_by_date( + assiduites_query, fin, sup=False + ) # cas 4 : moduleimpl_id - module = request.args.get("moduleimpl_id", False) + module = requested.args.get("moduleimpl_id", False) try: if module is False: - raise Exception + raise ValueError if module != "": module = int(module) else: module = None - except Exception: + except ValueError: module = False if module is not False: - assiduites = scass.filter_by_module_impl(assiduites, module) + assiduites_query = scass.filter_by_module_impl(assiduites_query, module) # cas 5 : formsemestre_id - formsemestre_id = request.args.get("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 = scass.filter_by_formsemstre(assiduites, formsemestre) + assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre) - return assiduites + return assiduites_query diff --git a/app/api/justificatif.py b/app/api/justificatif.py new file mode 100644 index 000000000..c51b0459a --- /dev/null +++ b/app/api/justificatif.py @@ -0,0 +1,536 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 API : Assiduités +""" +import os +from datetime import datetime + +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.scodoc.sco_exceptions import ScoValueError +from app.decorators import permission_required, scodoc +from app.models import Identite, Justificatif +from app.scodoc.sco_archives_justificatifs import JustificatifArchiver +from app.scodoc.sco_permissions import Permission +from flask import g, jsonify, request +from flask_login import login_required +from app.scodoc.sco_utils import json_error + + +# @bp.route("/justificatif/remove") +# @api_web_bp.route("/justificatif/remove") +# @scodoc +# def justremove(): +# """ """ +# archiver: JustificatifArchiver = JustificatifArchiver() + +# archiver.delete_justificatif(etudid=1, archive_id="2023-02-01-10-29-20") +# return jsonify("done") + +# Partie Modèle +# TODO: justificatif +@bp.route("/justificatif/") +@api_web_bp.route("/assiduite/") +@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", + } + """ + + 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 = query.first_or_404() + + data = justificatif_unique.to_dict() + + return jsonify(_change_etat(data)) + + +# TODO: justificatifs[-query] +@bp.route("/justificatifs/", defaults={"with_query": False}) +@bp.route("/justificatifs//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs//query", defaults={"with_query": True}) +@login_required +@scodoc +@permission_required(Permission.ScoView) +def justificatifs(etudid: int = 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 + """ + + 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) + 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() + data_set.append(_change_etat(data)) + + return jsonify(data_set) + + +# TODO: justificatif-create +@bp.route("/justificatif//create", methods=["POST"]) +@api_web_bp.route("/justificatif//create", methods=["POST"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_create(etudid: int = 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 = Identite.query.filter_by(id=etudid).first_or_404() + + 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: dict[int, str] = {} + success: dict[int, object] = {} + for i, data in enumerate(create_list): + code, obj = _create_singular(data, etud) + if code == 404: + errors[i] = obj + else: + success[i] = obj + + return jsonify({"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 etat not in scu.ETATS_JUSTIFICATIF: + errors.append("param 'etat': invalide") + + data = _change_etat(data, False) + etat = data.get("etat", None) + + # 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) + + if errors: + err: str = ", ".join(errors) + return (404, err) + + # TOUT EST OK + + try: + nouv_justificatif: Justificatif = Justificatif.create_justificatif( + date_debut=deb, + date_fin=fin, + etat=etat, + etud=etud, + raison=raison, + ) + + db.session.add(nouv_justificatif) + db.session.commit() + return (200, {"justif_id": nouv_justificatif.id}) + except ScoValueError as excp: + return ( + 404, + excp.args[0], + ) + + +# TODO: justificatif-edit +@bp.route("/justificatif//edit", methods=["POST"]) +@api_web_bp.route("/justificatif//edit", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +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 + } + """ + justificatif_unique: Justificatif = Justificatif.query.filter_by( + id=justif_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: + data = _change_etat(data, False) + if data.get("etat") is None: + errors.append("param 'etat': invalide") + else: + justificatif_unique.etat = data.get("etat") + + # Cas 2 : raison + raison = data.get("raison", False) + if raison is not False: + justificatif_unique.raison = raison + + if errors: + err: str = ", ".join(errors) + return json_error(404, err) + + db.session.add(justificatif_unique) + db.session.commit() + return jsonify({"OK": True}) + + +# TODO: justificatif-delete +@bp.route("/justificatif/delete", methods=["POST"]) +@api_web_bp.route("/justificatif/delete", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +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"][f"{i}"] = msg + else: + output["success"][f"{i}"] = {"OK": True} + db.session.commit() + return jsonify(output) + + +def _delete_singular(justif_id: int, database): + justificatif_unique: Justificatif = Justificatif.query.filter_by( + id=justif_id + ).first() + if justificatif_unique is None: + return (404, "Justificatif non existant") + database.session.delete(justificatif_unique) + return (200, "OK") + + +# Partie archivage +# TODO: justificatif-import +@bp.route("/justificatif/import/", methods=["POST"]) +@api_web_bp.route("/justificatif/import/", methods=["POST"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +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 = 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: + archive_name: str = archiver.save_justificatif( + etudid=justificatif_unique.etudid, + filename=file.filename, + data=file.stream.read(), + archive_name=archive_name, + ) + + justificatif_unique.fichier = archive_name + + db.session.add(justificatif_unique) + db.session.commit() + + return jsonify({"response": "imported"}) + except ScoValueError as err: + return json_error(404, err.args[1]) + + +# TODO: justificatif-export +@bp.route("/justificatif/export//", methods=["GET"]) +@api_web_bp.route("/justificatif/export//", methods=["GET"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_export(justif_id: int = None, filename: str = None): + """ + Retourne un fichier d'une archive d'un justificatif + """ + + query = Justificatif.query.filter_by(id=justif_id) + 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") + + 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[1]) + + +# TODO: justificatif-remove +@bp.route("/justificatif/remove/", methods=["POST"]) +@api_web_bp.route("/justificatif/remove/", methods=["POST"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_remove(justif_id: int = None): + """ + Supression d'un fichier ou d'une archive + + { + "remove": <"all"/"list"> + + "filenames"?: [ + , + ... + ] + } + """ + + data: dict = request.get_json(force=True) + + 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[1]) + + return jsonify({"response": "removed"}) + + +# TODO: justificatif-list +@bp.route("/justificatif/list/", methods=["GET"]) +@api_web_bp.route("/justificatif/list/", methods=["GET"]) +@scodoc +@login_required +@permission_required(Permission.ScoView) +# @permission_required(Permission.ScoAssiduiteChange) +def justif_list(justif_id: int = None): + """ + Liste les fichiers du justificatif + """ + + 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 + ) + + return jsonify(filenames) + + +# Partie justification +# TODO: justificatif-justified + + +# -- Utils -- +def _change_etat(data: dict, from_int: bool = True): + """change dans un json la valeur du champs état""" + if from_int: + data["etat"] = scu.ETAT_JUSTIFICATIF_NAME.get(data["etat"]) + else: + data["etat"] = scu.ETATS_JUSTIFICATIF.get(data["etat"]) + return data + + +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") + deb: datetime = scu.is_iso_formated(deb, True) + if deb is not None: + + justificatifs_query = scass.filter_justificatifs_by_date( + justificatifs_query, deb, sup=True + ) + + # cas 3 : date de fin + fin = requested.args.get("date_fin") + fin = scu.is_iso_formated(fin, True) + + if fin is not None: + justificatifs_query = scass.filter_justificatifs_by_date( + justificatifs_query, fin, sup=False + ) + + return justificatifs_query diff --git a/app/auth/email.py b/app/auth/email.py index 617596910..9ea8f23e0 100644 --- a/app/auth/email.py +++ b/app/auth/email.py @@ -11,5 +11,5 @@ def send_password_reset_email(user): sender=current_app.config["SCODOC_MAIL_FROM"], recipients=[user.email], text_body=render_template("email/reset_password.txt", user=user, token=token), - html_body=render_template("email/reset_password.html", user=user, token=token), + html_body=render_template("email/reset_password.j2", user=user, token=token), ) diff --git a/app/auth/routes.py b/app/auth/routes.py index 46d144d63..2c1594bc8 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -42,7 +42,7 @@ def login(): return form.redirect("scodoc.index") message = request.args.get("message", "") return render_template( - "auth/login.html", title=_("Sign In"), form=form, message=message + "auth/login.j2", title=_("Sign In"), form=form, message=message ) @@ -65,9 +65,7 @@ def create_user(): db.session.commit() flash(f"Utilisateur {user.user_name} créé") return redirect(url_for("scodoc.index")) - return render_template( - "auth/register.html", title="Création utilisateur", form=form - ) + return render_template("auth/register.j2", title="Création utilisateur", form=form) @bp.route("/reset_password_request", methods=["GET", "POST"]) @@ -98,7 +96,7 @@ def reset_password_request(): ) return redirect(url_for("auth.login")) return render_template( - "auth/reset_password_request.html", title=_("Reset Password"), form=form + "auth/reset_password_request.j2", title=_("Reset Password"), form=form ) @@ -116,7 +114,7 @@ def reset_password(token): db.session.commit() flash(_("Votre mot de passe a été changé.")) return redirect(url_for("auth.login")) - return render_template("auth/reset_password.html", form=form, user=user) + return render_template("auth/reset_password.j2", form=form, user=user) @bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"]) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 7ab989720..273d14517 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -841,6 +841,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): et autorisations d'inscription émises. Efface même si étudiant DEM ou DEF. Si à cheval, n'efface que pour le semestre d'origine du deca. + (commite la session.) """ if only_one_sem or self.a_cheval: # N'efface que les autorisations venant de ce semestre, diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index a58f41e2c..159344744 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -500,7 +500,7 @@ def jury_but_semestriel( H.append("") H.append( render_template( - "but/documentation_codes_jury.html", + "but/documentation_codes_jury.j2", nom_univ=f"""Export {sco_preferences.get_preference("InstituteName") or sco_preferences.get_preference("UnivName") or "Apogée"}""", diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index da9f8acf3..6374e0071 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -1358,6 +1358,44 @@ class BonusIUTvannes(BonusSportAdditif): classic_use_bonus_ues = False # seulement sur moy gen. +class BonusValenciennes(BonusDirect): + """Article 7 des RCC de l’IUT de Valenciennes + +

+ Une bonification maximale de 0.25 point (1/4 de point) peut être ajoutée + à la moyenne de chaque Unité d’Enseignement pour : +

+
    +
  • l'engagement citoyen ;
  • +
  • la participation à un module de sport.
  • +
+ +

+ Une bonification accordée par la commission des sports de l’UPHF peut être attribuée + aux sportifs de haut niveau. Cette bonification est appliquée à l’ensemble des + Unités d’Enseignement. Ce bonus est : +

+
    +
  • 0.5 pour la catégorie or (sportif inscrit sur liste ministérielle + jeunesse et sport) ; +
  • +
  • 0.45 pour la catégorie argent (sportif en club professionnel) ; +
  • +
  • 0.40 pour le bronze (sportif de niveau départemental, régional ou national). +
  • +
+

Le cumul de bonifications est possible mais ne peut excéder 0.5 point (un demi-point). +

+

Dans ScoDoc, saisir directement la valeur désirée du bonus + dans une évaluation notée sur 20. +

+ """ + + name = "bonus_valenciennes" + displayed_name = "IUT de Valenciennes" + bonus_max = 0.5 + + class BonusVilleAvray(BonusSportAdditif): """Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray. diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index c533c21aa..8c1d68dab 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -33,7 +33,10 @@ import pandas as pd from app import db from app import models from app.models import ( + DispenseUE, FormSemestre, + FormSemestreInscription, + Identite, Module, ModuleImpl, ModuleUECoef, @@ -215,6 +218,31 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: ) +def load_dispense_ues( + formsemestre: FormSemestre, etudids: pd.Index, ues: list[UniteEns] +) -> set[tuple[int, int]]: + """Construit l'ensemble des + etudids = modimpl_inscr_df.index, # les etudids + ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport + + Résultat: set de (etudid, ue_id). + """ + dispense_ues = set() + ue_sem_by_code = {ue.ue_code: ue for ue in ues} + # Prend toutes les dispenses obtenues par des étudiants de ce formsemestre, + # puis filtre sur inscrits et code d'UE UE + for dispense_ue in DispenseUE.query.join( + Identite, FormSemestreInscription + ).filter_by(formsemestre_id=formsemestre.id): + if dispense_ue.etudid in etudids: + # UE dans le semestre avec même code ? + ue = ue_sem_by_code.get(dispense_ue.ue.ue_code) + if ue is not None: + dispense_ues.add((dispense_ue.etudid, ue.id)) + + return dispense_ues + + def compute_ue_moys_apc( sem_cube: np.array, etuds: list, diff --git a/app/comp/res_but.py b/app/comp/res_but.py index f3f0c97db..cf7f41018 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -72,7 +72,7 @@ class ResultatsSemestreBUT(NotesTableCompat): modimpl.module.ue.type != UE_SPORT for modimpl in self.formsemestre.modimpls_sorted ] - self.dispense_ues = DispenseUE.load_formsemestre_dispense_ues_set( + self.dispense_ues = moy_ue.load_dispense_ues( self.formsemestre, self.modimpl_inscr_df.index, self.ues ) self.etud_moy_ue = moy_ue.compute_ue_moys_apc( diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index 2d4195004..e50ebf713 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -89,7 +89,7 @@ def index(): visible=True, association=True, siret_provisoire=True ) return render_template( - "entreprises/entreprises.html", + "entreprises/entreprises.j2", title="Entreprises", entreprises=entreprises, logs=logs, @@ -109,7 +109,7 @@ def logs(): EntrepriseHistorique.date.desc() ).paginate(page=page, per_page=20) return render_template( - "entreprises/logs.html", + "entreprises/logs.j2", title="Logs", logs=logs, ) @@ -134,7 +134,7 @@ def correspondants(): .all() ) return render_template( - "entreprises/correspondants.html", + "entreprises/correspondants.j2", title="Correspondants", correspondants=correspondants, logs=logs, @@ -149,7 +149,7 @@ def validation(): """ entreprises = Entreprise.query.filter_by(visible=False).all() return render_template( - "entreprises/entreprises_validation.html", + "entreprises/entreprises_validation.j2", title="Validation entreprises", entreprises=entreprises, ) @@ -167,7 +167,7 @@ def fiche_entreprise_validation(entreprise_id): description=f"fiche entreprise (validation) {entreprise_id} inconnue" ) return render_template( - "entreprises/fiche_entreprise_validation.html", + "entreprises/fiche_entreprise_validation.j2", title="Validation fiche entreprise", entreprise=entreprise, ) @@ -205,7 +205,7 @@ def validate_entreprise(entreprise_id): flash("L'entreprise a été validé et ajouté à la liste.") return redirect(url_for("entreprises.validation")) return render_template( - "entreprises/form_validate_confirmation.html", + "entreprises/form_validate_confirmation.j2", title="Validation entreprise", form=form, ) @@ -242,7 +242,7 @@ def delete_validation_entreprise(entreprise_id): flash("L'entreprise a été supprimé de la liste des entreprise à valider.") return redirect(url_for("entreprises.validation")) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supression entreprise", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -282,7 +282,7 @@ def offres_recues(): files.append(file) offres_recues_with_files.append([envoi_offre, offre, files, correspondant]) return render_template( - "entreprises/offres_recues.html", + "entreprises/offres_recues.j2", title="Offres reçues", offres_recues=offres_recues_with_files, ) @@ -321,7 +321,7 @@ def preferences(): form.mail_entreprise.data = EntreprisePreferences.get_email_notifications() form.check_siret.data = int(EntreprisePreferences.get_check_siret()) return render_template( - "entreprises/preferences.html", + "entreprises/preferences.j2", title="Préférences", form=form, ) @@ -357,7 +357,7 @@ def add_entreprise(): db.session.rollback() flash("Une erreur est survenue veuillez réessayer.") return render_template( - "entreprises/form_ajout_entreprise.html", + "entreprises/form_ajout_entreprise.j2", title="Ajout entreprise avec correspondant", form=form, ) @@ -408,7 +408,7 @@ def add_entreprise(): flash("L'entreprise a été ajouté à la liste pour la validation.") return redirect(url_for("entreprises.index")) return render_template( - "entreprises/form_ajout_entreprise.html", + "entreprises/form_ajout_entreprise.j2", title="Ajout entreprise avec correspondant", form=form, ) @@ -446,7 +446,7 @@ def fiche_entreprise(entreprise_id): .all() ) return render_template( - "entreprises/fiche_entreprise.html", + "entreprises/fiche_entreprise.j2", title="Fiche entreprise", entreprise=entreprise, offres=offres_with_files, @@ -472,7 +472,7 @@ def logs_entreprise(entreprise_id): .paginate(page=page, per_page=20) ) return render_template( - "entreprises/logs_entreprise.html", + "entreprises/logs_entreprise.j2", title="Logs", logs=logs, entreprise=entreprise, @@ -490,7 +490,7 @@ def offres_expirees(entreprise_id): ).first_or_404(description=f"fiche entreprise {entreprise_id} inconnue") offres_with_files = are.get_offres_expirees_with_files(entreprise.offres) return render_template( - "entreprises/offres_expirees.html", + "entreprises/offres_expirees.j2", title="Offres expirées", entreprise=entreprise, offres_expirees=offres_with_files, @@ -574,7 +574,7 @@ def edit_entreprise(entreprise_id): form.pays.data = entreprise.pays form.association.data = entreprise.association return render_template( - "entreprises/form_modification_entreprise.html", + "entreprises/form_modification_entreprise.j2", title="Modification entreprise", form=form, ) @@ -610,7 +610,7 @@ def fiche_entreprise_desactiver(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Désactiver entreprise", form=form, info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation", @@ -646,7 +646,7 @@ def fiche_entreprise_activer(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Activer entreprise", form=form, info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction", @@ -692,7 +692,7 @@ def add_taxe_apprentissage(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Ajout taxe apprentissage", form=form, ) @@ -735,7 +735,7 @@ def edit_taxe_apprentissage(entreprise_id, taxe_id): form.montant.data = taxe.montant form.notes.data = taxe.notes return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Modification taxe apprentissage", form=form, ) @@ -775,7 +775,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id): url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supprimer taxe apprentissage", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -845,7 +845,7 @@ def add_offre(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Ajout offre", form=form, ) @@ -921,7 +921,7 @@ def edit_offre(entreprise_id, offre_id): form.expiration_date.data = offre.expiration_date form.depts.data = offre_depts_list return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Modification offre", form=form, ) @@ -971,7 +971,7 @@ def delete_offre(entreprise_id, offre_id): url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supression offre", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -1047,7 +1047,7 @@ def add_site(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Ajout site", form=form, ) @@ -1098,7 +1098,7 @@ def edit_site(entreprise_id, site_id): form.ville.data = site.ville form.pays.data = site.pays return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Modification site", form=form, ) @@ -1154,7 +1154,7 @@ def add_correspondant(entreprise_id, site_id): url_for("entreprises.fiche_entreprise", entreprise_id=site.entreprise_id) ) return render_template( - "entreprises/form_ajout_correspondants.html", + "entreprises/form_ajout_correspondants.j2", title="Ajout correspondant", form=form, ) @@ -1234,7 +1234,7 @@ def edit_correspondant(entreprise_id, site_id, correspondant_id): form.origine.data = correspondant.origine form.notes.data = correspondant.notes return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Modification correspondant", form=form, ) @@ -1290,7 +1290,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id): ) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supression correspondant", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -1308,7 +1308,7 @@ def contacts(entreprise_id): ).first_or_404(description=f"entreprise {entreprise_id} inconnue") contacts = EntrepriseContact.query.filter_by(entreprise=entreprise.id).all() return render_template( - "entreprises/contacts.html", + "entreprises/contacts.j2", title="Liste des contacts", contacts=contacts, entreprise=entreprise, @@ -1365,7 +1365,7 @@ def add_contact(entreprise_id): db.session.commit() return redirect(url_for("entreprises.contacts", entreprise_id=entreprise.id)) return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Ajout contact", form=form, ) @@ -1421,7 +1421,7 @@ def edit_contact(entreprise_id, contact_id): ) form.notes.data = contact.notes return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Modification contact", form=form, ) @@ -1459,7 +1459,7 @@ def delete_contact(entreprise_id, contact_id): url_for("entreprises.contacts", entreprise_id=contact.entreprise) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supression contact", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -1525,7 +1525,7 @@ def add_stage_apprentissage(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id) ) return render_template( - "entreprises/form_ajout_stage_apprentissage.html", + "entreprises/form_ajout_stage_apprentissage.j2", title="Ajout stage / apprentissage", form=form, ) @@ -1599,7 +1599,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): form.date_fin.data = stage_apprentissage.date_fin form.notes.data = stage_apprentissage.notes return render_template( - "entreprises/form_ajout_stage_apprentissage.html", + "entreprises/form_ajout_stage_apprentissage.j2", title="Modification stage / apprentissage", form=form, ) @@ -1640,7 +1640,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id): ) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Supression stage/apprentissage", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -1690,7 +1690,7 @@ def envoyer_offre(entreprise_id, offre_id): url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id) ) return render_template( - "entreprises/form_envoi_offre.html", + "entreprises/form_envoi_offre.j2", title="Envoyer une offre", form=form, ) @@ -1816,7 +1816,7 @@ def import_donnees(): db.session.rollback() flash("Une erreur est survenue veuillez réessayer.") return render_template( - "entreprises/import_donnees.html", + "entreprises/import_donnees.j2", title="Importation données", form=form, ) @@ -1845,7 +1845,7 @@ def import_donnees(): db.session.commit() flash(f"Importation réussie") return render_template( - "entreprises/import_donnees.html", + "entreprises/import_donnees.j2", title="Importation données", form=form, entreprises_import=entreprises_import, @@ -1853,7 +1853,7 @@ def import_donnees(): correspondants_import=correspondants, ) return render_template( - "entreprises/import_donnees.html", title="Importation données", form=form + "entreprises/import_donnees.j2", title="Importation données", form=form ) @@ -1927,7 +1927,7 @@ def add_offre_file(entreprise_id, offre_id): url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id) ) return render_template( - "entreprises/form.html", + "entreprises/form.j2", title="Ajout fichier à une offre", form=form, ) @@ -1969,7 +1969,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir): ) ) return render_template( - "entreprises/form_confirmation.html", + "entreprises/form_confirmation.j2", title="Suppression fichier d'une offre", form=form, info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression", @@ -1981,4 +1981,4 @@ def not_found_error_handler(e): """ Renvoie une page d'erreur pour l'erreur 404 """ - return render_template("entreprises/error.html", title="Erreur", e=e) + return render_template("entreprises/error.j2", title="Erreur", e=e) diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index 2a0051f0d..2a54dd7c7 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -171,7 +171,7 @@ class AddLogoForm(FlaskForm): class LogoForm(FlaskForm): - """Embed both presentation of a logo (cf. template file configuration.html) + """Embed both presentation of a logo (cf. template file configuration.j2) and all its data and UI action (change, delete)""" dept_key = HiddenField() @@ -434,7 +434,7 @@ def config_logos(): scu.flash_errors(form) return render_template( - "config_logos.html", + "config_logos.j2", scodoc_dept=None, title="Configuration ScoDoc", form=form, diff --git a/app/forms/main/config_main.py b/app/forms/main/config_main.py index 205c88fac..4cc539fb1 100644 --- a/app/forms/main/config_main.py +++ b/app/forms/main/config_main.py @@ -133,7 +133,7 @@ def configuration(): return redirect(url_for("scodoc.index")) return render_template( - "configuration.html", + "configuration.j2", form_bonus=form_bonus, form_scodoc=form_scodoc, scu=scu, diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 53e127d87..ede6aacde 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -1,13 +1,19 @@ # -*- coding: UTF-8 -* """Gestion de l'assiduité (assiduités + justificatifs) """ -from app import db -from app.models import ModuleImpl, ModuleImplInscription -from app.models.etudiants import Identite -from app.scodoc.sco_utils import EtatAssiduite, localize_datetime, is_period_overlapping -from app.scodoc.sco_exceptions import ScoValueError from datetime import datetime +from app import db +from app.models import ModuleImpl +from app.models.etudiants import Identite +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_utils import ( + EtatAssiduite, + EtatJustificatif, + is_period_overlapping, + localize_datetime, +) + class Assiduite(db.Model): """ @@ -43,6 +49,8 @@ class Assiduite(db.Model): desc = db.Column(db.Text) + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + def to_dict(self) -> dict: data = { "assiduite_id": self.assiduite_id, @@ -52,6 +60,7 @@ class Assiduite(db.Model): "date_fin": self.date_fin, "etat": self.etat, "desc": self.desc, + "entry_date": self.entry_date, } return data @@ -119,7 +128,8 @@ class Justificatif(db.Model): __tablename__ = "justificatifs" - justif_id = db.Column(db.Integer, primary_key=True) + 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 @@ -136,26 +146,63 @@ class Justificatif(db.Model): ) etat = db.Column( db.Integer, + nullable=False, ) + entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + raison = db.Column(db.Text()) - """ - Les justificatifs sont enregistrés dans - /justificatifs/// - - d'après sco_archives.py#JustificatifArchiver - """ + # Archive_id -> sco_archives_justificatifs.py fichier = db.Column(db.Text()) def to_dict(self) -> dict: data = { - "justif_id": self.assiduite_id, + "justif_id": self.justif_id, "etudid": self.etudid, "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": self.etat, "raison": self.raison, "fichier": self.fichier, + "entry_date": self.entry_date, } return data + + @classmethod + def create_justificatif( + cls, + etud: Identite, + date_debut: datetime, + date_fin: datetime, + etat: EtatJustificatif, + raison: str = None, + ) -> object or int: + """Créer un nouveau justificatif pour l'étudiant""" + # Vérification de non duplication des périodes + justificatifs: list[Justificatif] = etud.justificatifs.all() + + date_debut = localize_datetime(date_debut) + date_fin = localize_datetime(date_fin) + justificatifs = [ + just + for just in justificatifs + if is_period_overlapping( + (date_debut, date_fin), + (just.date_debut, just.date_fin), + ) + ] + if len(justificatifs) != 0: + raise ScoValueError( + "Duplication des justificatifs (la période rentrée rentre en conflit avec un justificatif enregistré)" + ) + + nouv_assiduite = Justificatif( + date_debut=date_debut, + date_fin=date_fin, + etat=etat, + etudiant=etud, + raison=raison, + ) + + return nouv_assiduite diff --git a/app/models/formations.py b/app/models/formations.py index 986ef7e74..d4aac9ad2 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -36,6 +36,7 @@ class Formation(db.Model): titre = db.Column(db.Text(), nullable=False) titre_officiel = db.Column(db.Text(), nullable=False) version = db.Column(db.Integer, default=1, server_default="1") + commentaire = db.Column(db.Text()) formation_code = db.Column( db.String(SHORT_STR_LEN), server_default=db.text("notes_newid_fcod()"), @@ -63,7 +64,7 @@ class Formation(db.Model): return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" def to_dict(self, with_refcomp_attrs=False): - """ "as a dict. + """As a dict. Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp. """ e = dict(self.__dict__) diff --git a/app/models/ues.py b/app/models/ues.py index 596e0bef6..2f14ee74f 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -256,23 +256,12 @@ class UniteEns(db.Model): class DispenseUE(db.Model): """Dispense d'UE - Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée + Utilisé en PCC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée qu'ils ne refont pas. - La dispense d'UE n'est PAS une validation: - - elle n'est pas affectée par les décisions de jury (pas effacée) - - elle est associée à un formsemestre - - elle ne permet pas la délivrance d'ECTS ou du diplôme. - - On utilise cette dispense et non une "inscription" par souci d'efficacité: - en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours, - la dispense étant une exception. """ - __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),) + __table_args__ = (db.UniqueConstraint("ue_id", "etudid"),) id = db.Column(db.Integer, primary_key=True) - formsemestre_id = formsemestre_id = db.Column( - db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True - ) ue_id = db.Column( db.Integer, db.ForeignKey(UniteEns.id, ondelete="CASCADE"), @@ -291,25 +280,3 @@ class DispenseUE(db.Model): def __repr__(self) -> str: return f"""<{self.__class__.__name__} {self.id} etud={ repr(self.etud)} ue={repr(self.ue)}>""" - - @classmethod - def load_formsemestre_dispense_ues_set( - cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns] - ) -> set[tuple[int, int]]: - """Construit l'ensemble des - etudids = modimpl_inscr_df.index, # les etudids - ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport - - Résultat: set de (etudid, ue_id). - """ - # Prend toutes les dispenses obtenues par des étudiants de ce formsemestre, - # puis filtre sur inscrits et ues - ue_ids = {ue.id for ue in ues} - dispense_ues = { - (dispense_ue.etudid, dispense_ue.ue_id) - for dispense_ue in DispenseUE.query.filter_by( - formsemestre_id=formsemestre.id - ) - if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids - } - return dispense_ues diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 7098757c0..e4d330a78 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -274,7 +274,7 @@ def sco_header( H.append("""
""") # En attendant le replacement complet de cette fonction, # inclusion ici des messages flask - H.append(render_template("flashed_messages.html")) + H.append(render_template("flashed_messages.j2")) # # Barre menu semestre: H.append(formsemestre_page_title(formsemestre_id)) diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index a487fcf0c..33132a056 100644 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -166,6 +166,6 @@ def sidebar(etudid: int = None): def sidebar_dept(): """Partie supérieure de la marge de gauche""" return render_template( - "sidebar_dept.html", + "sidebar_dept.j2", prefs=sco_preferences.SemPreferences(), ) diff --git a/app/scodoc/sco_apogee_compare.py b/app/scodoc/sco_apogee_compare.py index 6c9676049..0c93c6652 100644 --- a/app/scodoc/sco_apogee_compare.py +++ b/app/scodoc/sco_apogee_compare.py @@ -43,6 +43,7 @@ Pour chaque étudiant commun: comparer les résultats """ +from flask import g, url_for from app import log from app.scodoc import sco_apogee_csv @@ -72,11 +73,11 @@ def apo_compare_csv_form(): """
Fichier Apogée A: - +
Fichier Apogée B: - +
autodétecter encodage
@@ -88,17 +89,36 @@ def apo_compare_csv_form(): return "\n".join(H) -def apo_compare_csv(A_file, B_file, autodetect=True): +def apo_compare_csv(file_a, file_b, autodetect=True): """Page comparing 2 Apogee CSV files""" - A = _load_apo_data(A_file, autodetect=autodetect) - B = _load_apo_data(B_file, autodetect=autodetect) - + try: + apo_data_a = _load_apo_data(file_a, autodetect=autodetect) + apo_data_b = _load_apo_data(file_b, autodetect=autodetect) + except (UnicodeDecodeError, UnicodeEncodeError) as exc: + dest_url = url_for("notes.semset_page", scodoc_dept=g.scodoc_dept) + if autodetect: + raise ScoValueError( + """ + Erreur: l'encodage de l'un des fichiers est mal détecté. + Essayez sans auto-détection, ou vérifiez le codage et le contenu + des fichiers. + """, + dest_url=dest_url, + ) from exc + else: + raise ScoValueError( + f""" + Erreur: l'encodage de l'un des fichiers est incorrect. + Vérifiez qu'il est bien en {sco_apogee_csv.APO_INPUT_ENCODING} + """, + dest_url=dest_url, + ) from exc H = [ html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"), "

Comparaison de fichiers Apogée

", _help_txt, '
', - _apo_compare_csv(A, B), + _apo_compare_csv(apo_data_a, apo_data_b), "
", """

Autre comparaison

""", html_sco_header.sco_footer(), @@ -112,9 +132,9 @@ def _load_apo_data(csvfile, autodetect=True): if autodetect: data_b, message = sco_apogee_csv.fix_data_encoding(data_b) if message: - log("apo_compare_csv: %s" % message) + log(f"apo_compare_csv: {message}") if not data_b: - raise ScoValueError("apo_compare_csv: no data") + raise ScoValueError("fichier vide ? (apo_compare_csv: no data)") data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING) apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename) return apo_data diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 212bb133b..8d21d579b 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -155,28 +155,25 @@ def fix_data_encoding( text: bytes, default_source_encoding=APO_INPUT_ENCODING, dest_encoding=APO_INPUT_ENCODING, -) -> bytes: +) -> tuple[bytes, str]: """Try to ensure that text is using dest_encoding returns converted text, and a message describing the conversion. + + Raises UnicodeEncodeError en cas de problème, en général liée à + une auto-détection errornée. """ message = "" detected_encoding = guess_data_encoding(text) if not detected_encoding: if default_source_encoding != dest_encoding: - message = "converting from %s to %s" % ( - default_source_encoding, - dest_encoding, - ) - text = text.decode(default_source_encoding).encode( - dest_encoding - ) # XXX #py3 #sco8 à tester + message = f"converting from {default_source_encoding} to {dest_encoding}" + text = text.decode(default_source_encoding).encode(dest_encoding) else: if detected_encoding != dest_encoding: - message = "converting from detected %s to %s" % ( - detected_encoding, - dest_encoding, + message = ( + f"converting from detected {default_source_encoding} to {dest_encoding}" ) - text = text.decode(detected_encoding).encode(dest_encoding) # XXX + text = text.decode(detected_encoding).encode(dest_encoding) return text, message diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 799024db4..271892149 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -373,7 +373,7 @@ def etudarchive_import_files( filename_title="fichier_a_charger", ) return render_template( - "scolar/photos_import_files.html", + "scolar/photos_import_files.j2", page_title="Téléchargement de fichiers associés aux étudiants", ignored_zipfiles=ignored_zipfiles, unmatched_files=unmatched_files, diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py new file mode 100644 index 000000000..e64d788a1 --- /dev/null +++ b/app/scodoc/sco_archives_justificatifs.py @@ -0,0 +1,112 @@ +from app.scodoc.sco_archives import BaseArchiver +from app.scodoc.sco_exceptions import ScoValueError +from app.models import Identite, Departement +from flask import g +import os + + +class JustificatifArchiver(BaseArchiver): + """ + + TOTALK: + - oid -> etudid + - archive_id -> date de création de l'archive (une archive par dépot de document) + + justificatif + └── + └── + └── + ├── [_description.txt] + └── [] + + + TODO: + - Faire fonction suppression fichier unique dans archive + """ + + def __init__(self): + BaseArchiver.__init__(self, archive_type="justificatifs") + + def save_justificatif( + self, + etudid: int, + filename: str, + data: bytes or str, + archive_name: str = None, + description: str = "", + ) -> str: + """ + Ajoute un fichier dans une archive "justificatif" pour l'etudid donné + Retourne l'archive_name utilisé + """ + self._set_dept(etudid) + if archive_name is None: + archive_id: str = self.create_obj_archive( + oid=etudid, description=description + ) + else: + archive_id: str = self.get_id_from_name(etudid, archive_name) + + self.store(archive_id, filename, data) + + return self.get_archive_name(archive_id) + + def delete_justificatif(self, etudid: int, archive_name: str, filename: str = None): + """ + Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné + """ + self._set_dept(etudid) + if str(etudid) not in self.list_oids(): + raise ValueError(f"Aucune archive pour etudid[{etudid}]") + + archive_id = self.get_id_from_name(etudid, archive_name) + + if filename is not None: + if filename not in self.list_archive(archive_id): + raise ValueError( + f"filename {filename} inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]" + ) + + path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename) + + if os.path.isfile(path): + os.remove(path) + + else: + self.delete_archive( + os.path.join( + self.get_obj_dir(etudid), + archive_id, + ) + ) + + def list_justificatifs(self, archive_name: str, etudid: int) -> list[str]: + """ + Retourne la liste des noms de fichiers dans l'archive donnée + """ + self._set_dept(etudid) + filenames: list[str] = [] + archive_id = self.get_id_from_name(etudid, archive_name) + + filenames = self.list_archive(archive_id) + return filenames + + def get_justificatif_file(self, archive_name: str, etudid: int, filename: str): + """ + Retourne une réponse de téléchargement de fichier si le fichier existe + """ + self._set_dept(etudid) + archive_id: str = self.get_id_from_name(etudid, archive_name) + if filename in self.list_archive(archive_id): + return self.get_archived_file(etudid, archive_name, filename) + raise ScoValueError( + f"Fichier {filename} introuvable dans l'archive {archive_name}" + ) + + def _set_dept(self, etudid: int): + if g.scodoc_dept is None or g.scodoc_dept_id is None: + etud: Identite = Identite.query.filter_by(id=etudid).first() + dept: Departement = Departement.query.filter_by(id=etud.dept_id).first() + + g.scodoc_dept = dept.acronym + g.scodoc_dept_id = dept.id diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index d1c477d01..c216184ef 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -1,27 +1,33 @@ -from app.models.etudiants import Identite -from app.models.formsemestre import FormSemestre -from app.models.assiduites import Assiduite +from datetime import date, datetime, time, timedelta + import app.scodoc.sco_utils as scu -from datetime import datetime, date, time, timedelta +from app.models.assiduites import Assiduite, Justificatif +from app.models.etudiants import Identite +from app.models.formsemestre import FormSemestre, FormSemestreInscription # TOTALK: Réfléchir sur le fractionnement d'une assiduite prolongée def get_assiduites_stats( - assiduites: Assiduite, metric: str = "all", filter: dict[str, object] = {} + assiduites: Assiduite, metric: str = "all", filtered: dict[str, object] = None ) -> Assiduite: - if filter != {}: - for key in filter: + + if filtered is not None: + for key in filtered: if key == "etat": - assiduites = filter_by_etat(assiduites, filter[key]) + assiduites = filter_assiduites_by_etat(assiduites, filtered[key]) elif key == "date_fin": - assiduites = filter_by_date(assiduites, filter[key], sup=False) + assiduites = filter_assiduites_by_date( + assiduites, filtered[key], sup=False + ) elif key == "date_debut": - assiduites = filter_by_date(assiduites, filter[key], sup=True) + assiduites = filter_assiduites_by_date( + assiduites, filtered[key], sup=True + ) elif key == "moduleimpl_id": - assiduites = filter_by_module_impl(assiduites, filter[key]) + assiduites = filter_by_module_impl(assiduites, filtered[key]) elif key == "formsemestre": - assiduites = filter_by_formsemstre(assiduites, filter[key]) + assiduites = filter_by_formsemestre(assiduites, filtered[key]) count: dict = get_count(assiduites) @@ -29,10 +35,10 @@ def get_assiduites_stats( output: dict = {} - for key in count: + for key, val in count.items(): if key in metrics: - output[key] = count[key] - return output if output != {} else count + output[key] = val + return output if output else count def get_count(assiduites: Assiduite) -> dict[str, int or float]: @@ -48,9 +54,11 @@ def get_count(assiduites: Assiduite) -> dict[str, int or float]: current_day: date = None current_time: str = None - MIDNIGHT: time = time(hour=0) - NOON: time = time(hour=12) - time_check = lambda d: (MIDNIGHT <= d.time() <= NOON) + midnight: time = time(hour=0) + noon: time = time(hour=12) + + def time_check(dtime): + return midnight <= dtime.time() <= noon for ass in all_assiduites: delta: timedelta = ass.date_fin - ass.date_debut @@ -72,7 +80,7 @@ def get_count(assiduites: Assiduite) -> dict[str, int or float]: return output -def filter_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: +def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: """ Filtrage d'une collection d'assiduites en fonction de leur état """ @@ -81,8 +89,8 @@ def filter_by_etat(assiduites: Assiduite, etat: str) -> Assiduite: return assiduites.filter(Assiduite.etat.in_(etats)) -def filter_by_date( - assiduites: Assiduite, date: datetime, sup: bool = True +def filter_assiduites_by_date( + assiduites: Assiduite, date_: datetime, sup: bool = True ) -> Assiduite: """ Filtrage d'une collection d'assiduites en fonction d'une date @@ -91,15 +99,47 @@ def filter_by_date( Sup == False -> les assiduites doivent finir avant 'date' """ - if date.tzinfo is None: + if date_.tzinfo is None: first_assiduite: Assiduite = assiduites.first() if first_assiduite is not None: - date: datetime = date.replace(tzinfo=first_assiduite.date_debut.tzinfo) + date_: datetime = date_.replace(tzinfo=first_assiduite.date_debut.tzinfo) if sup: - return assiduites.filter(Assiduite.date_debut >= date) - else: - return assiduites.filter(Assiduite.date_fin <= date) + return assiduites.filter(Assiduite.date_debut >= date_) + + return assiduites.filter(Assiduite.date_fin <= date_) + + +def filter_justificatifs_by_etat( + justificatifs: Justificatif, etat: str +) -> Justificatif: + """ + Filtrage d'une collection de justificatifs en fonction de leur état + """ + etats: list[str] = list(etat.split(",")) + etats = [scu.ETATS_JUSTIFICATIF.get(e, -1) for e in etats] + return justificatifs.filter(Justificatif.etat.in_(etats)) + + +def filter_justificatifs_by_date( + justificatifs: Justificatif, date_: datetime, sup: bool = True +) -> Assiduite: + """ + Filtrage d'une collection d'assiduites en fonction d'une date + + Sup == True -> les assiduites doivent débuter après 'date'\n + Sup == False -> les assiduites doivent finir avant 'date' + """ + + if date_.tzinfo is None: + first_justificatif: Justificatif = justificatifs.first() + if first_justificatif is not None: + date_: datetime = date_.replace(tzinfo=first_justificatif.date_debut.tzinfo) + + if sup: + return justificatifs.filter(Justificatif.date_debut >= date_) + + return justificatifs.filter(Justificatif.date_fin <= date_) def filter_by_module_impl( @@ -111,18 +151,24 @@ def filter_by_module_impl( return assiduites.filter(Assiduite.moduleimpl_id == module_impl_id) -def filter_by_formsemstre(assiduites: Assiduite, formsemestre: FormSemestre): +def filter_by_formsemestre(assiduites_query: Assiduite, formsemestre: FormSemestre): """ Filtrage d'une collection d'assiduites en fonction d'un formsemestre """ if formsemestre is None: - return assiduites.filter(False) + return assiduites_query.filter(False) - assiduites = assiduites.filter( - Identite.query.filter_by(id=Assiduite.etudid).first() - in formsemestre.etuds.all() + assiduites_query = ( + assiduites_query.join(Identite, Assiduite.etudid == Identite.id) + .join( + FormSemestreInscription, + Identite.id == FormSemestreInscription.etudid, + ) + .filter(FormSemestreInscription.formsemestre_id == formsemestre.id) ) - assiduites = assiduites.filter(Assiduite.date_debut >= formsemestre.date_debut) - return assiduites.filter(Assiduite.date_fin <= formsemestre.date_fin) + assiduites_query = assiduites_query.filter( + Assiduite.date_debut >= formsemestre.date_debut + ) + return assiduites_query.filter(Assiduite.date_fin <= formsemestre.date_fin) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index e47177d64..392a20bd2 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -926,7 +926,7 @@ def formsemestre_bulletinetud( _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version), bulletin, render_template( - "bul_foot.html", + "bul_foot.j2", appreciations=None, # déjà affichées css_class="bul_classic_foot", etud=etud, @@ -1259,7 +1259,7 @@ def _formsemestre_bulletinetud_header_html( cssstyles=["css/radar_bulletin.css"], ), render_template( - "bul_head.html", + "bul_head.j2", etud=etud, format=format, formsemestre=formsemestre, diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py index c64d732eb..b52e3d35b 100644 --- a/app/scodoc/sco_edit_formation.py +++ b/app/scodoc/sco_edit_formation.py @@ -234,6 +234,16 @@ def formation_edit(formation_id=None, create=False): "explanation": "optionel: code utilisé pour échanger avec d'autres logiciels et identifiant la filière ou spécialité (exemple: ASUR). N'est utilisé que s'il n'y a pas de numéro de semestre.", }, ), + ( + "commentaire", + { + "input_type": "textarea", + "rows": 3, + "cols": 77, + "title": "Commentaire", + "explanation": "commentaire libre.", + }, + ), ), initvalues=initvalues, submitlabel=submitlabel, diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index c8e6d0fdf..c4b1624cd 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -385,7 +385,7 @@ def module_edit( ), f"""

{title}

""", render_template( - "scodoc/help/modules.html", + "scodoc/help/modules.j2", is_apc=is_apc, semestre_id=semestre_id, formsemestres=FormSemestre.query.filter( @@ -396,6 +396,7 @@ def module_edit( .all() if not create else None, + create=create, ), ] if not unlocked: @@ -655,7 +656,8 @@ def module_edit( ( "numero", { - "size": 2, + "title": "Numéro", + "size": 4, "explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage", "type": "int", "default": default_num, diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index e37b0fd84..ac6028736 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -591,19 +591,45 @@ def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect= if not semset_id: raise ValueError("invalid null semset_id") semset = sco_semset.SemSet(semset_id=semset_id) - if csvfile: - data = csvfile.read() # bytes + try: + if csvfile: + data = csvfile.read() # bytes + if autodetect: + # check encoding (although documentation states that users SHOULD upload LATIN1) + + data, message = sco_apogee_csv.fix_data_encoding(data) + if message: + log(f"view_apo_csv_store: {message}") + else: + log("view_apo_csv_store: autodetection of encoding disabled by user") + if not data: + raise ScoValueError("view_apo_csv_store: no data") + # data est du bytes, encodé en APO_INPUT_ENCODING + data_str = data.decode(APO_INPUT_ENCODING) + except (UnicodeDecodeError, UnicodeEncodeError) as exc: + dest_url = url_for( + "notes.apo_semset_maq_status", + scodoc_dept=g.scodoc_dept, + semset_id=semset_id, + ) if autodetect: - # check encoding (although documentation states that users SHOULD upload LATIN1) - data, message = sco_apogee_csv.fix_data_encoding(data) - if message: - log("view_apo_csv_store: %s" % message) + raise ScoValueError( + f""" + Erreur: l'encodage du fichier est mal détecté. + Essayez sans auto-détection, ou vérifiez le codage et le contenu + du fichier (qui doit être en {sco_apogee_csv.APO_INPUT_ENCODING}). + """, + dest_url=dest_url, + ) from exc else: - log("view_apo_csv_store: autodetection of encoding disabled by user") - if not data: - raise ScoValueError("view_apo_csv_store: no data") - # data est du bytes, encodé en APO_INPUT_ENCODING - data_str = data.decode(APO_INPUT_ENCODING) + raise ScoValueError( + f""" + Erreur: l'encodage du fichier est incorrect. + Vérifiez qu'il est bien en {sco_apogee_csv.APO_INPUT_ENCODING} + """, + dest_url=dest_url, + ) from exc + # check si etape maquette appartient bien au semset apo_data = sco_apogee_csv.ApoData( data_str, periode=semset["sem_id"] diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index b99af2cc3..134bcd1ad 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -36,7 +36,7 @@ from flask_login import current_user from app import db, log -from app.models import Evaluation, ModuleImpl, ScolarNews +from app.models import ModuleImpl, ScolarNews from app.models.evaluations import evaluation_enrich_dict, check_evaluation_args import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index 900827d41..6c4185198 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -345,7 +345,7 @@ def evaluation_create_form( + "\n".join(H) + "\n" + tf[1] - + render_template("scodoc/help/evaluations.html", is_apc=is_apc) + + render_template("scodoc/help/evaluations.j2", is_apc=is_apc) + html_sco_header.sco_footer() ) elif tf[0] == -1: diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index b28590eb1..93f647346 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -75,6 +75,7 @@ _formationEditor = ndb.EditableTable( "type_parcours", "code_specialite", "referentiel_competence_id", + "commentaire", ), filter_dept=True, sortkey="acronyme", @@ -118,6 +119,7 @@ def formation_export( formation: Formation = Formation.query.get_or_404(formation_id) f_dict = formation.to_dict(with_refcomp_attrs=True) if not export_ids: + del f_dict["id"] del f_dict["formation_id"] del f_dict["dept_id"] ues = formation.ues diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index c3ab96908..402244080 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -541,7 +541,7 @@ def formsemestre_page_title(formsemestre_id=None): formsemestre = FormSemestre.query.get_or_404(formsemestre_id) h = render_template( - "formsemestre_page_title.html", + "formsemestre_page_title.j2", formsemestre=formsemestre, scu=scu, sem_menu_bar=formsemestre_status_menubar(formsemestre), diff --git a/app/scodoc/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py index 3af2c963b..5caba3beb 100644 --- a/app/scodoc/sco_groups_edit.py +++ b/app/scodoc/sco_groups_edit.py @@ -46,7 +46,7 @@ def affect_groups(partition_id): raise AccessDenied("vous n'avez pas la permission de modifier les groupes") partition.formsemestre.setup_parcours_groups() return render_template( - "scolar/affect_groups.html", + "scolar/affect_groups.j2", sco_header=html_sco_header.sco_header( page_title="Affectation aux groupes", javascripts=["js/groupmgr.js"], diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index bf330adb3..7ac6b2cae 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -400,9 +400,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id): # Etudiants "dispensés" d'une UE (capitalisée) ues_cap_info = get_etuds_with_capitalized_ue(formsemestre_id) if ues_cap_info: - H.append( - '

Étudiants avec UEs capitalisées (ADM):

    ' - ) + H.append('

    Étudiants avec UEs capitalisées:

      ') ues = [ sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in ues_cap_info.keys() ] @@ -470,9 +468,8 @@ def moduleimpl_inscriptions_stats(formsemestre_id): if can_change: H.append( f""" """ ) diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py index 0e0ec8444..6ce07aa8c 100644 --- a/app/scodoc/sco_placement.py +++ b/app/scodoc/sco_placement.py @@ -215,7 +215,7 @@ def placement_eval_selectetuds(evaluation_id): html_sco_header.sco_header(), sco_evaluations.evaluation_describe(evaluation_id=evaluation_id), "

      Placement et émargement des étudiants

      ", - render_template("scodoc/forms/placement.html", form=form), + render_template("scodoc/forms/placement.j2", form=form), ] footer = html_sco_header.sco_footer() return "\n".join(htmls) + "

      " + footer diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index de6299316..b9cea8606 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -166,9 +166,15 @@ def formsemestre_recapcomplet( H.append("

      ") if mode_jury: H.append( - f"""Calcul automatique des décisions du jury

      """ + }">Calcul automatique des décisions du jury +

      Effacer toutes les décisions de jury du semestre +

      +

      + """ ) else: H.append( diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index cf725794a..140d1634d 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -554,7 +554,7 @@ def photos_import_files_form(group_ids=()): back_url=back_url, ) return render_template( - "scolar/photos_import_files.html", + "scolar/photos_import_files.j2", page_title="Téléchargement des photos des étudiants", ignored_zipfiles=ignored_zipfiles, unmatched_files=unmatched_files, diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index ae178236a..69a115d88 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -128,6 +128,13 @@ ETAT_JUSTIFICATIF_NAME = { EtatJustificatif.MODIFIE: "modifié", } +ETATS_JUSTIFICATIF = { + "validé": EtatJustificatif.VALIDE, + "non vaidé": EtatJustificatif.NON_VALIDE, + "en attente": EtatJustificatif.ATTENTE, + "modifié": EtatJustificatif.MODIFIE, +} + def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: """ diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 42c2ecac1..d73737228 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -4,6 +4,7 @@ :root { --sco-content-min-width: 600px; --sco-content-max-width: 1024px; + --sco-color-explication: rgb(10, 58, 140); } html, @@ -325,9 +326,9 @@ div.logo-logo img { box-sizing: content-box; margin-top: 10px; /* -10px */ - width: 80px; + width: 130px; /* adapter suivant image */ - padding-right: 5px; + /* padding-right: 5px; */ } div.sidebar-bottom { @@ -2115,6 +2116,11 @@ div.formation_descr span.fd_n { margin-left: 6em; } +span.explication { + font-style: italic; + color: var(--sco-color-explication); +} + div.formation_ue_list { border: 1px solid black; margin-top: 5px; diff --git a/app/static/icons/scologo_img.png b/app/static/icons/scologo_img.png index 04a2c5c1c..9bf587a80 100644 Binary files a/app/static/icons/scologo_img.png and b/app/static/icons/scologo_img.png differ diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js index 7242ed391..cd30896f7 100644 --- a/app/static/js/table_recap.js +++ b/app/static/js/table_recap.js @@ -14,6 +14,8 @@ $(function () { const url = new URL(document.URL); const formsemestre_id = url.searchParams.get("formsemestre_id"); const order_info_key = JSON.stringify([url.pathname, formsemestre_id]); + const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); + const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); let order_info; if (formsemestre_id) { const x = localStorage.getItem(order_info_key); @@ -157,83 +159,89 @@ $(function () { } }); } - let table = $('table.table_recap').DataTable( - { - paging: false, - searching: true, - info: false, - autoWidth: false, - fixedHeader: { - header: true, - footer: false - }, - orderCellsTop: true, // cellules ligne 1 pour tri - aaSorting: [], // Prevent initial sorting - colReorder: true, - stateSave: true, // enregistre état de la table (tris, ...) - "columnDefs": [ - { - // cache les codes, le détail de l'identité, les groupes, les colonnes admission et les vides - targets: hidden_colums, - visible: false, + try { + let table = $('table.table_recap').DataTable( + { + paging: false, + searching: true, + info: false, + autoWidth: false, + fixedHeader: { + header: true, + footer: false }, - { - // Elimine les 0 à gauche pour les exports excel et les "copy" - targets: ["col_mod", "col_moy_gen", "col_ue", "col_res", "col_sae", "evaluation", "col_rcue"], - render: function (data, type, row) { - return type === 'export' ? data.replace(/0(\d\..*)/, '$1') : data; + orderCellsTop: true, // cellules ligne 1 pour tri + aaSorting: [], // Prevent initial sorting + colReorder: true, + stateSave: true, // enregistre état de la table (tris, ...) + "columnDefs": [ + { + // cache les codes, le détail de l'identité, les groupes, les colonnes admission et les vides + targets: hidden_colums, + visible: false, + }, + { + // Elimine les 0 à gauche pour les exports excel et les "copy" + targets: ["col_mod", "col_moy_gen", "col_ue", "col_res", "col_sae", "evaluation", "col_rcue"], + render: function (data, type, row) { + return type === 'export' ? data.replace(/0(\d\..*)/, '$1') : data; + } + }, + { + // Elimine les décorations (fleches bonus/malus) pour les exports + targets: ["col_ue_bonus", "col_malus"], + render: function (data, type, row) { + return type === 'export' ? data.replace(/.*(\d\d\.\d\d)/, '$1').replace(/0(\d\..*)/, '$1') : data; + } + }, + ], + dom: 'Bfrtip', + buttons: [ + { + extend: 'copyHtml5', + text: 'Copier', + exportOptions: { orthogonal: 'export' } + }, + { + extend: 'excelHtml5', + // footer: true, // ne fonctionne pas ? + exportOptions: { orthogonal: 'export' }, + title: document.querySelector('table.table_recap').dataset.filename + }, + { + extend: 'collection', + text: 'Colonnes affichées', + autoClose: true, + buttons: buttons, + }, + ], + "drawCallback": function (settings) { + // permet de conserver l'ordre de tri des colonnes + let order_info = JSON.stringify($('table.table_recap').DataTable().order()); + if (formsemestre_id) { + localStorage.setItem(order_info_key, order_info); } + let etudids = []; + document.querySelectorAll("td.identite_court").forEach(e => { + etudids.push(e.dataset.etudid); + }); + let noms = []; + document.querySelectorAll("td.identite_court").forEach(e => { + noms.push(e.dataset.nomprenom); + }); + localStorage.setItem(etudids_key, JSON.stringify(etudids)); + localStorage.setItem(noms_key, JSON.stringify(noms)); }, - { - // Elimine les décorations (fleches bonus/malus) pour les exports - targets: ["col_ue_bonus", "col_malus"], - render: function (data, type, row) { - return type === 'export' ? data.replace(/.*(\d\d\.\d\d)/, '$1').replace(/0(\d\..*)/, '$1') : data; - } - }, - ], - dom: 'Bfrtip', - buttons: [ - { - extend: 'copyHtml5', - text: 'Copier', - exportOptions: { orthogonal: 'export' } - }, - { - extend: 'excelHtml5', - // footer: true, // ne fonctionne pas ? - exportOptions: { orthogonal: 'export' }, - title: document.querySelector('table.table_recap').dataset.filename - }, - { - extend: 'collection', - text: 'Colonnes affichées', - autoClose: true, - buttons: buttons, - }, - ], - "drawCallback": function (settings) { - // permet de conserver l'ordre de tri des colonnes - let order_info = JSON.stringify($('table.table_recap').DataTable().order()); - if (formsemestre_id) { - localStorage.setItem(order_info_key, order_info); - } - let etudids = []; - document.querySelectorAll("td.identite_court").forEach(e => { - etudids.push(e.dataset.etudid); - }); - let noms = []; - document.querySelectorAll("td.identite_court").forEach(e => { - noms.push(e.dataset.nomprenom); - }); - const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); - localStorage.setItem(etudids_key, JSON.stringify(etudids)); - const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); - localStorage.setItem(noms_key, JSON.stringify(noms)); - }, - "order": order_info, - } - ); + "order": order_info, + } + ); + } catch (error) { + // l'erreur peut etre causee par un ancien storage: + localStorage.removeItem(etudids_key); + localStorage.removeItem(noms_key); + localStorage.removeItem(order_info_key); + location.reload(); + } update_buttons_labels(table); }); $('table.table_recap tbody').on('click', 'tr', function () { diff --git a/app/templates/about.html b/app/templates/about.j2 similarity index 50% rename from app/templates/about.html rename to app/templates/about.j2 index 0b2eed389..3dff1edee 100644 --- a/app/templates/about.html +++ b/app/templates/about.j2 @@ -1,18 +1,18 @@ {# -*- mode: jinja-html -*- #} -{% extends 'base.html' %} +{% extends 'base.j2' %} {% import 'bootstrap/wtf.html' as wtf %} {% block app_content %}

      Système de gestion scolarité

      -

      © Emmanuel Viennet 2021

      +

      © Emmanuel Viennet 2023

      Version {{ version }}

      - -

      ScoDoc est un logiciel libre écrit en -Python. -Information et documentation sur scodoc.org. + +

      ScoDoc est un logiciel libre écrit en + Python. + Information et documentation sur scodoc.org.

      Dernières évolutions

      diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.j2 similarity index 57% rename from app/templates/auth/change_password.html rename to app/templates/auth/change_password.j2 index 11e844811..5f0dbe002 100644 --- a/app/templates/auth/change_password.html +++ b/app/templates/auth/change_password.j2 @@ -1,14 +1,14 @@ {# -*- mode: jinja-html -*- #} -{% extends "base.html" %} +{% extends "base.j2" %} {% import 'bootstrap/wtf.html' as wtf %} {% macro render_field(field, auth_name=None) %} - {% if auth_name %} - {{ field.label }} ({{ auth_name }}): + {% if auth_name %} + {{ field.label }} ({{ auth_name }}): {% else %} - {{ field.label }} - {% endif %} + {{ field.label }} + {% endif %} {{ field(**kwargs)|safe }} {% if field.errors %}
        @@ -23,29 +23,31 @@ {% block app_content %}

        Modification du compte ScoDoc {{form.user_name.data}}

        -
        +

        Identifiez-vous avec votre mot de passe actuel

        -
        -
        - {{ form.user_name }} - {{ form.csrf_token }} - + + + {{ form.user_name }} + {{ form.csrf_token }} +
        + {{ render_field(form.old_password, size=14, auth_name=auth_username, style="padding:1px; margin-left: 1em; margin-top: 4px;") }} - {{ render_field(form.new_password, size=14, style="padding:1px; margin-left: 1em; margin-top: 12px;") }} - {{ render_field(form.bis_password, size=14, + {{ render_field(form.bis_password, size=14, style="padding:1px; margin-left: 1em; margin-top: 4px;") }} - {{ render_field(form.email, size=40, + {{ render_field(form.email, size=40, style="padding:1px; margin-top: 12px;margin-bottom: 16px; margin-left: 1em;") }} -
        -

        Vous pouvez changer le mot de passe et/ou l'adresse email.

        -

        Les champs laissés vides ne seront pas modifiés.

        +
        +

        Vous pouvez changer le mot de passe et/ou l'adresse email.

        +

        Les champs laissés vides ne seront pas modifiés.

        - - +
          {% if ( - current_user.is_administrator() - or current_user.has_permission(Permission.ScoUsersAdmin, user.dept) + current_user.is_administrator() + or current_user.has_permission(Permission.ScoUsersAdmin, user.dept) ) %}
        • {{"désactiver" if user.active else "activer"}} ce compte -
        • + {% endif %}
        diff --git a/app/templates/base.html b/app/templates/base.j2 similarity index 82% rename from app/templates/base.html rename to app/templates/base.j2 index 724eac009..640a4d72e 100644 --- a/app/templates/base.html +++ b/app/templates/base.j2 @@ -24,21 +24,23 @@ ScoDoc
        - +