diff --git a/app/__init__.py b/app/__init__.py index eb9d1980..54fd0a0e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -41,6 +41,7 @@ migrate = Migrate(compare_type=True) login = LoginManager() login.login_view = "auth.login" login.login_message = "Identifiez-vous pour accéder à cette page." + mail = Mail() bootstrap = Bootstrap() moment = Moment() @@ -249,8 +250,8 @@ 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.api import bp as api_bp - from app.api import api_web_bp as api_web_bp + from app.api import api_bp + from app.api import api_web_bp # https://scodoc.fr/ScoDoc app.register_blueprint(scodoc_bp) @@ -265,7 +266,7 @@ def create_app(config_class=DevConfig): absences_bp, url_prefix="/ScoDoc//Scolarite/Absences" ) app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") - app.register_blueprint(api_web_bp, url_prefix="/ScoDoc//apiweb") + app.register_blueprint(api_web_bp, url_prefix="/ScoDoc//api") scodoc_log_formatter = LogRequestFormatter( "[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n" diff --git a/app/api/__init__.py b/app/api/__init__.py index 45cad23b..a5414798 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -4,7 +4,7 @@ from flask import Blueprint from flask import request -bp = Blueprint("api", __name__) +api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) diff --git a/app/api/absences.py b/app/api/absences.py index 992f484f..ec564c44 100644 --- a/app/api/absences.py +++ b/app/api/absences.py @@ -8,9 +8,9 @@ from flask import jsonify -from app.api import bp +from app.api import api_bp as bp from app.api.errors import error_response -from app.api.auth import permission_required_api +from app.decorators import scodoc, permission_required from app.models import Identite from app.scodoc import notesdb as ndb @@ -21,7 +21,8 @@ from app.scodoc.sco_permissions import Permission @bp.route("/absences/etudid/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def absences(etudid: int = None): """ Retourne la liste des absences d'un étudiant donné @@ -65,7 +66,8 @@ def absences(etudid: int = None): @bp.route("/absences/etudid//just", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def absences_just(etudid: int = None): """ Retourne la liste des absences justifiées d'un étudiant donné @@ -120,7 +122,8 @@ def absences_just(etudid: int = None): "/absences/abs_group_etat/group_id//date_debut//date_fin/", methods=["GET"], ) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) 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) diff --git a/app/api/auth.py b/app/api/auth.py deleted file mode 100644 index 22492620..00000000 --- a/app/api/auth.py +++ /dev/null @@ -1,148 +0,0 @@ -# -*- coding: UTF-8 -* -# Authentication code borrowed from Miguel Grinberg's Mega Tutorial -# (see https://github.com/miguelgrinberg/microblog) -# and modified for ScoDoc - -# Under The MIT License (MIT) - -# Copyright (c) 2017 Miguel Grinberg - -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from functools import wraps - -from flask import g -from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth -import flask_login -from flask_login import current_user - -from app import log -from app.auth.models import User -from app.api import bp, api_web_bp -from app.api.errors import error_response -from app.decorators import scodoc, permission_required - -basic_auth = HTTPBasicAuth() -token_auth = HTTPTokenAuth() - - -@basic_auth.verify_password -def verify_password(username, password): - "Verify password for this user" - user: User = User.query.filter_by(user_name=username).first() - if user and user.check_password(password): - g.current_user = user - # note: est aussi basic_auth.current_user() - return user - - -@basic_auth.error_handler -def basic_auth_error(status): - "error response (401 for invalid auth.)" - return error_response(status) - - -@token_auth.verify_token -def verify_token(token) -> User: - """Retrouve l'utilisateur à partir du jeton. - Si la requête n'a pas de jeton, token == "". - """ - - user = User.check_token(token) if token else None - if user is not None: - flask_login.login_user(user) - g.current_user = user - return user - - -@token_auth.error_handler -def token_auth_error(status): - "Réponse en cas d'erreur d'auth." - return error_response(status) - - -@token_auth.get_user_roles -def get_user_roles(user): - return user.roles - - -def token_permission_required(permission): - "Décorateur pour les fonctions de l'API ScoDoc" - - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - current_user = basic_auth.current_user() - if not current_user or not current_user.has_permission(permission, None): - if current_user: - message = f"API permission denied (user {current_user})" - else: - message = f"API permission denied (no user supplied)" - log(message) - # raise werkzeug.exceptions.Forbidden(description=message) - return error_response(403, message=None) - if not hasattr(g, "scodoc_dept"): - g.scodoc_dept = None - return f(*args, **kwargs) - - # return decorated_function(token_auth.login_required()) - return decorated_function - - return decorator - - -def permission_required_api(permission_web, permission_api): - """Décorateur pour les fonctions de l'API accessibles en mode jeton - ou en mode web. - Si cookie d'authentification web, utilise pour se logger et calculer les - permissions. - Sinon, tente le jeton jwt. - """ - - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - scodoc_dept = getattr(g, "scodoc_dept", None) - if not current_user.has_permission(permission_web, scodoc_dept): - # try API - return token_auth.login_required( - token_permission_required(permission_api)(f) - )(*args, **kwargs) - return f(*args, **kwargs) - - return decorated_function - - return decorator - - -def web_publish(route, function, permission, methods=("GET",)): - """Declare a route for a python function protected by permission - using web http cookie - """ - return api_web_bp.route(route, methods=methods)( - scodoc(permission_required(permission)(function)) - ) - - -def api_publish(route, function, permission, methods=("GET",)): - """Declare a route for a python function protected by permission - using API token - """ - return bp.route(route, methods=methods)( - token_auth.login_required(token_permission_required(permission)(function)) - ) diff --git a/app/api/departements.py b/app/api/departements.py index 12b19b0d..b592397b 100644 --- a/app/api/departements.py +++ b/app/api/departements.py @@ -4,8 +4,8 @@ from flask import jsonify import app from app import models -from app.api import bp -from app.api.auth import permission_required_api +from app.api import api_bp as bp +from app.decorators import scodoc, permission_required from app.models import Departement, FormSemestre from app.scodoc.sco_permissions import Permission @@ -22,21 +22,24 @@ def get_departement(dept_ident: str) -> Departement: @bp.route("/departements", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def departements(): """Liste les départements""" return jsonify([dept.to_dict() for dept in Departement.query]) @bp.route("/departements_ids", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def departements_ids(): """Liste des ids de départements""" return jsonify([dept.id for dept in Departement.query]) @bp.route("/departement/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def departement(acronym: str): """ Info sur un département. Accès par acronyme. @@ -55,7 +58,8 @@ def departement(acronym: str): @bp.route("/departement/id/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def departement_by_id(dept_id: int): """ Info sur un département. Accès par id. @@ -65,7 +69,8 @@ def departement_by_id(dept_id: int): @bp.route("/departement//etudiants", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_etudiants(acronym: str): """ Retourne la liste des étudiants d'un département @@ -93,7 +98,8 @@ def dept_etudiants(acronym: str): @bp.route("/departement/id//etudiants", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_etudiants_by_id(dept_id: int): """ Retourne la liste des étudiants d'un département d'id donné. @@ -103,7 +109,8 @@ def dept_etudiants_by_id(dept_id: int): @bp.route("/departement//formsemestres_ids", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_formsemestres_ids(acronym: str): """liste des ids formsemestre du département""" dept = Departement.query.filter_by(acronym=acronym).first_or_404() @@ -111,7 +118,8 @@ def dept_formsemestres_ids(acronym: str): @bp.route("/departement/id//formsemestres_ids", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_formsemestres_ids_by_id(dept_id: int): """liste des ids formsemestre du département""" dept = Departement.query.get_or_404(dept_id) @@ -119,7 +127,8 @@ def dept_formsemestres_ids_by_id(dept_id: int): @bp.route("/departement//formsemestres_courants", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_formsemestres_courants(acronym: str): """ Liste des semestres actifs d'un département d'acronyme donné @@ -173,7 +182,8 @@ def dept_formsemestres_courants(acronym: str): @bp.route("/departement/id//formsemestres_courants", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@scodoc +@permission_required(Permission.ScoView) def dept_formsemestres_courants_by_id(dept_id: int): """ Liste des semestres actifs d'un département d'id donné diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 01b86b5e..0da5f60f 100644 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -10,41 +10,46 @@ from flask import g, jsonify from flask_login import current_user +from flask_login import login_required from sqlalchemy import or_ import app -from app.api import bp +from app.api import api_bp as bp, api_web_bp from app.api.errors import error_response -from app.api.auth import permission_required_api, api_publish, web_publish from app.api import tools - +from app.decorators import scodoc, permission_required from app.models import Departement, FormSemestreInscription, FormSemestre, Identite from app.scodoc import sco_bulletins from app.scodoc import sco_groups from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_permissions import Permission - +# Un exemple: +@bp.route("/api_function/") +@api_web_bp.route("/api_function/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def api_function(arg: int): """Une fonction quelconque de l'API""" - # u = current_user - # dept = g.scodoc_dept # peut être None si accès API - return jsonify({"current_user": current_user.to_dict(), "dept": g.scodoc_dept}) - - -api_publish("/api_function/", api_function, Permission.APIView) -web_publish("/api_function/", api_function, Permission.ScoView) + return jsonify( + {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept} + ) @bp.route("/etudiants/courants", defaults={"long": False}) @bp.route("/etudiants/courants/long", defaults={"long": True}) -@permission_required_api(Permission.ScoView, Permission.APIView) +@api_web_bp.route("/etudiants/courants", defaults={"long": False}) +@api_web_bp.route("/etudiants/courants/long", defaults={"long": True}) +@login_required +@scodoc +@permission_required(Permission.ScoView) def etudiants_courants(long=False): """ - La liste des étudiants des semestres "courants" (tous département) + La liste des étudiants des semestres "courants" (tous départements) (date du jour comprise dans la période couverte par le sem.) - dans lesquels l'utilisateur a le rôle APIView (donc tous si le dept du - rôle est None). + dans lesquels l'utilisateur a la permission ScoView + (donc tous si le dept du rôle est None). Exemple de résultat : [ @@ -89,9 +94,7 @@ def etudiants_courants(long=False): "villedomicile": "VALPARAISO", } """ - allowed_depts = current_user.get_depts_with_permission( - Permission.APIView | Permission.ScoView - ) + allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) etuds = Identite.query.filter( Identite.id == FormSemestreInscription.etudid, FormSemestreInscription.formsemestre_id == FormSemestre.id, @@ -110,10 +113,15 @@ def etudiants_courants(long=False): return jsonify(data) -@bp.route("/etudiant/etudid/", methods=["GET"]) -@bp.route("/etudiant/nip/", methods=["GET"]) -@bp.route("/etudiant/ine/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/etudiant/etudid/") +@bp.route("/etudiant/nip/") +@bp.route("/etudiant/ine/") +@api_web_bp.route("/etudiant/etudid/") +@api_web_bp.route("/etudiant/nip/") +@api_web_bp.route("/etudiant/ine/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def etudiant(etudid: int = None, nip: str = None, ine: str = None): """ Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé. @@ -167,7 +175,11 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): @bp.route("/etudiants/etudid/", methods=["GET"]) @bp.route("/etudiants/nip/", methods=["GET"]) @bp.route("/etudiants/ine/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@api_web_bp.route("/etudiants/etudid/", methods=["GET"]) +@api_web_bp.route("/etudiants/nip/", methods=["GET"]) +@api_web_bp.route("/etudiants/ine/", methods=["GET"]) +@scodoc +@permission_required(Permission.ScoView) def etudiants(etudid: int = None, nip: str = None, ine: str = None): """ Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie @@ -176,9 +188,7 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): Dans 99% des cas, la liste contient un seul étudiant, mais si l'étudiant a été inscrit dans plusieurs départements, on a plusieurs objets (1 par dept.). """ - allowed_depts = current_user.get_depts_with_permission( - Permission.APIView | Permission.ScoView - ) + allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) if etudid is not None: query = Identite.query.filter_by(id=etudid) elif nip is not None: @@ -201,14 +211,20 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): @bp.route("/etudiant/etudid//formsemestres") @bp.route("/etudiant/nip//formsemestres") @bp.route("/etudiant/ine//formsemestres") -@permission_required_api(Permission.ScoView, Permission.APIView) +@api_web_bp.route("/etudiant/etudid//formsemestres") +@api_web_bp.route("/etudiant/nip//formsemestres") +@api_web_bp.route("/etudiant/ine//formsemestres") +@scodoc +@permission_required(Permission.ScoView) def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None): """ Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique. - Attention, si accès via NIP ou INE, les semestres peuvent être de départements différents - (si l'étudiant a changé de département). L'id du département est `dept_id`. + Accès par etudid, nip ou ine. - Accès par etudid, nip ou ine + Attention, si accès via NIP ou INE, les semestres peuvent être de départements + différents (si l'étudiant a changé de département). L'id du département est `dept_id`. + + Si accès par département, ne retourne que les formsemestre suivis dans le département. Exemple de résultat : [ @@ -265,6 +281,9 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None) message="parametre manquant", ) + if g.scodoc_dept is not None: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestres = query.order_by(FormSemestre.date_debut) return jsonify( @@ -287,22 +306,12 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None) methods=["GET"], defaults={"version": "long", "pdf": False}, ) -# Version PDF non fonctionnelle +# Version PDF non testée @bp.route( "/etudiant/etudid//formsemestre//bulletin/pdf", methods=["GET"], defaults={"version": "long", "pdf": True}, ) -# @bp.route( -# "/etudiant/nip//formsemestre//bulletin/pdf", -# methods=["GET"], -# defaults={"version": "long", "pdf": True}, -# ) -# @bp.route( -# "/etudiant/ine//formsemestre//bulletin/pdf", -# methods=["GET"], -# defaults={"version": "long", "pdf": True}, -# ) @bp.route( "/etudiant/etudid//formsemestre//bulletin/short", methods=["GET"], @@ -333,8 +342,60 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None) methods=["GET"], defaults={"version": "short", "pdf": True}, ) -@permission_required_api(Permission.ScoView, Permission.APIView) -def etudiant_bulletin_semestre( # XXX TODO Ajouter la possibilité de retourner en version pdf +@api_web_bp.route( + "/etudiant/etudid//formsemestre//bulletin", + methods=["GET"], + defaults={"version": "long", "pdf": False}, +) +@api_web_bp.route( + "/etudiant/nip//formsemestre//bulletin", + methods=["GET"], + defaults={"version": "long", "pdf": False}, +) +@api_web_bp.route( + "/etudiant/ine//formsemestre//bulletin", + methods=["GET"], + defaults={"version": "long", "pdf": False}, +) +# Version PDF non testée +@api_web_bp.route( + "/etudiant/etudid//formsemestre//bulletin/pdf", + methods=["GET"], + defaults={"version": "long", "pdf": True}, +) +@api_web_bp.route( + "/etudiant/etudid//formsemestre//bulletin/short", + methods=["GET"], + defaults={"version": "short", "pdf": False}, +) +@api_web_bp.route( + "/etudiant/nip//formsemestre//bulletin/short", + methods=["GET"], + defaults={"version": "short", "pdf": False}, +) +@api_web_bp.route( + "/etudiant/ine//formsemestre//bulletin/short", + methods=["GET"], + defaults={"version": "short", "pdf": False}, +) +@api_web_bp.route( + "/etudiant/etudid//formsemestre//bulletin/short/pdf", + methods=["GET"], + defaults={"version": "short", "pdf": True}, +) +@api_web_bp.route( + "/etudiant/nip//formsemestre//bulletin/short/pdf", + methods=["GET"], + defaults={"version": "short", "pdf": True}, +) +@api_web_bp.route( + "/etudiant/ine//formsemestre//bulletin/short/pdf", + methods=["GET"], + defaults={"version": "short", "pdf": True}, +) +@scodoc +@permission_required(Permission.ScoView) +def etudiant_bulletin_semestre( formsemestre_id, etudid: int = None, nip: str = None, @@ -354,7 +415,8 @@ def etudiant_bulletin_semestre( # XXX TODO Ajouter la possibilité de retourner """ formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404() dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404() - + if g.scodoc_dept and dept != g.scodoc_dept: + return error_response(404, "formsemestre non trouve") if etudid is not None: query = Identite.query.filter_by(id=etudid) elif nip is not None: @@ -391,25 +453,14 @@ def etudiant_bulletin_semestre( # XXX TODO Ajouter la possibilité de retourner "/etudiant/etudid//formsemestre//groups", methods=["GET"], ) -@bp.route( - "/etudiant/nip//formsemestre//groups", - methods=["GET"], -) -@bp.route( - "/etudiant/ine//formsemestre//groups", - methods=["GET"], -) -@permission_required_api(Permission.ScoView, Permission.APIView) -def etudiant_groups( - formsemestre_id: int, etudid: int = None, nip: int = None, ine: int = None -): +@scodoc +@permission_required(Permission.ScoView) +def etudiant_groups(formsemestre_id: int, etudid: int = None): """ Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué formsemestre_id : l'id d'un formsemestre etudid : l'etudid d'un étudiant - nip : le code nip d'un étudiant - ine : le code ine d'un étudiant Exemple de résultat : [ @@ -438,30 +489,18 @@ def etudiant_groups( ] """ - formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first() + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre = query.first() if formsemestre is None: return error_response( 404, message="formsemestre inconnu", ) - dept = Departement.query.get(formsemestre.dept_id) - if etudid is not None: - query = Identite.query.filter_by(id=etudid) - elif nip is not None: - query = Identite.query.filter_by(code_nip=nip, dept_id=dept.id) - elif ine is not None: - query = Identite.query.filter_by(code_ine=ine, dept_id=dept.id) - else: - return error_response( - 404, - message="parametre manquant", - ) - etud = query.first() - if etud is None: - return error_response( - 404, - message="etudiant inconnu", - ) + dept = formsemestre.departement + etud = Identite.query.filter_by(id=etudid, dept_id=dept.id).first_or_404(etudid) + app.set_sco_dept(dept.acronym) data = sco_groups.get_etud_groups(etud.id, formsemestre.id) diff --git a/app/api/evaluations.py b/app/api/evaluations.py index 43d17722..2bd6859a 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -8,21 +8,24 @@ ScoDoc 9 API : accès aux évaluations """ -from flask import jsonify +from flask import g, jsonify +from flask_login import login_required import app -from app import models -from app.api import bp -from app.api.auth import permission_required_api +from app.api import api_bp as bp, api_web_bp +from app.decorators import scodoc, permission_required from app.api.errors import error_response -from app.models import Evaluation +from app.models import Evaluation, ModuleImpl, FormSemestre from app.scodoc.sco_evaluation_db import do_evaluation_get_all_notes from app.scodoc.sco_permissions import Permission -@bp.route("/evaluations/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/evaluations/") +@api_web_bp.route("/evaluations/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def evaluations(moduleimpl_id: int): """ Retourne la liste des évaluations d'un moduleimpl @@ -54,17 +57,21 @@ def evaluations(moduleimpl_id: int): ... ] """ - # Récupération de toutes les évaluations - evals = Evaluation.query.filter_by(id=moduleimpl_id) - - # Mise en forme des données - data = [d.to_dict() for d in evals] - - return jsonify(data) + query = Evaluation.query.filter_by(id=moduleimpl_id) + if g.scodoc_dept: + query = ( + query.join(ModuleImpl) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + return jsonify([d.to_dict() for d in query]) -@bp.route("/evaluation/eval_notes/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/evaluation//notes") +@api_web_bp.route("/evaluation//notes") +@login_required +@scodoc +@permission_required(Permission.ScoView) def evaluation_notes(evaluation_id: int): """ Retourne la liste des notes à partir de l'id d'une évaluation donnée @@ -94,7 +101,15 @@ def evaluation_notes(evaluation_id: int): ... } """ - evaluation = models.Evaluation.query.filter_by(id=evaluation_id).first_or_404() + 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) diff --git a/app/api/formations.py b/app/api/formations.py index a851647a..44438b12 100644 --- a/app/api/formations.py +++ b/app/api/formations.py @@ -8,43 +8,58 @@ ScoDoc 9 API : accès aux formations """ -from flask import jsonify +from flask import g, jsonify +from flask_login import login_required import app -from app import models -from app.api import bp +from app.api import api_bp as bp, api_web_bp from app.api.errors import error_response -from app.api.auth import permission_required_api +from app.decorators import scodoc, permission_required from app.models.formations import Formation +from app.models.formsemestre import FormSemestre +from app.models.moduleimpls import ModuleImpl from app.scodoc import sco_formations from app.scodoc.sco_permissions import Permission -@bp.route("/formations", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formations") +@api_web_bp.route("/formations") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formations(): """ Retourne la liste de toutes les formations (tous départements) - """ - data = [d.to_dict() for d in models.Formation.query] - return jsonify(data) + query = Formation.query + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + + return jsonify([d.to_dict() for d in query]) -@bp.route("/formations_ids", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formations_ids") +@api_web_bp.route("/formations_ids") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formations_ids(): """ Retourne la liste de toutes les id de formations (tous départements) Exemple de résultat : [ 17, 99, 32 ] """ - data = [d.id for d in models.Formation.query] - return jsonify(data) + query = Formation.query + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + return jsonify([d.id for d in query]) -@bp.route("/formation/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formation/") +@api_web_bp.route("/formation/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formation_by_id(formation_id: int): """ La formation d'id donné @@ -66,21 +81,31 @@ def formation_by_id(formation_id: int): "formation_id": 1 } """ - formation = models.Formation.query.get_or_404(formation_id) - return jsonify(formation.to_dict()) + query = Formation.query.filter_by(id=formation_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + return jsonify(query.first_or_404().to_dict()) @bp.route( - "/formation/formation_export/", - methods=["GET"], + "/formation//export", defaults={"export_ids": False}, ) @bp.route( - "/formation/formation_export//with_ids", - methods=["GET"], + "/formation//export/with_ids", defaults={"export_ids": True}, ) -@permission_required_api(Permission.ScoView, Permission.APIView) +@api_web_bp.route( + "/formation//export", + defaults={"export_ids": False}, +) +@api_web_bp.route( + "/formation//export/with_ids", + defaults={"export_ids": True}, +) +@login_required +@scodoc +@permission_required(Permission.ScoView) def formation_export_by_formation_id(formation_id: int, export_ids=False): """ Retourne la formation, avec UE, matières, modules @@ -177,7 +202,10 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False): ] } """ - formation = Formation.query.get_or_404(formation_id) + query = Formation.query.filter_by(id=formation_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formation = query.first_or_404(formation_id) app.set_sco_dept(formation.departement.acronym) try: data = sco_formations.formation_export(formation_id, export_ids) @@ -187,11 +215,36 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False): return jsonify(data) -@bp.route("/formation/moduleimpl/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formation//referentiel_competences") +@api_web_bp.route("/formation//referentiel_competences") +@login_required +@scodoc +@permission_required(Permission.ScoView) +def referentiel_competences(formation_id: int): + """ + Retourne le référentiel de compétences + + formation_id : l'id d'une formation + + return null si pas de référentiel associé. + """ + query = Formation.query.filter_by(id=formation_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formation = query.first_or_404(formation_id) + if formation.referentiel_competence is None: + return jsonify(None) + return jsonify(formation.referentiel_competence.to_dict()) + + +@bp.route("/moduleimpl/") +@api_web_bp.route("/moduleimpl/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def moduleimpl(moduleimpl_id: int): """ - Retourne un module moduleimpl en fonction de son id + Retourne un moduleimpl en fonction de son id moduleimpl_id : l'id d'un moduleimpl @@ -224,24 +277,8 @@ def moduleimpl(moduleimpl_id: int): } } """ - modimpl = models.ModuleImpl.query.get_or_404(moduleimpl_id) + 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 = query.first_or_404() return jsonify(modimpl.to_dict()) - - -@bp.route( - "/formation//referentiel_competences", - methods=["GET"], -) -@permission_required_api(Permission.ScoView, Permission.APIView) -def referentiel_competences(formation_id: int): - """ - Retourne le référentiel de compétences - - formation_id : l'id d'une formation - - return null si pas de référentiel associé. - """ - formation = models.Formation.query.get_or_404(formation_id) - if formation.referentiel_competence is None: - return jsonify(None) - return jsonify(formation.referentiel_competence.to_dict()) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 25286f5c..b0ff13fb 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -7,17 +7,23 @@ """ ScoDoc 9 API : accès aux formsemestres """ -from flask import abort, jsonify, request +from flask import g, jsonify, request +from flask_login import login_required import app -from app import models -from app.api import bp -from app.api.auth import permission_required_api +from app.api import api_bp as bp, api_web_bp +from app.decorators import scodoc, permission_required from app.api.errors import error_response from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults from app.comp.res_compat import NotesTableCompat -from app.models import Evaluation, FormSemestre, FormSemestreEtape, ModuleImpl +from app.models import ( + Departement, + Evaluation, + FormSemestre, + FormSemestreEtape, + ModuleImpl, +) from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json from app.scodoc import sco_groups from app.scodoc.sco_permissions import Permission @@ -25,8 +31,11 @@ from app.scodoc.sco_utils import ModuleType import app.scodoc.sco_utils as scu -@bp.route("/formsemestre/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre/") +@api_web_bp.route("/formsemestre/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestre_infos(formsemestre_id: int): """ Information sur le formsemestre indiqué. @@ -64,12 +73,18 @@ def formsemestre_infos(formsemestre_id: int): } """ - formsemestre: FormSemestre = models.FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) return jsonify(formsemestre.to_dict_api()) -@bp.route("/formsemestres/query", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestres/query") +@api_web_bp.route("/formsemestres/query") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestres_query(): """ Retourne les formsemestres filtrés par @@ -85,6 +100,8 @@ def formsemestres_query(): dept_acronym = request.args.get("dept_acronym") dept_id = request.args.get("dept_id") formsemestres = FormSemestre.query + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) if etape_apo is not None: formsemestres = formsemestres.join(FormSemestreEtape).filter( FormSemestreEtape.etape_apo == etape_apo @@ -100,9 +117,7 @@ def formsemestres_query(): FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee ) if dept_acronym is not None: - formsemestres = formsemestres.join(models.Departement).filter_by( - acronym=dept_acronym - ) + formsemestres = formsemestres.join(Departement).filter_by(acronym=dept_acronym) if dept_id is not None: try: dept_id = int(dept_id) @@ -113,8 +128,11 @@ def formsemestres_query(): return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres]) -@bp.route("/formsemestre//bulletins", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre//bulletins") +@api_web_bp.route("/formsemestre//bulletins") +@login_required +@scodoc +@permission_required(Permission.ScoView) def bulletins(formsemestre_id: int): """ Retourne les bulletins d'un formsemestre donné @@ -123,7 +141,10 @@ def bulletins(formsemestre_id: int): Exemple de résultat : liste, voir https://scodoc.org/ScoDoc9API/#bulletin """ - formsemestre = models.FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) data = [] @@ -134,11 +155,11 @@ def bulletins(formsemestre_id: int): return jsonify(data) -@bp.route( - "/formsemestre//programme", - methods=["GET"], -) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre//programme") +@api_web_bp.route("/formsemestre//programme") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestre_programme(formsemestre_id: int): """ Retourne la liste des Ues, ressources et SAE d'un semestre @@ -204,7 +225,10 @@ def formsemestre_programme(formsemestre_id: int): "modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ] } """ - formsemestre: FormSemestre = models.FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) ues = formsemestre.query_ues() m_list = { ModuleType.RESSOURCE: [], @@ -226,29 +250,41 @@ def formsemestre_programme(formsemestre_id: int): @bp.route( "/formsemestre//etudiants", - methods=["GET"], defaults={"etat": scu.INSCRIT}, ) @bp.route( "/formsemestre//etudiants/demissionnaires", - methods=["GET"], defaults={"etat": scu.DEMISSION}, ) @bp.route( "/formsemestre//etudiants/defaillants", - methods=["GET"], defaults={"etat": scu.DEF}, ) -@permission_required_api(Permission.ScoView, Permission.APIView) +@api_web_bp.route( + "/formsemestre//etudiants", + defaults={"etat": scu.INSCRIT}, +) +@api_web_bp.route( + "/formsemestre//etudiants/demissionnaires", + defaults={"etat": scu.DEMISSION}, +) +@api_web_bp.route( + "/formsemestre//etudiants/defaillants", + defaults={"etat": scu.DEF}, +) +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestre_etudiants(formsemestre_id: int, etat: str): """ Retourne la liste des étudiants d'un formsemestre formsemestre_id : l'id d'un formsemestre """ - formsemestre: FormSemestre = models.FormSemestre.query.filter_by( - id=formsemestre_id - ).first_or_404() + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) inscriptions = [ins for ins in formsemestre.inscriptions if ins.etat == etat] etuds = [ins.etud.to_dict_short() for ins in inscriptions] @@ -260,8 +296,11 @@ def formsemestre_etudiants(formsemestre_id: int, etat: str): return jsonify(etuds) -@bp.route("/formsemestre//etat_evals", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre//etat_evals") +@api_web_bp.route("/formsemestre//etat_evals") +@login_required +@scodoc +@permission_required(Permission.ScoView) def etat_evals(formsemestre_id: int): """ Informations sur l'état des évaluations d'un formsemestre. @@ -297,7 +336,10 @@ def etat_evals(formsemestre_id: int): }, ] """ - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) @@ -364,8 +406,11 @@ def etat_evals(formsemestre_id: int): return jsonify(result) -@bp.route("/formsemestre//resultats", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre//resultats") +@api_web_bp.route("/formsemestre//resultats") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestre_resultat(formsemestre_id: int): """Tableau récapitulatif des résultats Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules. @@ -375,7 +420,10 @@ def formsemestre_resultat(formsemestre_id: int): return error_response(404, "invalid format specification") convert_values = format_spec != "raw" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) rows, footer_rows, titles, column_ids = res.get_table_recap( diff --git a/app/api/jury.py b/app/api/jury.py index 1f42abff..6a911c6c 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -2,9 +2,8 @@ # from flask import jsonify # from app import models -# from app.api import bp +# from app.api import api_bp as bp # from app.api.errors import error_response -# from app.api.auth import permission_required_api # from app.scodoc.sco_prepajury import feuille_preparation_jury # from app.scodoc.sco_pvjury import formsemestre_pvjury diff --git a/app/api/logos.py b/app/api/logos.py index 9fd9cade..5a3a6ab3 100644 --- a/app/api/logos.py +++ b/app/api/logos.py @@ -31,19 +31,22 @@ Contrib @jmp from datetime import datetime from flask import jsonify, g, send_file +from flask_login import login_required -from app.api import bp +from app.api import api_bp as bp, api_web_bp from app.api import requested_format -from app.api.auth import token_auth from app.api.errors import error_response from app.models import Departement from app.scodoc.sco_logos import list_logos, find_logo -from app.api.auth import permission_required_api +from app.decorators import scodoc, permission_required from app.scodoc.sco_permissions import Permission +# Note: l'API logos n'est accessible qu'en mode global (avec jeton, sans dept) -@bp.route("/logos", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) + +@bp.route("/logos") +@scodoc +@permission_required(Permission.ScoView) def api_get_glob_logos(): if not g.current_user.has_permission(Permission.ScoSuperAdmin, None): return error_response(401, message="accès interdit") @@ -54,8 +57,9 @@ def api_get_glob_logos(): return jsonify(list(logos.keys())) -@bp.route("/logos/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/logos/") +@scodoc +@permission_required(Permission.ScoView) def api_get_glob_logo(logoname): if not g.current_user.has_permission(Permission.ScoSuperAdmin, None): return error_response(401, message="accès interdit") @@ -70,8 +74,9 @@ def api_get_glob_logo(logoname): ) -@bp.route("/departements//logos", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/departements//logos") +@scodoc +@permission_required(Permission.ScoView) def api_get_local_logos(departement): dept_id = Departement.from_acronym(departement).id if not g.current_user.has_permission(Permission.ScoChangePreferences, departement): @@ -80,8 +85,9 @@ def api_get_local_logos(departement): return jsonify(list(logos.keys())) -@bp.route("/departements//logos/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/departements//logos/") +@scodoc +@permission_required(Permission.ScoView) def api_get_local_logo(departement, logoname): # format = requested_format("jpg", ['png', 'jpg']) XXX ? dept_id = Departement.from_acronym(departement).id diff --git a/app/api/partitions.py b/app/api/partitions.py index c55ef911..fcb6013c 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -7,12 +7,14 @@ """ ScoDoc 9 API : partitions """ -from flask import jsonify, request +from flask import g, jsonify, request +from flask_login import login_required import app from app import db, log -from app.api import bp -from app.api.auth import permission_required_api +from app import api +from app.api import api_bp as bp, api_web_bp +from app.decorators import scodoc, permission_required from app.api.errors import error_response from app.models import FormSemestre, FormSemestreInscription, Identite from app.models import GroupDescr, Partition @@ -22,8 +24,10 @@ from app.scodoc.sco_permissions import Permission from app.scodoc import sco_utils as scu -@bp.route("/partition/", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/partition/") +@login_required +@scodoc +@permission_required(Permission.ScoView) def partition_info(partition_id: int): """Info sur une partition. @@ -44,12 +48,18 @@ def partition_info(partition_id: int): } ``` """ - partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition = query.first_or_404() return jsonify(partition.to_dict(with_groups=True)) -@bp.route("/formsemestre//partitions", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/formsemestre//partitions") +@api_web_bp.route("/formsemestre//partitions") +@login_required +@scodoc +@permission_required(Permission.ScoView) def formsemestre_partitions(formsemestre_id: int): """Liste de toutes les partitions d'un formsemestre @@ -70,7 +80,10 @@ def formsemestre_partitions(formsemestre_id: int): } """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) partitions = sorted(formsemestre.partitions, key=lambda p: p.numero or 0) return jsonify( { @@ -81,8 +94,11 @@ def formsemestre_partitions(formsemestre_id: int): ) -@bp.route("/group//etudiants", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/group//etudiants") +@api_web_bp.route("/group//etudiants") +@login_required +@scodoc +@permission_required(Permission.ScoView) def etud_in_group(group_id: int): """ Retourne la liste des étudiants dans un groupe @@ -103,18 +119,31 @@ def etud_in_group(group_id: int): ... ] """ - group = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + ) + group = query.first_or_404() return jsonify([etud.to_dict_short() for etud in group.etuds]) -@bp.route("/group//etudiants/query", methods=["GET"]) -@permission_required_api(Permission.ScoView, Permission.APIView) +@bp.route("/group//etudiants/query") +@api_web_bp.route("/group//etudiants/query") +@login_required +@scodoc +@permission_required(Permission.ScoView) def etud_in_group_query(group_id: int): """Etudiants du groupe, filtrés par état""" etat = request.args.get("etat") if etat not in {scu.INSCRIT, scu.DEMISSION, scu.DEF}: return error_response(404, "etat: valeur invalide") - group = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + ) + group = query.first_or_404() # just tro ckeck that group exists in accessible dept query = ( Identite.query.join(FormSemestreInscription) .filter_by(formsemestre_id=group.partition.formsemestre_id, etat=etat) @@ -126,11 +155,21 @@ def etud_in_group_query(group_id: int): @bp.route("/group//set_etudiant/", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/group//set_etudiant/", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def set_etud_group(etudid: int, group_id: int): """Affecte l'étudiant au groupe indiqué""" etud = Identite.query.get_or_404(etudid) - group = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition) + .join(FormSemestre) + .filter_by(dept_id=group.scodoc_dept_id) + ) + group = query.first_or_404() if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: return error_response(404, "etud non inscrit au formsemestre du groupe") groups = ( @@ -139,11 +178,11 @@ def set_etud_group(etudid: int, group_id: int): .filter_by(etudid=etudid) ) ok = False - for g in groups: - if g.id == group_id: + for group in groups: + if group.id == group_id: ok = True else: - g.etuds.remove(etud) + group.etuds.remove(etud) if not ok: group.etuds.append(etud) log(f"set_etud_group({etud}, {group})") @@ -153,11 +192,21 @@ def set_etud_group(etudid: int, group_id: int): @bp.route("/group//remove_etudiant/", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route( + "/group//remove_etudiant/", methods=["POST"] +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def group_remove_etud(group_id: int, etudid: int): """Retire l'étudiant de ce groupe. S'il n'y est pas, ne fait rien.""" etud = Identite.query.get_or_404(etudid) - group = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + ) + group = query.first_or_404() group.etuds.remove(etud) db.session.commit() sco_cache.invalidate_formsemestre(group.partition.formsemestre_id) @@ -167,20 +216,28 @@ def group_remove_etud(group_id: int, etudid: int): @bp.route( "/partition//remove_etudiant/", methods=["POST"] ) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route( + "/partition//remove_etudiant/", methods=["POST"] +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def partition_remove_etud(partition_id: int, etudid: int): """Enlève l'étudiant de tous les groupes de cette partition (NB: en principe, un étudiant ne doit être que dans 0 ou 1 groupe d'une partition) """ etud = Identite.query.get_or_404(etudid) - partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition = query.first_or_404() groups = ( GroupDescr.query.filter_by(partition_id=partition_id) .join(group_membership) .filter_by(etudid=etudid) ) - for g in groups: - g.etuds.remove(etud) + for group in groups: + group.etuds.remove(etud) db.session.commit() app.set_sco_dept(partition.formsemestre.departement.acronym) sco_cache.invalidate_formsemestre(partition.formsemestre_id) @@ -188,7 +245,10 @@ def partition_remove_etud(partition_id: int, etudid: int): @bp.route("/partition//group/create", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/partition//group/create", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def group_create(partition_id: int): """Création d'un groupe dans une partition @@ -197,7 +257,10 @@ def group_create(partition_id: int): "group_name" : nom_du_groupe, } """ - partition: Partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition: Partition = query.first_or_404() if not partition.groups_editable: return error_response(404, "partition non editable") data = request.get_json(force=True) # may raise 400 Bad Request @@ -218,10 +281,18 @@ def group_create(partition_id: int): @bp.route("/group//delete", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/group//delete", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def group_delete(group_id: int): """Suppression d'un groupe""" - group = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + ) + group: GroupDescr = query.first_or_404() if not group.partition.groups_editable: return error_response(404, "partition non editable") formsemestre_id = group.partition.formsemestre_id @@ -234,10 +305,18 @@ def group_delete(group_id: int): @bp.route("/group//edit", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/group//edit", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def group_edit(group_id: int): """Edit a group""" - group: GroupDescr = GroupDescr.query.get_or_404(group_id) + query = GroupDescr.query.filter_by(id=group_id) + if g.scodoc_dept: + query = ( + query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + ) + group: GroupDescr = query.first_or_404() if not group.partition.groups_editable: return error_response(404, "partition non editable") data = request.get_json(force=True) # may raise 400 Bad Request @@ -255,7 +334,12 @@ def group_edit(group_id: int): @bp.route("/formsemestre//partition/create", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route( + "/formsemestre//partition/create", methods=["POST"] +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def partition_create(formsemestre_id: int): """Création d'une partition dans un semestre @@ -268,7 +352,10 @@ def partition_create(formsemestre_id: int): "groups_editable":bool } """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) data = request.get_json(force=True) # may raise 400 Bad Request partition_name = data.get("partition_name") if partition_name is None: @@ -301,12 +388,20 @@ def partition_create(formsemestre_id: int): @bp.route("/formsemestre//partitions/order", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route( + "/formsemestre//partitions/order", methods=["POST"] +) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def formsemestre_order_partitions(formsemestre_id: int): """Modifie l'ordre des partitions du formsemestre JSON args: [partition_id1, partition_id2, ...] """ - formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + query = FormSemestre.query.filter_by(id=formsemestre_id) + if g.scodoc_dept: + query = query.filter_by(dept_id=g.scodoc_dept_id) + formsemestre: FormSemestre = query.first_or_404(formsemestre_id) partition_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(partition_ids, int) and not all( isinstance(x, int) for x in partition_ids @@ -326,12 +421,18 @@ def formsemestre_order_partitions(formsemestre_id: int): @bp.route("/partition//groups/order", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/partition//groups/order", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def partition_order_groups(partition_id: int): """Modifie l'ordre des groupes de la partition JSON args: [group_id1, group_id2, ...] """ - partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition: Partition = query.first_or_404() group_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(group_ids, int) and not all( isinstance(x, int) for x in group_ids @@ -351,7 +452,10 @@ def partition_order_groups(partition_id: int): @bp.route("/partition//edit", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/partition//edit", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def partition_edit(partition_id: int): """Modification d'une partition dans un semestre @@ -365,7 +469,10 @@ def partition_edit(partition_id: int): "groups_editable":bool } """ - partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition: Partition = query.first_or_404() data = request.get_json(force=True) # may raise 400 Bad Request modified = False partition_name = data.get("partition_name") @@ -403,7 +510,10 @@ def partition_edit(partition_id: int): @bp.route("/partition//delete", methods=["POST"]) -@permission_required_api(Permission.ScoEtudChangeGroups, Permission.APIEditGroups) +@api_web_bp.route("/partition//delete", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEtudChangeGroups) def partition_delete(partition_id: int): """Suppression d'une partition (et de tous ses groupes). @@ -412,7 +522,10 @@ def partition_delete(partition_id: int): Note 2: Si la partition de parcours est supprimée, les étudiants sont désinscrits des parcours. """ - partition = Partition.query.get_or_404(partition_id) + query = Partition.query.filter_by(id=partition_id) + if g.scodoc_dept: + query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) + partition: Partition = query.first_or_404() if not partition.partition_name: return error_response(404, "ne peut pas supprimer la partition par défaut") is_parcours = partition.is_parcours() diff --git a/app/api/tokens.py b/app/api/tokens.py index f5c11b09..de190a59 100644 --- a/app/api/tokens.py +++ b/app/api/tokens.py @@ -1,7 +1,7 @@ from flask import jsonify from app import db, log -from app.api import bp -from app.api.auth import basic_auth, token_auth +from app.api import api_bp as bp +from app.auth.logic import basic_auth, token_auth @bp.route("/tokens", methods=["POST"]) diff --git a/app/api/tools.py b/app/api/tools.py index 9da42fab..a471cb88 100644 --- a/app/api/tools.py +++ b/app/api/tools.py @@ -26,9 +26,7 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite: Return None si étudiant inexistant. """ - allowed_depts = current_user.get_depts_with_permission( - Permission.APIView | Permission.ScoView - ) + allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) if etudid is not None: etud: Identite = Identite.query.get(etudid) diff --git a/app/auth/logic.py b/app/auth/logic.py new file mode 100644 index 00000000..1d1e8b73 --- /dev/null +++ b/app/auth/logic.py @@ -0,0 +1,87 @@ +# -*- coding: UTF-8 -* + +"""app.auth.logic.py +""" +import http + +import flask +from flask import g, redirect, request, url_for +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth +import flask_login +from app import login +from app.api.errors import error_response +from app.auth.models import User + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + + +@basic_auth.verify_password +def verify_password(username, password): + """Verify password for this user + Appelé lors d'une demande de jeton (normalement via la route /tokens) + """ + user: User = User.query.filter_by(user_name=username).first() + if user and user.check_password(password): + g.current_user = user + # note: est aussi basic_auth.current_user() + return user + + +@basic_auth.error_handler +def basic_auth_error(status): + "error response (401 for invalid auth.)" + return error_response(status) + + +@login.user_loader +def load_user(uid: str) -> User: + "flask-login: accès à un utilisateur" + return User.query.get(int(uid)) + + +@token_auth.verify_token +def verify_token(token) -> User: + """Retrouve l'utilisateur à partir du jeton. + Si la requête n'a pas de jeton, token == "". + """ + user = User.check_token(token) if token else None + if user is not None: + flask_login.login_user(user) + g.current_user = user + return user + + +@token_auth.error_handler +def token_auth_error(status): + "Réponse en cas d'erreur d'auth." + return error_response(status) + + +@token_auth.get_user_roles +def get_user_roles(user): + return user.roles + + +@login.request_loader +def load_user_from_request(req: flask.Request) -> User: + """Custom Login using Request Loader""" + # Try token + try: + auth_type, token = req.headers["Authorization"].split(None, 1) + except (ValueError, KeyError): + # The Authorization header is either empty or has no token + return None + if auth_type == "Bearer" and token: + return verify_token(token) + return None + + +@login.unauthorized_handler +def unauthorized(): + "flask-login: si pas autorisé, redirige vers page login, sauf si API" + from app.api.errors import error_response as api_error_response + + if request.blueprint == "api" or request.blueprint == "apiweb": + return api_error_response(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)") + return redirect(url_for("auth.login")) diff --git a/app/auth/models.py b/app/auth/models.py index c5b06444..78d4cda0 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -11,6 +11,7 @@ from time import time from typing import Optional import cracklib # pylint: disable=import-error + from flask import current_app, g from flask_login import UserMixin, AnonymousUserMixin @@ -523,8 +524,3 @@ def get_super_admin(): ) assert admin_user return admin_user - - -@login.user_loader -def load_user(uid): - return User.query.get(int(uid)) diff --git a/app/auth/routes.py b/app/auth/routes.py index 16df3135..6263ef35 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -3,11 +3,8 @@ auth.routes.py """ -from app.scodoc.sco_exceptions import ScoValueError -from flask import current_app, g, flash, render_template +from flask import current_app, flash, render_template from flask import redirect, url_for, request -from flask_login.utils import login_required -from werkzeug.urls import url_parse from flask_login import login_user, logout_user, current_user from app import db @@ -17,13 +14,11 @@ from app.auth.forms import ( UserCreationForm, ResetPasswordRequestForm, ResetPasswordForm, - DeactivateUserForm, ) from app.auth.models import Role from app.auth.models import User from app.auth.email import send_password_reset_email from app.decorators import admin_required -from app.decorators import permission_required _ = lambda x: x # sans babel _l = _ @@ -31,6 +26,7 @@ _l = _ @bp.route("/login", methods=["GET", "POST"]) def login(): + "ScoDoc Login form" if current_user.is_authenticated: return redirect(url_for("scodoc.index")) form = LoginForm() @@ -42,9 +38,6 @@ def login(): return redirect(url_for("auth.login")) login_user(user, remember=form.remember_me.data) current_app.logger.info("login: success (%s)", form.user_name.data) - # next_page = request.args.get("next") - # if not next_page or url_parse(next_page).netloc != "": - # next_page = url_for("scodoc.index") return form.redirect("scodoc.index") message = request.args.get("message", "") return render_template( @@ -54,6 +47,7 @@ def login(): @bp.route("/logout") def logout(): + "Logout current user and redirect to home page" logout_user() return redirect(url_for("scodoc.index")) @@ -109,9 +103,10 @@ def reset_password_request(): @bp.route("/reset_password/", methods=["GET", "POST"]) def reset_password(token): + "Reset passord après demande par mail" if current_user.is_authenticated: return redirect(url_for("scodoc.index")) - user = User.verify_reset_password_token(token) + user: User = User.verify_reset_password_token(token) if user is None: return redirect(url_for("scodoc.index")) form = ResetPasswordForm() @@ -126,6 +121,7 @@ def reset_password(token): @bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"]) @admin_required def reset_standard_roles_permissions(): + "Réinitialise (recrée au besoin) les rôles standards de ScoDoc et leurs permissions" Role.reset_standard_roles_permissions() - flash("rôles standard réinitialisés !") + flash("rôles standards réinitialisés !") return redirect(url_for("scodoc.configuration")) diff --git a/app/models/formations.py b/app/models/formations.py index 224b8475..167fef04 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -64,6 +64,7 @@ class Formation(db.Model): def to_dict(self): e = dict(self.__dict__) e.pop("_sa_instance_state", None) + e["departement"] = self.departement.to_dict() # ScoDoc7 output_formators: (backward compat) e["formation_id"] = self.id return e diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index 53dfcf97..b534b023 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -12,6 +12,7 @@ _SCO_PERMISSIONS = ( # - ZScoDoc: add/delete departments # - tous rôles lors creation utilisateurs (1 << 1, "ScoSuperAdmin", "Super Administrateur"), + (1 << 2, "APIView", "Voir"), # deprecated (1 << 2, "ScoView", "Voir"), (1 << 3, "ScoEnsView", "Voir les parties pour les enseignants"), (1 << 4, "ScoObservateur", "Observer (accès lecture restreint aux bulletins)"), @@ -50,7 +51,7 @@ _SCO_PERMISSIONS = ( (1 << 27, "RelationsEntreprisesCorrespondants", "Voir les correspondants"), # 27 à 39 ... réservé pour "entreprises" # Api scodoc9 - (1 << 40, "APIView", "API: Lecture"), + # XXX à revoir (1 << 41, "APIEditGroups", "API: Modifier les groupes"), (1 << 42, "APIEditAllNotes", "API: Modifier toutes les notes"), (1 << 43, "APIAbsChange", "API: Saisir des absences"), diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py index 69e237fd..28d2be9c 100644 --- a/app/scodoc/sco_roles_default.py +++ b/app/scodoc/sco_roles_default.py @@ -53,7 +53,6 @@ SCO_ROLES_DEFAULTS = { p.ScoUsersAdmin, p.ScoUsersView, p.ScoView, - p.APIView, ), # RespPE est le responsable poursuites d'études # il peut ajouter des tags sur les formations: @@ -78,7 +77,7 @@ SCO_ROLES_DEFAULTS = { p.RelationsEntreprisesCorrespondants, ), # LecteurAPI peut utiliser l'API en lecture - "LecteurAPI": (p.APIView,), + "LecteurAPI": (p.ScoView,), # Super Admin est un root: création/suppression de départements # _tous_ les droits # Afin d'avoir tous les droits, il ne doit pas être asscoié à un département diff --git a/tests/api/exemple-api-basic.py b/tests/api/exemple-api-basic.py index c889909a..1c9b67cb 100644 --- a/tests/api/exemple-api-basic.py +++ b/tests/api/exemple-api-basic.py @@ -55,9 +55,13 @@ class ScoError(Exception): pass -def GET(path: str, headers={}, errmsg=None): +def GET(path: str, headers={}, errmsg=None, dept=None): """Get and returns as JSON""" - r = requests.get(API_URL + path, headers=headers or HEADERS, verify=CHK_CERT) + if dept: + url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path + else: + url = API_URL + path + r = requests.get(url, headers=headers or HEADERS, verify=CHK_CERT) if r.status_code != 200: raise ScoError(errmsg or f"""erreur status={r.status_code} !\n{r.text}""") return r.json() # decode la reponse JSON @@ -170,6 +174,11 @@ POST_JSON(f"/group/5559/delete") POST_JSON(f"/group/5327/edit", data={"group_name": "TDXXX"}) # --------- XXX à passer en dans les tests unitaires + +# 0- Prend un étudiant au hasard dans le semestre +etud = GET(f"/formsemestre/{formsemestre_id}/etudiants")[10] +etudid = etud["id"] + # 1- Crée une partition, puis la change de nom js = POST_JSON( f"/formsemestre/{formsemestre_id}/partition/create", @@ -182,21 +191,58 @@ POST_JSON( ) # 2- Crée un groupe -js = POST_JSON(f"/partition/{partition_id}/group/create", data={"group_name": "GG"}) -group_id = js["id"] -# Prend un étudiant au hasard dans le semestre -etud = GET(f"/formsemestre/{formsemestre_id}/etudiants")[10] -etudid = etud["id"] -# 3- Affecte étudiant au groupe -POST_JSON(f"/group/{group_id}/set_etudiant/{etudid}") +js = POST_JSON(f"/partition/{partition_id}/group/create", data={"group_name": "G1"}) +group_1 = js["id"] -# 4- retire du groupe -POST_JSON(f"/group/{group_id}/remove_etudiant/{etudid}") +# 3- Crée deux autres groupes +js = POST_JSON(f"/partition/{partition_id}/group/create", data={"group_name": "G2"}) +js = POST_JSON(f"/partition/{partition_id}/group/create", data={"group_name": "G3"}) -# 5- Suppression +# 4- Affecte étudiant au groupe G1 +POST_JSON(f"/group/{group_1}/set_etudiant/{etudid}") + +# 5- retire du groupe +POST_JSON(f"/group/{group_1}/remove_etudiant/{etudid}") + +# 6- affecte au groupe G2 +partition = GET(f"/partition/{partition_id}") +assert len(partition["groups"]) == 3 +group_2 = [g for g in partition["groups"].values() if g["name"] == "G2"][0]["id"] +POST_JSON(f"/group/{group_2}/set_etudiant/{etudid}") + +# 7- Membres du groupe +etuds_g2 = GET(f"/group/{group_2}/etudiants") +assert len(etuds_g2) == 1 +assert etuds_g2[0]["id"] == etudid + +# 8- Ordres des groupes +group_3 = [g for g in partition["groups"].values() if g["name"] == "G3"][0]["id"] + +POST_JSON( + f"/partition/{partition_id}/groups/order", + data=[group_2, group_1, group_3], +) + +new_groups = [g["id"] for g in GET(f"/partition/{partition_id}")["groups"].values()] +assert new_groups == [group_2, group_1, group_3] + +# 9- Suppression POST_JSON(f"/partition/{partition_id}/delete") # ------ +# Tests accès API: +""" + * En mode API: + Avec admin: + - GET, POST ci-dessus : OK + Avec user ayant ScoView (rôle LecteurAPI) + - idem + Avec user sans ScoView: + - GET et POST: erreur 403 + * En mode Web: + admin: GET + user : GET = 403 +""" # POST_JSON( diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index 2ba8a23d..960e5b42 100644 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -3,7 +3,7 @@ """Test permissions On a deux utilisateurs dans la base test API: - - "test", avec le rôle LecteurAPI qui a APIView, + - "test", avec le rôle LecteurAPI qui a la permission ScoView, - et "other", qui n'a aucune permission. @@ -23,7 +23,7 @@ from config import RunningConfig def test_permissions(api_headers): """ - vérification de la permissions APIView et du non accès sans role + vérification de la permissions ScoView et du non accès sans role de toutes les routes de l'API """ # Ce test va récupérer toutes les routes de l'API diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index c4ad78f8..a9a0f986 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -101,8 +101,8 @@ def create_users(dept: Departement) -> tuple: if role is None: print("Erreur: rôle LecteurAPI non existant") sys.exit(1) - perm_api_view = Permission.get_by_name("APIView") - role.add_permission(perm_api_view) + perm_sco_view = Permission.get_by_name("ScoView") + role.add_permission(perm_sco_view) db.session.add(role) user.add_role(role, None)