From d2f41b6a21cb58b7891f9d8a4bf6a1df2a402f19 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 28 Oct 2021 00:52:23 +0200 Subject: [PATCH] API scodoc7, exemple/test usage, progres sur l'API scodoc9 --- app/api/__init__.py | 1 + app/api/auth.py | 15 ++++ app/api/sco_api.py | 4 +- app/auth/models.py | 3 + app/decorators.py | 6 +- app/views/absences.py | 8 +- app/views/notes.py | 8 +- app/views/scolar.py | 2 +- misc/example-api-1.py | 133 --------------------------- tests/api/exemple-api-basic.py | 149 +++++++++++++++++++++++++++++++ tests/api/exemple-api-scodoc7.py | 144 +++++++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 146 deletions(-) delete mode 100644 misc/example-api-1.py create mode 100644 tests/api/exemple-api-basic.py create mode 100644 tests/api/exemple-api-scodoc7.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 34ebbc77a..956c1b46b 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -6,3 +6,4 @@ from flask import Blueprint bp = Blueprint("api", __name__) from app.api import sco_api +from app.api import tokens diff --git a/app/api/auth.py b/app/api/auth.py index 24348aab8..bb8464e0d 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -33,6 +33,7 @@ token_auth = HTTPTokenAuth() @basic_auth.verify_password def verify_password(username, password): + # breakpoint() user = User.query.filter_by(user_name=username).first() if user and user.check_password(password): return user @@ -51,3 +52,17 @@ def verify_token(token): @token_auth.error_handler def token_auth_error(status): return error_response(status) + + +def token_permission_required(permission): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + scodoc_dept = getattr(g, "scodoc_dept", None) + if not current_user.has_permission(permission, scodoc_dept): + abort(403) + return f(*args, **kwargs) + + return login_required(decorated_function) + + return decorator diff --git a/app/api/sco_api.py b/app/api/sco_api.py index e2619a0b0..c3ee74240 100644 --- a/app/api/sco_api.py +++ b/app/api/sco_api.py @@ -48,9 +48,9 @@ from app.api.errors import bad_request from app import models -@bp.route("/ScoDoc/api/list_depts", methods=["GET"]) +@bp.route("list_depts", methods=["GET"]) @token_auth.login_required def list_depts(): depts = models.Departement.query.filter_by(visible=True).all() - data = {"items": [d.to_dict() for d in depts]} + data = [d.to_dict() for d in depts] return jsonify(data) diff --git a/app/auth/models.py b/app/auth/models.py index f243f0e79..86ebdb83c 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -213,6 +213,9 @@ class User(UserMixin, db.Model): @staticmethod def check_token(token): + """Retreive user for given token, chek token's validity + and returns the user object. + """ user = User.query.filter_by(token=token).first() if user is None or user.token_expiration < datetime.utcnow(): return None diff --git a/app/decorators.py b/app/decorators.py index a688cb17b..ce94743e5 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -50,6 +50,7 @@ def scodoc(func): @wraps(func) def scodoc_function(*args, **kwargs): + # current_app.logger.info("@scodoc") # interdit les POST si pas loggué if request.method == "POST" and not current_user.is_authenticated: current_app.logger.info( @@ -71,6 +72,7 @@ def scodoc(func): # current_app.logger.info("setting dept to None") g.scodoc_dept = None g.scodoc_dept_id = -1 # invalide + return func(*args, **kwargs) return scodoc_function @@ -100,8 +102,8 @@ def permission_required_compat_scodoc7(permission): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): - # current_app.logger.warning("PERMISSION; kwargs=%s" % str(kwargs)) # cherche les paramètre d'auth: + # current_app.logger.info("@permission_required_compat_scodoc7") auth_ok = False if request.method == "GET": user_name = request.args.get("__ac_name") @@ -116,7 +118,6 @@ def permission_required_compat_scodoc7(permission): if u and u.check_password(user_password): auth_ok = True flask_login.login_user(u) - # reprend le chemin classique: scodoc_dept = getattr(g, "scodoc_dept", None) @@ -153,6 +154,7 @@ def scodoc7func(func): 2. or be called directly from Python. """ + # current_app.logger.info("@scodoc7func") # Détermine si on est appelé via une route ("toplevel") # ou par un appel de fonction python normal. top_level = not hasattr(g, "scodoc7_decorated") diff --git a/app/views/absences.py b/app/views/absences.py index c6fe5a1cf..f4cb15969 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -1047,8 +1047,8 @@ def EtatAbsencesDate(group_ids=[], date=None): # list of groups to display # ----- Gestion des "billets d'absence": signalement par les etudiants eux mêmes (à travers le portail) @bp.route("/AddBilletAbsence", methods=["GET", "POST"]) # API ScoDoc 7 compat -@permission_required_compat_scodoc7(Permission.ScoAbsAddBillet) @scodoc +@permission_required_compat_scodoc7(Permission.ScoAbsAddBillet) @scodoc7func def AddBilletAbsence( begin, @@ -1238,8 +1238,8 @@ def listeBilletsEtud(etudid=False, format="html"): @bp.route( "/XMLgetBilletsEtud", methods=["GET", "POST"] ) # pour compat anciens clients PHP -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def XMLgetBilletsEtud(etudid=False): """Liste billets pour un etudiant""" @@ -1464,8 +1464,8 @@ def ProcessBilletAbsenceForm(billet_id): # @bp.route("/essai_api7") -# @permission_required_compat_scodoc7(Permission.ScoView) # @scodoc +# @permission_required_compat_scodoc7(Permission.ScoView) # @scodoc7func # def essai_api7(x="xxx"): # "un essai" @@ -1474,8 +1474,8 @@ def ProcessBilletAbsenceForm(billet_id): @bp.route("/XMLgetAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def XMLgetAbsEtud(beg_date="", end_date=""): """returns list of absences in date interval""" diff --git a/app/views/notes.py b/app/views/notes.py index c549377bf..b3b2535de 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -264,10 +264,10 @@ sco_publish( @bp.route( - "formsemestre_bulletinetud", methods=["GET", "POST"] + "/formsemestre_bulletinetud", methods=["GET", "POST"] ) # POST pour compat anciens clients PHP (deprecated) -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def formsemestre_bulletinetud( etudid=None, @@ -642,8 +642,8 @@ sco_publish("/ue_move", sco_edit_formation.ue_move, Permission.ScoChangeFormatio @bp.route( "/formsemestre_list", methods=["GET", "POST"] ) # pour compat anciens clients PHP -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def formsemestre_list( format="json", @@ -669,8 +669,8 @@ def formsemestre_list( @bp.route( "/XMLgetFormsemestres", methods=["GET", "POST"] ) # pour compat anciens clients PHP -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def XMLgetFormsemestres(etape_apo=None, formsemestre_id=None): """List all formsemestres matching etape, XML format diff --git a/app/views/scolar.py b/app/views/scolar.py index 40c45a192..38f63f063 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -358,8 +358,8 @@ def search_etud_by_name(): @bp.route( "/Notes/XMLgetEtudInfos", methods=["GET", "POST"] ) # pour compat anciens clients PHP -@permission_required_compat_scodoc7(Permission.ScoView) @scodoc +@permission_required_compat_scodoc7(Permission.ScoView) @scodoc7func def etud_info(etudid=None, format="xml"): "Donne les informations sur un etudiant" diff --git a/misc/example-api-1.py b/misc/example-api-1.py deleted file mode 100644 index 37a06c56f..000000000 --- a/misc/example-api-1.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -"""Exemple connexion sur ScoDoc et utilisation de l'API - -- Ouverture session -- Liste semestres -- Liste modules -- Creation d'une évaluation -- Saisie d'une note - -Attention: cet exemple est en Python 3 (>= 3.6) -""" - -import requests -import urllib3 -import pdb -from pprint import pprint as pp -from flask import g, url_for - -# A modifier pour votre serveur: -CHECK_CERTIFICATE = False # set to True in production -BASEURL = "https://scodoc.xxx.net/ScoDoc/RT/Scolarite" -USER = "XXX" -PASSWORD = "XXX" - -# --- -if not CHECK_CERTIFICATE: - urllib3.disable_warnings() - - -class ScoError(Exception): - pass - - -def GET(s, path, errmsg=None): - """Get and returns as JSON""" - r = s.get(BASEURL + "/" + path, verify=CHECK_CERTIFICATE) - if r.status_code != 200: - raise ScoError(errmsg or "erreur !") - return r.json() # decode la reponse JSON - - -def POST(s, path, data, errmsg=None): - """Post""" - r = s.post(BASEURL + "/" + path, data=data, verify=CHECK_CERTIFICATE) - if r.status_code != 200: - raise ScoError(errmsg or "erreur !") - return r.text - - -# --- Ouverture session (login) -s = requests.Session() -s.post( - "https://deb11.viennet.net/api/auth/login", - data={"user_name": USER, "password": PASSWORD}, -) -r = s.get(BASEURL, auth=(USER, PASSWORD), verify=CHECK_CERTIFICATE) -if r.status_code != 200: - raise ScoError("erreur de connection: vérifier adresse et identifiants") - -# --- Recupere la liste de tous les semestres: -sems = GET(s, "Notes/formsemestre_list?format=json", "Aucun semestre !") - -# sems est une liste de semestres (dictionnaires) -for sem in sems: - if sem["etat"]: - break - -if sem["etat"] == "0": - raise ScoError("Aucun semestre non verrouillé !") - -# Affiche le semestre trouvé: -pp(sem) - -# ---- Récupère la description de ce semestre: -# semdescr = GET(s, f"Notes/formsemestre_description?formsemestre_id={sem['formsemestre_id']}&with_evals=0&format=json" ) - -# ---- Liste les modules et prend le premier -mods = GET(s, f"/Notes/moduleimpl_list?formsemestre_id={sem['formsemestre_id']}") -print(f"{len(mods)} modules dans le semestre {sem['titre']}") - -mod = mods[0] - -# ---- Etudiants inscrits dans ce module -inscrits = GET( - s, f"Notes/do_moduleimpl_inscription_list?moduleimpl_id={mod['moduleimpl_id']}" -) -print(f"{len(inscrits)} inscrits dans ce module") -# prend le premier inscrit, au hasard: -etudid = inscrits[0]["etudid"] - -# ---- Création d'une evaluation le dernier jour du semestre -jour = sem["date_fin"] -evaluation_id = POST( - s, - "/Notes/do_evaluation_create", - data={ - "moduleimpl_id": mod["moduleimpl_id"], - "coefficient": 1, - "jour": jour, # "5/9/2019", - "heure_debut": "9h00", - "heure_fin": "10h00", - "note_max": 20, # notes sur 20 - "description": "essai", - }, - errmsg="échec création évaluation", -) - -print( - f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}" -) -print( - "Pour vérifier, aller sur: ", - url_for( - "notes.moduleimpl_status", - scodoc_dept="DEPT", - moduleimpl_id=mod["moduleimpl_id"], - ), -) - -# ---- Saisie d'une note -junk = POST( - s, - "/Notes/save_note", - data={ - "etudid": etudid, - "evaluation_id": evaluation_id, - "value": 16.66, # la note ! - "comment": "test API", - }, -) diff --git a/tests/api/exemple-api-basic.py b/tests/api/exemple-api-basic.py new file mode 100644 index 000000000..529c379e5 --- /dev/null +++ b/tests/api/exemple-api-basic.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Exemple utilisation API ScoDoc 9 avec jeton obtenu par basic athentication + + +Utilisation: créer les variables d'environnement: (indiquer les valeurs +pour le serveur ScoDoc que vous voulez interroger) + +export SCODOC_URL="https://scodoc.xxx.net/" +export SCODOC_USER="xxx" +export SCODOC_PASSWD="xxx" +export CHECK_CERTIFICATE=0 # ou 1 si serveur de production avec certif SSL valide + +(on peut aussi placer ces valeurs dans un fichier .env du répertoire tests/api). + + +Travail en cours, un seul point d'API (list_depts). +""" + +from dotenv import load_dotenv +import os +import pdb +import requests +import urllib3 +from pprint import pprint as pp + +# --- Lecture configuration (variables d'env ou .env) +BASEDIR = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(BASEDIR, ".env")) +CHECK_CERTIFICATE = bool(int(os.environ.get("CHECK_CERTIFICATE", False))) +SCODOC_URL = os.environ["SCODOC_URL"] +SCODOC_DEPT = os.environ["SCODOC_DEPT"] +DEPT_URL = SCODOC_URL + "/ScoDoc/" + SCODOC_DEPT + "/Scolarite/" +SCODOC_USER = os.environ["SCODOC_USER"] +SCODOC_PASSWORD = os.environ["SCODOC_PASSWD"] +print(f"SCODOC_URL={SCODOC_URL}") + +# --- +if not CHECK_CERTIFICATE: + urllib3.disable_warnings() + + +class ScoError(Exception): + pass + + +def GET(path: str, headers={}, errmsg=None): + """Get and returns as JSON""" + r = requests.get( + DEPT_URL + "/" + path, headers=headers or HEADERS, verify=CHECK_CERTIFICATE + ) + if r.status_code != 200: + raise ScoError(errmsg or "erreur !") + return r.json() # decode la reponse JSON + + +def POST(s, path: str, data: dict, errmsg=None): + """Post""" + r = s.post(DEPT_URL + "/" + path, data=data, verify=CHECK_CERTIFICATE) + if r.status_code != 200: + raise ScoError(errmsg or "erreur !") + return r.text + + +# --- Obtention du jeton (token) +r = requests.post( + SCODOC_URL + "/ScoDoc/api/tokens", auth=(SCODOC_USER, SCODOC_PASSWORD) +) +assert r.status_code == 200 +token = r.json()["token"] +HEADERS = {"Authorization": f"Bearer {token}"} + +r = requests.get( + SCODOC_URL + "/ScoDoc/api/list_depts", headers=HEADERS, verify=CHECK_CERTIFICATE +) +if r.status_code != 200: + raise ScoError("erreur de connexion: vérifier adresse et identifiants") + +pp(r.json()) + + +# # --- Recupere la liste de tous les semestres: +# sems = GET(s, "Notes/formsemestre_list?format=json", "Aucun semestre !") + +# # sems est une liste de semestres (dictionnaires) +# for sem in sems: +# if sem["etat"]: +# break + +# if sem["etat"] == "0": +# raise ScoError("Aucun semestre non verrouillé !") + +# # Affiche le semestre trouvé: +# pp(sem) + +# # ---- Récupère la description de ce semestre: +# # semdescr = GET(s, f"Notes/formsemestre_description?formsemestre_id={sem['formsemestre_id']}&with_evals=0&format=json" ) + +# # ---- Liste les modules et prend le premier +# mods = GET(s, f"/Notes/moduleimpl_list?formsemestre_id={sem['formsemestre_id']}") +# print(f"{len(mods)} modules dans le semestre {sem['titre']}") + +# mod = mods[0] + +# # ---- Etudiants inscrits dans ce module +# inscrits = GET( +# s, f"Notes/do_moduleimpl_inscription_list?moduleimpl_id={mod['moduleimpl_id']}" +# ) +# print(f"{len(inscrits)} inscrits dans ce module") +# # prend le premier inscrit, au hasard: +# etudid = inscrits[0]["etudid"] + +# # ---- Création d'une evaluation le dernier jour du semestre +# jour = sem["date_fin"] +# evaluation_id = POST( +# s, +# "/Notes/do_evaluation_create", +# data={ +# "moduleimpl_id": mod["moduleimpl_id"], +# "coefficient": 1, +# "jour": jour, # "5/9/2019", +# "heure_debut": "9h00", +# "heure_fin": "10h00", +# "note_max": 20, # notes sur 20 +# "description": "essai", +# }, +# errmsg="échec création évaluation", +# ) + +# print( +# f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}" +# ) +# print( +# f"Pour vérifier, aller sur: {DEPT_URL}/Notes/moduleimpl_status?moduleimpl_id={mod['moduleimpl_id']}", +# ) + +# # ---- Saisie d'une note +# junk = POST( +# s, +# "/Notes/save_note", +# data={ +# "etudid": etudid, +# "evaluation_id": evaluation_id, +# "value": 16.66, # la note ! +# "comment": "test API", +# }, +# ) diff --git a/tests/api/exemple-api-scodoc7.py b/tests/api/exemple-api-scodoc7.py new file mode 100644 index 000000000..c3190717b --- /dev/null +++ b/tests/api/exemple-api-scodoc7.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Exemple connexion sur ScoDoc 9 et utilisation de l'ancienne API ScoDoc 7 +à la mode "PHP": les gens passaient directement __ac_name et __ac_password +dans chaque requête, en POST ou en GET. + +Cela n'a jamais été documenté mais était implitement supporté. C'est "deprecated" +et ne sera plus supporté à partir de juillet 2022. + +Ce script va tester: +- Liste semestres +- Liste modules +- Creation d'une évaluation +- Saisie d'une note + +Utilisation: créer les variables d'environnement: (indiquer les valeurs +pour le serveur ScoDoc que vous voulez interroger) + +export SCODOC_URL="https://scodoc.xxx.net/" +export SCODOC_USER="xxx" +export SCODOC_PASSWD="xxx" +export CHECK_CERTIFICATE=0 # ou 1 si serveur de production avec certif SSL valide + +(on peut aussi placer ces valeurs dans un fichier .env du répertoire tests/api). +""" + +from dotenv import load_dotenv +import os +import pdb +import requests +import urllib3 +from pprint import pprint as pp + +# --- Lecture configuration (variables d'env ou .env) +BASEDIR = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(BASEDIR, ".env")) +CHECK_CERTIFICATE = bool(int(os.environ.get("CHECK_CERTIFICATE", False))) +SCODOC_URL = os.environ["SCODOC_URL"] +SCODOC_DEPT = os.environ["SCODOC_DEPT"] +DEPT_URL = SCODOC_URL + "/ScoDoc/" + SCODOC_DEPT + "/Scolarite" +SCODOC_USER = os.environ["SCODOC_USER"] +SCODOC_PASSWORD = os.environ["SCODOC_PASSWD"] +print(f"SCODOC_URL={SCODOC_URL}") + +# --- +if not CHECK_CERTIFICATE: + urllib3.disable_warnings() + + +class ScoError(Exception): + pass + + +def GET(path: str, params=None, errmsg=None): + """Get and returns as JSON""" + # ajoute auth + params["__ac_name"] = SCODOC_USER + params["__ac_password"] = SCODOC_PASSWORD + r = requests.get(DEPT_URL + "/" + path, params=params, verify=CHECK_CERTIFICATE) + if r.status_code != 200: + raise ScoError(errmsg or "erreur !") + return r.json() # decode la reponse JSON + + +def POST(path: str, data: dict, errmsg=None): + """Post""" + data["__ac_name"] = SCODOC_USER + data["__ac_password"] = SCODOC_PASSWORD + r = requests.post(DEPT_URL + "/" + path, data=data, verify=CHECK_CERTIFICATE) + if r.status_code != 200: + raise ScoError(errmsg or "erreur !") + return r.text + + +# --- +# pas besoin d'ouvrir une session, on y va directement: + +# --- Recupere la liste de tous les semestres: +sems = GET("Notes/formsemestre_list", params={"format": "json"}) + +# sems est une liste de semestres (dictionnaires) +for sem in sems: + if sem["etat"]: + break + +if sem["etat"] == "0": + raise ScoError("Aucun semestre non verrouillé !") + +# Affiche le semestre trouvé: +pp(sem) + +# Les fonctions ci-dessous ne fonctionne plus en ScoDoc 9 +# Voir https://scodoc.org/git/viennet/ScoDoc/issues/149 + +# # ---- Liste les modules et prend le premier +# mods = GET("/Notes/moduleimpl_list", params={"formsemestre_id": sem["formsemestre_id"]}) +# print(f"{len(mods)} modules dans le semestre {sem['titre']}") + +# mod = mods[0] + +# # ---- Etudiants inscrits dans ce module +# inscrits = GET( +# "Notes/do_moduleimpl_inscription_list", +# params={"moduleimpl_id": mod["moduleimpl_id"]}, +# ) +# print(f"{len(inscrits)} inscrits dans ce module") +# # prend le premier inscrit, au hasard: +# etudid = inscrits[0]["etudid"] + +# # ---- Création d'une evaluation le dernier jour du semestre +# jour = sem["date_fin"] +# evaluation_id = POST( +# "/Notes/do_evaluation_create", +# data={ +# "moduleimpl_id": mod["moduleimpl_id"], +# "coefficient": 1, +# "jour": jour, # "5/9/2019", +# "heure_debut": "9h00", +# "heure_fin": "10h00", +# "note_max": 20, # notes sur 20 +# "description": "essai", +# }, +# errmsg="échec création évaluation", +# ) + +# print( +# f"Evaluation créée dans le module {mod['moduleimpl_id']}, evaluation_id={evaluation_id}" +# ) +# print( +# f"Pour vérifier, aller sur: {DEPT_URL}/Notes/moduleimpl_status?moduleimpl_id={mod['moduleimpl_id']}", +# ) + +# # ---- Saisie d'une note +# junk = POST( +# "/Notes/save_note", +# data={ +# "etudid": etudid, +# "evaluation_id": evaluation_id, +# "value": 16.66, # la note ! +# "comment": "test API", +# }, +# )