diff --git a/README.md b/README.md index 12d2bbca80..b1117729b7 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,15 @@ Mémo pour développeurs: séquence re-création d'une base: flask import-scodoc7-users flask import-scodoc7-dept STID SCOSTID +Si la base utilisée pour les dev n'est plus en phase avec les scripts de +migration, utiliser les commandes `flask db history`et `flask db stamp`pour se +positionner à la bonne étape. + # Paquet debian 11 -Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. +Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus +important est `postinst`qui se charge de configurer le système (install ou +upgrade de scodoc9). La préparation d'une release se fait à l'aide du script `tools/build_release.sh`. diff --git a/app/__init__.py b/app/__init__.py index a913f57ec5..ac256d8a49 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,6 +2,7 @@ # pylint: disable=invalid-name import os +import re import socket import sys import time @@ -12,19 +13,19 @@ from logging.handlers import SMTPHandler, WatchedFileHandler from flask import current_app, g, request from flask import Flask -from flask import abort, has_request_context +from flask import abort, has_request_context, jsonify from flask import render_template from flask.logging import default_handler from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate -from flask_login import LoginManager +from flask_login import LoginManager, current_user from flask_mail import Mail from flask_bootstrap import Bootstrap from flask_moment import Moment from flask_caching import Cache import sqlalchemy -from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_exceptions import ScoValueError, APIInvalidParams from config import DevConfig import sco_version @@ -32,7 +33,7 @@ db = SQLAlchemy() migrate = Migrate(compare_type=True) login = LoginManager() login.login_view = "auth.login" -login.login_message = "Please log in to access this page." +login.login_message = "Identifiez-vous pour accéder à cette page." mail = Mail() bootstrap = Bootstrap() moment = Moment() @@ -56,6 +57,12 @@ def internal_server_error(e): return render_template("error_500.html", SCOVERSION=sco_version.SCOVERSION), 500 +def handle_invalid_usage(error): + response = jsonify(error.to_dict()) + response.status_code = error.status_code + return response + + def render_raw_html(template_filename: str, **args) -> str: """Load and render an HTML file _without_ using Flask Necessary for 503 error mesage, when DB is down and Flask may be broken. @@ -76,7 +83,7 @@ def postgresql_server_error(e): return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503 -class RequestFormatter(logging.Formatter): +class LogRequestFormatter(logging.Formatter): """Ajoute URL et remote_addr for logging""" def format(self, record): @@ -86,12 +93,64 @@ class RequestFormatter(logging.Formatter): else: record.url = None record.remote_addr = None + record.sco_user = current_user return super().format(record) +class LogExceptionFormatter(logging.Formatter): + """Formatteur pour les exceptions: ajoute détails""" + + def format(self, record): + if has_request_context(): + record.url = request.url + record.remote_addr = request.environ.get( + "HTTP_X_FORWARDED_FOR", request.remote_addr + ) + record.http_referrer = request.referrer + record.http_method = request.method + if request.method == "GET": + record.http_params = str(request.args) + else: + record.http_params = "(post data not loggued)" + else: + record.url = None + record.remote_addr = None + record.http_referrer = None + record.http_method = None + record.http_params = None + record.sco_user = current_user + + return super().format(record) + + +class ScoSMTPHandler(SMTPHandler): + def getSubject(self, record: logging.LogRecord) -> str: + stack_summary = traceback.extract_tb(record.exc_info[2]) + frame_summary = stack_summary[-1] + subject = f"ScoExc({sco_version.SCOVERSION}): {record.exc_info[0].__name__} in {frame_summary.name} {frame_summary.filename}" + + return subject + + +class ReverseProxied(object): + """Adaptateur wsgi qui nous permet d'avoir toutes les URL calculées en https + sauf quand on est en dev. + La variable HTTP_X_FORWARDED_PROTO est positionnée par notre config nginx""" + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + scheme = environ.get("HTTP_X_FORWARDED_PROTO") + if scheme: + environ["wsgi.url_scheme"] = scheme # ou forcer à https ici ? + return self.app(environ, start_response) + + def create_app(config_class=DevConfig): app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static") + app.wsgi_app = ReverseProxied(app.wsgi_app) app.logger.setLevel(logging.DEBUG) app.config.from_object(config_class) @@ -107,6 +166,7 @@ def create_app(config_class=DevConfig): app.register_error_handler(ScoValueError, handle_sco_value_error) app.register_error_handler(500, internal_server_error) app.register_error_handler(503, postgresql_server_error) + app.register_error_handler(APIInvalidParams, handle_invalid_usage) from app.auth import bp as auth_bp @@ -132,9 +192,16 @@ def create_app(config_class=DevConfig): 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" + scodoc_log_formatter = LogRequestFormatter( + "[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n" + "%(levelname)s: %(message)s" + ) + scodoc_exc_formatter = LogExceptionFormatter( + "[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n" + "%(levelname)s: %(message)s\n" + "Referrer: %(http_referrer)s\n" + "Method: %(http_method)s\n" + "Params: %(http_params)s\n" ) if not app.testing: if not app.debug: @@ -150,11 +217,11 @@ def create_app(config_class=DevConfig): if app.config["MAIL_USE_TLS"]: secure = () host_name = socket.gethostname() - mail_handler = SMTPHandler( + mail_handler = ScoSMTPHandler( mailhost=(app.config["MAIL_SERVER"], app.config["MAIL_PORT"]), fromaddr="no-reply@" + app.config["MAIL_SERVER"], toaddrs=["exception@scodoc.org"], - subject="ScoDoc Exception from " + host_name, + subject="ScoDoc Exception", # unused see ScoSMTPHandler credentials=auth, secure=secure, ) @@ -163,7 +230,7 @@ def create_app(config_class=DevConfig): app.logger.addHandler(mail_handler) else: # Pour logs en DEV uniquement: - default_handler.setFormatter(scodoc_exc_formatter) + default_handler.setFormatter(scodoc_log_formatter) # Config logs pour DEV et PRODUCTION # Configuration des logs (actifs aussi en mode development) @@ -172,9 +239,17 @@ def create_app(config_class=DevConfig): file_handler = WatchedFileHandler( app.config["SCODOC_LOG_FILE"], encoding="utf-8" ) - file_handler.setFormatter(scodoc_exc_formatter) + file_handler.setFormatter(scodoc_log_formatter) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) + # Log pour les erreurs (exceptions) uniquement: + # usually /opt/scodoc-data/log/scodoc_exc.log + file_handler = WatchedFileHandler( + app.config["SCODOC_ERR_FILE"], encoding="utf-8" + ) + file_handler.setFormatter(scodoc_exc_formatter) + file_handler.setLevel(logging.ERROR) + app.logger.addHandler(file_handler) # app.logger.setLevel(logging.INFO) app.logger.info(f"{sco_version.SCONAME} {sco_version.SCOVERSION} startup") @@ -353,4 +428,4 @@ from app.scodoc import sco_cache # click.echo( # "Warning: user database not initialized !\n (use: flask user-db-init)" # ) -# admin = None \ No newline at end of file +# admin = None diff --git a/app/auth/forms.py b/app/auth/forms.py index e374781776..143f655461 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -16,20 +16,20 @@ _l = _ class LoginForm(FlaskForm): - user_name = StringField(_l("Username"), validators=[DataRequired()]) - password = PasswordField(_l("Password"), validators=[DataRequired()]) - remember_me = BooleanField(_l("Remember Me")) - submit = SubmitField(_l("Sign In")) + user_name = StringField(_l("Nom d'utilisateur"), validators=[DataRequired()]) + password = PasswordField(_l("Mot de passe"), validators=[DataRequired()]) + remember_me = BooleanField(_l("mémoriser la connexion")) + submit = SubmitField(_l("Suivant")) class UserCreationForm(FlaskForm): - user_name = StringField(_l("Username"), validators=[DataRequired()]) + user_name = StringField(_l("Nom d'utilisateur"), validators=[DataRequired()]) email = StringField(_l("Email"), validators=[DataRequired(), Email()]) - password = PasswordField(_l("Password"), validators=[DataRequired()]) + password = PasswordField(_l("Mot de passe"), validators=[DataRequired()]) password2 = PasswordField( - _l("Repeat Password"), validators=[DataRequired(), EqualTo("password")] + _l("Répéter"), validators=[DataRequired(), EqualTo("password")] ) - submit = SubmitField(_l("Register")) + submit = SubmitField(_l("Inscrire")) def validate_user_name(self, user_name): user = User.query.filter_by(user_name=user_name.data).first() @@ -48,9 +48,9 @@ class ResetPasswordRequestForm(FlaskForm): class ResetPasswordForm(FlaskForm): - password = PasswordField(_l("Password"), validators=[DataRequired()]) + password = PasswordField(_l("Mot de passe"), validators=[DataRequired()]) password2 = PasswordField( - _l("Repeat Password"), validators=[DataRequired(), EqualTo("password")] + _l("Répéter"), validators=[DataRequired(), EqualTo("password")] ) submit = SubmitField(_l("Request Password Reset")) diff --git a/app/auth/routes.py b/app/auth/routes.py index 7b1712f00b..8f01a0c196 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -38,7 +38,7 @@ def login(): 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")) + flash(_("Nom ou mot de passe invalide")) 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) @@ -95,7 +95,7 @@ def reset_password_request(): current_app.logger.info( "reset_password_request: for unkown user '{}'".format(form.email.data) ) - flash(_("Check your email for the instructions to reset your password")) + flash(_("Voir les instructions envoyées par mail")) return redirect(url_for("auth.login")) return render_template( "auth/reset_password_request.html", title=_("Reset Password"), form=form diff --git a/app/decorators.py b/app/decorators.py index 3696d56cad..4987f0d834 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -43,12 +43,14 @@ class ZRequest(object): "Emulating Zope 2 REQUEST" def __init__(self): - if current_app.config["DEBUG"]: - self.URL = request.base_url - self.BASE0 = request.url_root - else: - self.URL = request.base_url.replace("http://", "https://") - self.BASE0 = request.url_root.replace("http://", "https://") + # if current_app.config["DEBUG"]: + + # le ReverseProxied se charge maintenant de mettre le bon protocole http ou https + self.URL = request.base_url + self.BASE0 = request.url_root + # else: + # self.URL = request.base_url.replace("http://", "https://") + # self.BASE0 = request.url_root.replace("http://", "https://") self.URL0 = self.URL # query_string is bytes: self.QUERY_STRING = request.query_string.decode("utf-8") diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 57eadbc788..a77f272e1f 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -41,6 +41,7 @@ class Identite(db.Model): code_nip = db.Column(db.Text()) code_ine = db.Column(db.Text()) # Ancien id ScoDoc7 pour les migrations de bases anciennes + # ne pas utiliser après migrate_scodoc7_dept_archive scodoc7_id = db.Column(db.Text(), nullable=True) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 148d9c8714..89468ab2f5 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -19,7 +19,7 @@ class FormSemestre(db.Model): id = db.Column(db.Integer, primary_key=True) formsemestre_id = db.synonym("id") - # dept_id est aussi dans la formation, ajpouté ici pour + # dept_id est aussi dans la formation, ajouté ici pour # simplifier et accélérer les selects dans notesdb dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) @@ -41,6 +41,10 @@ class FormSemestre(db.Model): bul_hide_xml = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) + # Bloque le calcul des moyennes (générale et d'UE) + block_moyennes = db.Column( + db.Boolean(), nullable=False, default=False, server_default="false" + ) # semestres decales (pour gestion jurys): gestion_semestrielle = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" @@ -70,6 +74,7 @@ class FormSemestre(db.Model): "NotesFormsemestreEtape", cascade="all,delete", backref="notes_formsemestre" ) # Ancien id ScoDoc7 pour les migrations de bases anciennes + # ne pas utiliser après migrate_scodoc7_dept_archive scodoc7_id = db.Column(db.Text(), nullable=True) def __init__(self, **kwargs): diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index ebe5c52a82..eacbfd78f2 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -8,6 +8,7 @@ v 1.3 (python3) """ +import html def TrivialFormulator( @@ -722,7 +723,9 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); if str(descr["allowed_values"][i]) == str(self.values[field]): R.append('%s' % labels[i]) elif input_type == "textarea": - R.append('
%s
' % self.values[field]) + R.append( + '
%s
' % html.escape(self.values[field]) + ) elif input_type == "separator" or input_type == "hidden": pass elif input_type == "file": diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 4944f6e406..c204e7403d 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -87,10 +87,6 @@ Problème de connexion (identifiant, mot de passe): contacter votre responsa ) -_TOP_LEVEL_CSS = """ - """ - _HTML_BEGIN = """ @@ -105,31 +101,30 @@ _HTML_BEGIN = """ - - - - + + + - - - + + + - + - + - - + + """ def scodoc_top_html_header(page_title="ScoDoc: bienvenue"): H = [ _HTML_BEGIN % {"page_title": page_title, "encoding": scu.SCO_ENCODING}, - _TOP_LEVEL_CSS, """""", scu.CUSTOM_HTML_HEADER_CNX, ] @@ -185,13 +180,10 @@ def sco_header( init_jquery = True H = [ - """ - - + """ + %(page_title)s - - @@ -206,9 +198,7 @@ def sco_header( ) if init_google_maps: # It may be necessary to add an API key: - H.append( - '' - ) + H.append('') # Feuilles de style additionnelles: for cssstyle in cssstyles: @@ -223,9 +213,9 @@ def sco_header( - - - + + + """ """ ) - H.append( - '' - ) + H.append('') # qTip if init_qtip: H.append( - '' + '' ) H.append( '' @@ -253,32 +241,25 @@ def sco_header( if init_jquery_ui: H.append( - '' - ) - # H.append('') - H.append( - '' + '' ) + # H.append('') + H.append('') if init_google_maps: H.append( - '' + '' ) if init_datatables: H.append( '' ) - H.append( - '' - ) + H.append('') # JS additionels for js in javascripts: - H.append( - """\n""" - % js - ) + H.append("""\n""" % js) H.append( - """