diff --git a/app/__init__.py b/app/__init__.py index 9d450cd7..a913f57e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -117,6 +117,7 @@ def create_app(config_class=DevConfig): from app.views import notes_bp from app.views import users_bp from app.views import absences_bp + from app.api import bp as api_bp # https://scodoc.fr/ScoDoc app.register_blueprint(scodoc_bp) @@ -130,6 +131,7 @@ def create_app(config_class=DevConfig): app.register_blueprint( absences_bp, url_prefix="/ScoDoc//Scolarite/Absences" ) + app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") scodoc_exc_formatter = RequestFormatter( "[%(asctime)s] %(remote_addr)s requested %(url)s\n" "%(levelname)s in %(module)s: %(message)s" @@ -190,9 +192,7 @@ def create_app(config_class=DevConfig): sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard) sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC) - app.logger.info( - f"registered bulletin classes {[ k for k in sco_bulletins_generator.BULLETIN_CLASSES ]}" - ) + return app diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 00000000..34ebbc77 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,8 @@ +"""api.__init__ +""" + +from flask import Blueprint + +bp = Blueprint("api", __name__) + +from app.api import sco_api diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 00000000..0226976c --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,53 @@ +# -*- coding: UTF-8 -* +# Authentication code borrowed from Miguel Grinberg's Mega Tutorial +# (see https://github.com/miguelgrinberg/microblog) + +# 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 flask_httpauth import HTTPBasicAuth, HTTPTokenAuth +from app.auth.models import User +from app.api.errors import error_response + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + + +@basic_auth.verify_password +def verify_password(username, password): + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + return user + + +@basic_auth.error_handler +def basic_auth_error(status): + return error_response(status) + + +@token_auth.verify_token +def verify_token(token): + return User.check_token(token) if token else None + + +@token_auth.error_handler +def token_auth_error(status): + return error_response(status) diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 00000000..ed8d0f3f --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,37 @@ +# Authentication code borrowed from Miguel Grinberg's Mega Tutorial +# (see https://github.com/miguelgrinberg/microblog) + +# 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 flask import jsonify +from werkzeug.http import HTTP_STATUS_CODES + + +def error_response(status_code, message=None): + payload = {"error": HTTP_STATUS_CODES.get(status_code, "Unknown error")} + if message: + payload["message"] = message + response = jsonify(payload) + response.status_code = status_code + return response + + +def bad_request(message): + return error_response(400, message) diff --git a/app/api/sco_api.py b/app/api/sco_api.py new file mode 100644 index 00000000..46be85a7 --- /dev/null +++ b/app/api/sco_api.py @@ -0,0 +1,56 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""API ScoDoc 9 +""" +# PAS ENCORE IMPLEMENTEE, juste un essai +# Pour P. Bouron, il faudrait en priorité l'équivalent de +# Scolarite/Notes/do_moduleimpl_withmodule_list +# Scolarite/Notes/evaluation_create +# Scolarite/Notes/evaluation_delete +# Scolarite/Notes/formation_list +# Scolarite/Notes/formsemestre_list +# Scolarite/Notes/formsemestre_partition_list +# Scolarite/Notes/groups_view +# Scolarite/Notes/moduleimpl_status +# Scolarite/setGroups + +from flask import jsonify, request, url_for, abort +from app import db +from app.api import bp +from app.api.auth import token_auth +from app.api.errors import bad_request + +from app import models + + +@bp.route("/ScoDoc/api/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]} + return jsonify(data) diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 00000000..f36ec7b0 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,20 @@ +from flask import jsonify +from app import db +from app.api import bp +from app.api.auth import basic_auth, token_auth + + +@bp.route("/tokens", methods=["POST"]) +@basic_auth.login_required +def get_token(): + token = basic_auth.current_user().get_token() + db.session.commit() + return jsonify({"token": token}) + + +@bp.route("/tokens", methods=["DELETE"]) +@token_auth.login_required +def revoke_token(): + token_auth.current_user().revoke_token() + db.session.commit() + return "", 204 diff --git a/app/auth/routes.py b/app/auth/routes.py index 42cdc8e6..7b1712f0 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -37,9 +37,11 @@ def login(): if form.validate_on_submit(): user = User.query.filter_by(user_name=form.user_name.data).first() if user is None or not user.check_password(form.password.data): + current_app.logger.info("login: invalid (%s)", form.user_name.data) flash(_("Invalid user name or password")) 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") diff --git a/app/decorators.py b/app/decorators.py index a1a91f48..3696d56c 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -16,8 +16,10 @@ from flask import request from flask_login import current_user from flask_login import login_required from flask import current_app +import flask_login import app +from app.auth.models import User class ZUser(object): @@ -141,6 +143,48 @@ def permission_required(permission): return decorator +def permission_required_compat_scodoc7(permission): + """Décorateur pour les fonctions utilisée comme API dans ScoDoc 7 + Comme @permission_required mais autorise de passer directement + les informations d'auth en paramètres: + __ac_name, __ac_password + """ + + 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: + auth_ok = False + if request.method == "GET": + user_name = request.args.get("__ac_name") + user_password = request.args.get("__ac_password") + elif request.method == "POST": + user_name = request.form.get("__ac_name") + user_password = request.form.get("__ac_password") + else: + abort(405) # method not allowed + if user_name and user_password: + u = User.query.filter_by(user_name=user_name).first() + 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) + + if not current_user.has_permission(permission, scodoc_dept): + abort(403) + if auth_ok: + return f(*args, **kwargs) + else: + return login_required(f)(*args, **kwargs) + + return decorated_function + + return decorator + + def admin_required(f): from app.auth.models import Permission diff --git a/app/models/departements.py b/app/models/departements.py index 1dee2ca0..c0a928e3 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -34,3 +34,13 @@ class Departement(db.Model): def __repr__(self): return f"" + + def to_dict(self): + data = { + "id": self.id, + "acronym": self.acronym, + "description": self.description, + "visible": self.visible, + "date_creation": self.date_creation, + } + return data diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index f02a19c7..148d9c87 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -325,10 +325,15 @@ class NotesSemSet(db.Model): sem_id = db.Column(db.Integer, nullable=True, default=None) -# Association: +# Association: many to many notes_semset_formsemestre = db.Table( "notes_semset_formsemestre", db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")), - db.Column("semset_id", db.Integer, db.ForeignKey("notes_semset.id")), + db.Column( + "semset_id", + db.Integer, + db.ForeignKey("notes_semset.id", ondelete="CASCADE"), + nullable=False, + ), db.UniqueConstraint("formsemestre_id", "semset_id"), ) diff --git a/app/models/preferences.py b/app/models/preferences.py index 65b88508..b04ad0da 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -33,6 +33,17 @@ class ScoDocSiteConfig(db.Model): value = db.Column(db.Text()) BONUS_SPORT = "bonus_sport_func_name" + NAMES = { + BONUS_SPORT: str, + "always_require_ine": bool, + "SCOLAR_FONT": str, + "SCOLAR_FONT_SIZE": str, + "SCOLAR_FONT_SIZE_FOOT": str, + "INSTITUTION_NAME": str, + "INSTITUTION_ADDRESS": str, + "INSTITUTION_CITY": str, + "DEFAULT_PDF_FOOTER_TEMPLATE": str, + } def __init__(self, name, value): self.name = name @@ -41,6 +52,13 @@ class ScoDocSiteConfig(db.Model): def __repr__(self): return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" + def get_dict(self) -> dict: + "Returns all data as a dict name = value" + return { + c.name: self.NAMES.get(c.name, lambda x: x)(c.value) + for c in ScoDocSiteConfig.query.all() + } + @classmethod def set_bonus_sport_func(cls, func_name): """Record bonus_sport config. diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 4dabbfe8..5ec2ae26 100644 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -83,22 +83,19 @@ def sidebar(): from app.scodoc import sco_abs from app.scodoc import sco_etud - params = { - "ScoURL": scu.ScoURL(), - "SCO_USER_MANUAL": scu.SCO_USER_MANUAL, - } + params = {} - H = ['