Application Flask pour ScoDoc 8

This commit is contained in:
Emmanuel Viennet 2021-05-29 18:22:51 +02:00
parent 078e0e85e0
commit 4864fa5040
166 changed files with 1628 additions and 587 deletions

1
.gitignore vendored
View File

@ -131,6 +131,7 @@ venv/
ENV/
env.bak/
venv.bak/
envsco8/
# Spyder project settings
.spyderproject

View File

@ -1,5 +1,5 @@
# SCODOC - gestion de la scolarité
# ScoDoc - Gestion de la scolarité
(c) Emmanuel Viennet 1999 - 2021 (voir LICENCE.txt)
@ -8,7 +8,40 @@ Installation: voir instructions à jour sur <https://scodoc.org>
Documentation utilisateur: <https://scodoc.org>
Ce logiciel est un produit pour Zope 2.13 écrit en Python (2.4, passé à 2.7 pour ScoDoc7).
## Branche ScoDoc 8 expérimentale
N'utiliser que pour les développements et tests, dans le cadre de la migration de Zope vers Flask.
Basée sur **python 2.7**.
## Setup (sur Debian 10 / python2.7)
virtualenv envsco8
source envsco8/bin/activate
installation:
pip install flask
# et pas mal d'autres paquets
donc utiliser:
pip install -r requirements.txt
pour régénerer ce fichier:
pip freeze > requirements.txt
## Lancement serveur (développement, sur VM Linux)
export FLASK_APP=scodoc.py
export FLASK_ENV=development
flask run --host=0.0.0.0
## Tests
python -m unittest tests.test_users

238
TODO
View File

@ -1,238 +0,0 @@
NOTES EN VRAC / Brouillon / Trucs obsoletes
#do_moduleimpl_list\(\{"([a-z_]*)"\s*:\s*(.*)\}\)
#do_moduleimpl_list( $1 = $2 )
#do_moduleimpl_list\([\s\n]*args[\s\n]*=[\s\n]*\{"([a-z_]*)"[\s\n]*:[\s\n]*(.*)[\s\n]*\}[\s\n]*\)
Upgrade JavaScript
- jquery-ui-1.12.1 introduit un problème d'affichage de la barre de menu.
Il faudrait la revoir entièrement pour upgrader.
On reste donc à jquery-ui-1.10.4.custom
Or cette version est incompatible avec jQuery 3 (messages d'erreur dans la console)
On reste donc avec jQuery 1.12.14
Suivi des requêtes utilisateurs:
table sql: id, ip, authuser, request
* Optim:
porcodeb4, avant memorisation des moy_ue:
S1 SEM14133 cold start: min 9s, max 12s, avg > 11s
inval (add note): 1.33s (pas de recalcul des autres)
inval (add abs) : min8s, max 12s (recalcule tout :-()
LP SEM14946 cold start: 0.7s - 0.86s
----------------- LISTE OBSOLETE (très ancienne, à trier) -----------------------
BUGS
----
- formsemestre_inscription_with_modules
si inscription 'un etud deja inscrit, IntegrityError
FEATURES REQUESTS
-----------------
* Bulletins:
. logos IUT et Univ sur bull PDF
. nom departement: nom abbrégé (CJ) ou complet (Carrière Juridiques)
. bulletin: deplacer la barre indicateur (cf OLDGEA S2: gêne)
. bulletin: click nom titre -> ficheEtud
. formsemestre_pagebulletin_dialog: marges en mm: accepter "2,5" et "2.5"
et valider correctement le form !
* Jury
. recapcomplet: revenir avec qq lignes au dessus de l'étudiant en cours
* Divers
. formsemestre_editwithmodules: confirmer suppression modules
(et pour l'instant impossible si evaluations dans le module)
* Modules et UE optionnelles:
. UE capitalisées: donc dispense possible dans semestre redoublé.
traitable en n'inscrivant pas l'etudiant au modules
de cette UE: faire interface utilisateur
. page pour inscription d'un etudiant a un module
. page pour visualiser les modules auquel un etudiant est inscrit,
et le desinscrire si besoin.
. ficheEtud indiquer si inscrit au module sport
* Absences
. EtatAbsences : verifier dates (en JS)
. Listes absences pdf et listes groupes pdf + emargements (cf mail Nathalie)
. absences par demi-journées sur EtatAbsencesDate (? à vérifier)
. formChoixSemestreGroupe: utilisé par Absences/index_html
a améliorer
* Notes et évaluations:
. Exception "Not an OLE file": generer page erreur plus explicite
. Dates evaluation: utiliser JS pour calendrier
. Saisie des notes: si une note invalide, l'indiquer dans le listing (JS ?)
. et/ou: notes invalides: afficher les noms des etudiants concernes
dans le message d'erreur.
. upload excel: message erreur peu explicite:
* Feuille "Saisie notes", 17 lignes
* Erreur: la feuille contient 1 notes invalides
* Notes invalides pour les id: ['10500494']
(pas de notes modifiées)
Notes chargées. <<< CONTRADICTOIRE !!
. recap complet semestre:
Options:
- choix groupes
- critère de tri (moy ou alphab)
- nb de chiffres a afficher
+ definir des "catégories" d'évaluations (eg "théorie","pratique")
afin de n'afficher que des moyennes "de catégorie" dans
le bulletin.
. liste des absents à une eval et croisement avec BD absences
. notes_evaluation_listenotes
- afficher groupes, moyenne, #inscrits, #absents, #manquantes dans l'en-tete.
- lien vers modif notes (selon role)
. Export excel des notes d'evaluation: indiquer date, et autres infos en haut.
. Génération PDF listes notes
. Page recap notes moyennes par groupes (choisir type de groupe?)
. (GEA) edition tableau notes avec tous les evals d'un module
(comme notes_evaluation_listenotes mais avec tt les evals)
* Non prioritaire:
. optimiser scolar_news_summary
. recapitulatif des "nouvelles"
- dernieres notes
- changement de statuts (demissions,inscriptions)
- annotations
- entreprises
. notes_table: pouvoir changer decision sans invalider tout le cache
. navigation: utiliser Session pour montrer historique pages vues ?
------------------------------------------------------------------------
A faire:
- fiche etud: code dec jury sur ligne 1
si ancien, indiquer autorisation inscription sous le parcours
- saisie notes: undo
- saisie notes: validation
- ticket #18:
UE capitalisées: donc dispense possible dans semestre redoublé. Traitable en n'inscrivant pas l'etudiant aux modules de cette UE: faire interface utilisateur.
Prévoir d'entrer une UE capitalisée avec sa note, date d'obtention et un commentaire. Coupler avec la désincription aux modules (si l'étudiant a été inscrit avec ses condisciples).
- Ticket #4: Afin d'éviter les doublons, vérifier qu'il n'existe pas d'homonyme proche lors de la création manuelle d'un étudiant. (confirmé en ScoDoc 6, vérifier aussi les imports Excel)
- Ticket #74: Il est possible d'inscrire un étudiant sans prénom par un import excel !!!
- Ticket #64: saisir les absences pour la promo entiere (et pas par groupe). Des fois, je fais signer une feuille de presence en amphi a partir de la liste de tous les etudiants. Ensuite pour reporter les absents par groupe, c'est galere.
- Ticket #62: Lors des exports Excel, le format des cellules n'est pas reconnu comme numérique sous Windows (pas de problèmes avec Macintosh et Linux).
A confirmer et corriger.
- Ticket #75: On peut modifier une décision de jury (et les autorisations de passage associées), mais pas la supprimer purement et simplement.
Ajoute ce choix dans les "décisions manuelles".
- Ticket #37: Page recap notes moyennes par groupes
Construire une page avec les moyennes dans chaque UE ou module par groupe d'étudiants.
Et aussi pourquoi pas ventiler par type de bac, sexe, parcours (nombre de semestre de parcours) ?
redemandé par CJ: à faire avant mai 2008 !
- Ticket #75: Synchro Apogée: choisir les etudiants
Sur la page de syncho Apogée (formsemestre_synchro_etuds), on peut choisir (cocher) les étudiants Apogée à importer. mais on ne peut pas le faire s'ils sont déjà dans ScoDoc: il faudrait ajouter des checkboxes dans toutes les listes.
- Ticket #9: Format des valeurs de marges des bulletins.
formsemestre_pagebulletin_dialog: marges en mm: accepter "2,5" et "2.5" et valider correctement le form !
- Ticket #17: Suppression modules dans semestres
formsemestre_editwithmodules: confirmer suppression modules
- Ticket #29: changer le stoquage des photos, garder une version HD.
- bencher NotesTable sans calcul de moyennes. Etudier un cache des moyennes de modules.
- listes d'utilisateurs (modules): remplacer menus par champs texte + completions javascript
- documenter archives sur Wiki
- verifier paquet Debian pour font pdf (reportab: helvetica ... plante si font indisponible)
- chercher comment obtenir une page d'erreur correcte pour les pages POST
(eg: si le font n'existe pas, archive semestre echoue sans page d'erreur)
? je ne crois pas que le POST soit en cause. HTTP status=500
ne se produit pas avec Safari
- essayer avec IE / Win98
- faire apparaitre les diplômés sur le graphe des parcours
- démission: formulaire: vérifier que la date est bien dans le semestre
+ graphe parcours: aligner en colonnes selon les dates (de fin), placer les diplomes
dans la même colone que le semestre terminal.
- modif gestion utilisateurs (donner droits en fct du dept. d'appartenance, bug #57)
- modif form def. utilisateur (dept appartenance)
- utilisateurs: source externe
- archivage des semestres
o-------------------------------------o
* Nouvelle gestion utilisateurs:
objectif: dissocier l'authentification de la notion "d'enseignant"
On a une source externe "d'utilisateurs" (annuaire LDAP ou base SQL)
qui permet seulement de:
- authentifier un utilisateur (login, passwd)
- lister un utilisateur: login => firstname, lastname, email
- lister les utilisateurs
et une base interne ScoDoc "d'acteurs" (enseignants, administratifs).
Chaque acteur est défini par:
- actor_id, firstname, lastname
date_creation, date_expiration,
roles, departement,
email (+flag indiquant s'il faut utiliser ce mail ou celui de
l'utilisateur ?)
state (on, off) (pour desactiver avant expiration ?)
user_id (login) => lien avec base utilisateur
On offrira une source d'utilisateurs SQL (base partagée par tous les dept.
d'une instance ScoDoc), mais dans la plupart des cas les gens utiliseront
un annuaire LDAP.
La base d'acteurs remplace ScoUsers. Les objets ScoDoc (semestres,
modules etc) font référence à des acteurs (eg responsable_id est un actor_id).
Le lien entre les deux ?
Loger un utilisateur => authentification utilisateur + association d'un acteur
Cela doit se faire au niveau d'un UserFolder Zope, pour avoir les
bons rôles et le contrôle d'accès adéquat.
(Il faut donc coder notre propre UserFolder).
On ne peut associer qu'un acteur à l'état 'on' et non expiré.
Opérations ScoDoc:
- paramétrage: choisir et paramétrer source utilisateurs
- ajouter utilisateur: choisir un utilisateur dans la liste
et lui associer un nouvel acteur (choix des rôles, des dates)
+ éventuellement: synchro d'un ensemble d'utilisateurs, basé sur
une requête (eg LDAP) précise (quelle interface utilisateur proposer ?)
- régulièrement (cron) aviser quelqu'un (le chef) de l'expiration des acteurs.
- changer etat d'un acteur (on/off)
o-------------------------------------o

92
app/__init__.py Executable file
View File

@ -0,0 +1,92 @@
# -*- coding: UTF-8 -*
# pylint: disable=invalid-name
import os
import logging
from logging.handlers import SMTPHandler, RotatingFileHandler
from flask import request
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from config import Config
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login = LoginManager()
login.login_view = "auth.login"
login.login_message = "Please log in to access this page."
mail = Mail()
bootstrap = Bootstrap(app)
moment = Moment()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
bootstrap.init_app(app)
moment.init_app(app)
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix="/auth")
from app.views import notes_bp
app.register_blueprint(notes_bp, url_prefix="/ScoDoc")
from app.main import bp as main_bp
app.register_blueprint(main_bp)
if not app.debug and not app.testing:
if app.config["MAIL_SERVER"]:
auth = None
if app.config["MAIL_USERNAME"] or app.config["MAIL_PASSWORD"]:
auth = (app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
secure = None
if app.config["MAIL_USE_TLS"]:
secure = ()
mail_handler = SMTPHandler(
mailhost=(app.config["MAIL_SERVER"], app.config["MAIL_PORT"]),
fromaddr="no-reply@" + app.config["MAIL_SERVER"],
toaddrs=[app.config["ADMINS"]],
subject="ScoDoc8 Failure",
credentials=auth,
secure=secure,
)
mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler)
if not os.path.exists("logs"):
os.mkdir("logs")
file_handler = RotatingFileHandler(
"logs/scodoc.log", maxBytes=10240, backupCount=10
)
file_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]"
)
)
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info("ScoDoc8 startup")
return app
# from app import models

6
app/auth/README.md Normal file
View File

@ -0,0 +1,6 @@
# ScoDoc User Authentication Blueprint
Code borrowed and adapted from
https://courses.miguelgrinberg.com/p/flask-mega-tutorial

8
app/auth/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""auth.__init__
"""
from flask import Blueprint
bp = Blueprint("auth", __name__)
from app.auth import routes

15
app/auth/email.py Normal file
View File

@ -0,0 +1,15 @@
# -*- coding: UTF-8 -*
from flask import render_template, current_app
from flask_babel import _
from app.email import send_email
def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email(
"[ScoDoc] Reset Your Password",
sender=current_app.config["ADMINS"][0],
recipients=[user.email],
text_body=render_template("email/reset_password.txt", user=user, token=token),
html_body=render_template("email/reset_password.html", user=user, token=token),
)

55
app/auth/forms.py Normal file
View File

@ -0,0 +1,55 @@
# -*- coding: UTF-8 -*
"""Formulaires authentification
TODO: à revoir complètement pour reprendre ZScoUsers et les pages d'authentification
"""
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.auth.models import User
_ = lambda x: x # sans babel
_l = _
class LoginForm(FlaskForm):
username = StringField(_l("Username"), validators=[DataRequired()])
password = PasswordField(_l("Password"), validators=[DataRequired()])
remember_me = BooleanField(_l("Remember Me"))
submit = SubmitField(_l("Sign In"))
class UserCreationForm(FlaskForm):
username = StringField(_l("Username"), validators=[DataRequired()])
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
password = PasswordField(_l("Password"), validators=[DataRequired()])
password2 = PasswordField(
_l("Repeat Password"), validators=[DataRequired(), EqualTo("password")]
)
submit = SubmitField(_l("Register"))
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError(_("Please use a different username."))
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError(_("Please use a different email address."))
class ResetPasswordRequestForm(FlaskForm):
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
submit = SubmitField(_l("Request Password Reset"))
class ResetPasswordForm(FlaskForm):
password = PasswordField(_l("Password"), validators=[DataRequired()])
password2 = PasswordField(
_l("Repeat Password"), validators=[DataRequired(), EqualTo("password")]
)
submit = SubmitField(_l("Request Password Reset"))

262
app/auth/models.py Normal file
View File

@ -0,0 +1,262 @@
# -*- coding: UTF-8 -*
"""Users and Roles models for ScoDoc
"""
import base64
from datetime import datetime, timedelta
from hashlib import md5
import json
import os
from time import time
from flask import current_app, url_for
from flask_login import UserMixin, AnonymousUserMixin
from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, login
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
password_hash = db.Column(db.String(128))
about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
token = db.Column(db.String(32), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
roles = db.relationship("Role", secondary="user_role", viewonly=True)
Permission = Permission
def __init__(self, **kwargs):
self.roles = []
super(User, self).__init__(**kwargs)
if (
not self.roles
and self.email
and self.email == current_app.config["SCODOC_ADMIN_MAIL"]
):
# super-admin
admin_role = Role.query.filter_by(name="Admin").first()
assert admin_role
self.add_role(admin_role, None)
db.session.commit()
current_app.logger.info("creating user with roles={}".format(self.roles))
def __repr__(self):
return "<User {}>".format(self.username)
def __str__(self):
return self.username
def set_password(self, password):
"Set password"
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_password(self, password):
"""Check given password vs current one.
Returns `True` if the password matched, `False` otherwise.
"""
if not self.password_hash: # user without password can't login
return False
return check_password_hash(self.password_hash, password)
def get_reset_password_token(self, expires_in=600):
return jwt.encode(
{"reset_password": self.id, "exp": time() + expires_in},
current_app.config["SECRET_KEY"],
algorithm="HS256",
).decode("utf-8")
@staticmethod
def verify_reset_password_token(token):
try:
id = jwt.decode(
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
)["reset_password"]
except:
return
return User.query.get(id)
def to_dict(self, include_email=False):
data = {
"id": self.id,
"username": self.username,
"last_seen": self.last_seen.isoformat() + "Z",
"about_me": self.about_me,
}
if include_email:
data["email"] = self.email
return data
def from_dict(self, data, new_user=False):
for field in ["username", "email", "about_me"]:
if field in data:
setattr(self, field, data[field])
if new_user and "password" in data:
self.set_password(data["password"])
def get_token(self, expires_in=3600):
now = datetime.utcnow()
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode("utf-8")
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def revoke_token(self):
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
@staticmethod
def check_token(token):
user = User.query.filter_by(token=token).first()
if user is None or user.token_expiration < datetime.utcnow():
return None
return user
# Permissions management:
def has_permission(self, perm, dept):
"""Check if user has permission `perm` in given `dept`.
Emulate Zope `has_permission``
Args:
perm: integer, one of the value defined in Permission class.
context:
"""
# les role liés à ce département, et les roles avec dept=None (super-admin)
roles_in_dept = (
UserRole.query.filter_by(user_id=self.id)
.filter((UserRole.dept == dept) | (UserRole.dept == None))
.all()
)
for user_role in roles_in_dept:
if user_role.role.has_permission(perm):
return True
return False
# Role management
def add_role(self, role, dept):
"""Add a role to this user.
:param role: Role to add.
"""
self.user_roles.append(UserRole(user=self, role=role, dept=dept))
def add_roles(self, roles, dept):
"""Add roles to this user.
:param roles: Roles to add.
"""
for role in roles:
self.add_role(role, dept)
def set_roles(self, roles, dept):
self.user_roles = [UserRole(user=self, role=r, dept=dept) for r in roles]
def get_roles(self):
for role in self.roles:
yield role
def is_administrator(self):
return self.has_permission(Permission.ScoSuperAdmin, None)
class AnonymousUser(AnonymousUserMixin):
def has_permission(self, perm, dept=None):
return False
def is_administrator(self):
return False
login.anonymous_user = AnonymousUser
class Role(db.Model):
"""Roles for ScoDoc"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.BigInteger) # 64 bits
users = db.relationship("User", secondary="user_role", viewonly=True)
# __table_args__ = (db.UniqueConstraint("name", "dept", name="_rolename_dept_uc"),)
def __init__(self, **kwargs):
super(Role, self).__init__(**kwargs)
if self.permissions is None:
self.permissions = 0
def __repr__(self):
return "<Role {} perm={:0{w}b}>".format(
self.name,
self.permissions & ((1 << Permission.NBITS) - 1),
w=Permission.NBITS,
)
def add_permission(self, perm):
self.permissions |= perm
def remove_permission(self, perm):
self.permissions = self.permissions & ~perm
def reset_permissions(self):
self.permissions = 0
def has_permission(self, perm):
return self.permissions & perm == perm
@staticmethod
def insert_roles():
"""Create default roles"""
default_role = "Observateur"
for r, permissions in SCO_ROLES_DEFAULTS.items():
role = Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.reset_permissions()
for perm in permissions:
role.add_permission(perm)
role.default = role.name == default_role
db.session.add(role)
db.session.commit()
@staticmethod
def get_named_role(name):
"""Returns existing role with given name, or None."""
return Role.query.filter_by(name=name).first()
class UserRole(db.Model):
"""Associate user to role, in a dept.
If dept is None, the role applies to all departments (eg super admin).
"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
role_id = db.Column(db.Integer, db.ForeignKey("role.id"))
dept = db.Column(db.String(64))
user = db.relationship(
User, backref=db.backref("user_roles", cascade="all, delete-orphan")
)
role = db.relationship(
Role, backref=db.backref("user_roles", cascade="all, delete-orphan")
)
def __repr__(self):
return "<UserRole u={} r={} dept={}>".format(self.user, self.role, self.dept)
@login.user_loader
def load_user(id):
return User.query.get(int(id))

100
app/auth/routes.py Normal file
View File

@ -0,0 +1,100 @@
# -*- coding: UTF-8 -*
"""
auth.routes.py
"""
from flask import render_template, redirect, url_for, current_app, flash, request
from werkzeug.urls import url_parse
from flask_login import login_user, logout_user, current_user
from app import db
from app.auth import bp
from app.auth.forms import (
LoginForm,
UserCreationForm,
ResetPasswordRequestForm,
ResetPasswordForm,
)
from app.auth.models import User
from app.auth.email import send_password_reset_email
from app.decorators import scodoc7func, admin_required
_ = lambda x: x # sans babel
_l = _
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash(_("Invalid username or password"))
return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get("next")
if not next_page or url_parse(next_page).netloc != "":
next_page = url_for("main.index")
return redirect(next_page)
return render_template("auth/login.html", title=_("Sign In"), form=form)
@bp.route("/logout")
def logout():
logout_user()
return redirect(url_for("main.index"))
@bp.route("/create_user", methods=["GET", "POST"])
@admin_required
def create_user():
"Form creating new user"
form = UserCreationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash("User {} created".format(user.username))
return redirect(url_for("main.index"))
return render_template(
"auth/register.html", title=u"Création utilisateur", form=form
)
@bp.route("/reset_password_request", methods=["GET", "POST"])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = ResetPasswordRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
send_password_reset_email(user)
else:
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"))
return redirect(url_for("auth.login"))
return render_template(
"auth/reset_password_request.html", title=_("Reset Password"), form=form
)
@bp.route("/reset_password/<token>", methods=["GET", "POST"])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for("main.index"))
user = User.verify_reset_password_token(token)
if not user:
return redirect(url_for("main.index"))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash(_("Your password has been reset."))
return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form)

7
app/cli.py Normal file
View File

@ -0,0 +1,7 @@
# -*- coding: UTF-8 -*
import os
import click
def register(app):
pass

195
app/decorators.py Normal file
View File

@ -0,0 +1,195 @@
# -*- coding: UTF-8 -*
"""Decorators for permissions, roles and ScoDoc7 Zope compatibility
"""
import functools
from functools import wraps
import inspect
import flask
from flask import g
from flask import abort, current_app
from flask import request
from flask_login import current_user
from flask_login import login_required
from flask import current_app
from werkzeug.exceptions import BadRequest
from app.auth.models import Permission
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
current_app.logger.info(
"permission_required: %s in %s" % (permission, g.scodoc_dept)
)
if not current_user.has_permission(permission, g.scodoc_dept):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
return permission_required(Permission.ScoSuperAdmin)(f)
class ZUser(object):
"Emulating Zope User"
def __init__(self):
"create, based on `flask_login.current_user`"
self.username = current_user.username
def __str__(self):
return self.username
def has_permission(self, perm, context):
"""check if this user as the permission `perm`
in departement given by `g.scodoc_dept`.
"""
raise NotImplementedError()
class ZRequest(object):
"Emulating Zope 2 REQUEST"
def __init__(self):
self.URL = request.base_url
self.URL0 = self.URL
self.BASE0 = request.url_root
self.QUERY_STRING = request.query_string
self.REQUEST_METHOD = request.method
self.AUTHENTICATED_USER = current_user
if request.method == "POST":
self.form = request.form
if request.files:
# Add files in form: must copy to get a mutable version
# request.form is a werkzeug.datastructures.ImmutableMultiDict
self.form = self.form.copy()
self.form.update(request.files)
elif request.method == "GET":
self.form = request.args
self.RESPONSE = ZResponse()
def __str__(self):
return """REQUEST
URL={r.URL}
QUERY_STRING={r.QUERY_STRING}
REQUEST_METHOD={r.REQUEST_METHOD}
AUTHENTICATED_USER={r.AUTHENTICATED_USER}
form={r.form}
""".format(
r=self
)
class ZResponse(object):
"Emulating Zope 2 RESPONSE"
def __init__(self):
self.headers = {}
def redirect(self, url):
return flask.redirect(url) # http 302
def setHeader(self, header, value):
self.headers[header.tolower()] = value
def scodoc7func(func):
"""Décorateur pour intégrer les fonctions Zope 2 de ScoDoc 7.
Si on a un kwarg `scodoc_dept`(venant de la route), le stocke dans `g.scodoc_dept`.
Ajoute l'argument REQUEST s'il est dans la signature de la fonction.
Les paramètres de la query string deviennent des (keywords) paramètres de la fonction.
"""
@wraps(func)
def scodoc7func_decorator(*args, **kwargs):
"""Decorator allowing legacy Zope published methods to be called via Flask
routes without modification.
There are two cases: the function can be called
1. via a Flask route ("top level call")
2. or be called directly from Python.
If called via a route, this decorator setups a REQUEST object (emulating Zope2 REQUEST)
and `g.scodoc_dept` if present in the argument (for routes like `/<scodoc_dept>/Scolarite/sco_exemple`).
"""
assert not args
if hasattr(g, "zrequest"):
top_level = False
else:
g.zrequest = None
top_level = True
#
if "scodoc_dept" in kwargs:
g.scodoc_dept = kwargs["scodoc_dept"]
del kwargs["scodoc_dept"]
elif not hasattr(g, "scodoc_dept"): # if toplevel call
g.scodoc_dept = None
# --- Emulate Zope's REQUEST
REQUEST = ZRequest()
g.zrequest = REQUEST
req_args = REQUEST.form # args from query string (get) or form (post)
# --- Add positional arguments
pos_arg_values = []
# PY3 à remplacer par inspect.getfullargspec en py3:
argspec = inspect.getargspec(func)
current_app.logger.info("argspec=%s" % str(argspec))
nb_default_args = len(argspec.defaults) if argspec.defaults else 0
if nb_default_args:
arg_names = argspec.args[:-nb_default_args]
else:
arg_names = argspec.args
for arg_name in arg_names:
if arg_name == "REQUEST": # special case
pos_arg_values.append(REQUEST)
else:
pos_arg_values.append(req_args[arg_name])
current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# Add keyword arguments
if nb_default_args:
for arg_name in argspec.args[-nb_default_args:]:
if arg_name == "REQUEST": # special case
kwargs[arg_name] = REQUEST
elif arg_name in req_args:
# set argument kw optionnel
kwargs[arg_name] = req_args[arg_name]
current_app.logger.info(
"scodoc7func_decorator: top_level=%s, pos_arg_values=%s, kwargs=%s"
% (top_level, pos_arg_values, kwargs)
)
value = func(*pos_arg_values, **kwargs)
if not top_level:
return value
else:
# Build response, adding collected http headers:
headers = []
kw = {"response": value, "status": 200}
if g.zrequest:
headers = g.zrequest.RESPONSE.headers
if not headers:
# no customized header, speedup:
return value
if "content-type" in headers:
kw["mimetype"] = headers["content-type"]
r = flask.Response(**kw)
for h in headers:
r.headers[h] = headers[h]
return r
return scodoc7func_decorator
# Le "context" de ScoDoc7
class ScoDoc7Context(object):
"""Context object for legacy Zope methods.
Mainly used to call published methods, as context.function(...)
"""
def __init__(self, globals_dict):
self.__dict__ = globals_dict

19
app/email.py Normal file
View File

@ -0,0 +1,19 @@
# -*- coding: UTF-8 -*
from threading import Thread
from flask import current_app
from flask_mail import Message
from app import mail
def send_async_email(app, msg):
with app.app_context():
mail.send(msg)
def send_email(subject, sender, recipients, text_body, html_body):
msg = Message(subject, sender=sender, recipients=recipients)
msg.body = text_body
msg.html = html_body
Thread(
target=send_async_email, args=(current_app._get_current_object(), msg)
).start()

8
app/main/README.md Normal file
View File

@ -0,0 +1,8 @@
# main Blueprint
Quelques essais pour la migration.
TODO: Ne sera pas conservé.

6
app/main/__init__.py Normal file
View File

@ -0,0 +1,6 @@
# -*- coding: UTF-8 -*
from flask import Blueprint
bp = Blueprint("main", __name__)
from app.main import routes

143
app/main/routes.py Normal file
View File

@ -0,0 +1,143 @@
# -*- coding: UTF-8 -*
import pprint
from pprint import pprint as pp
import functools
import thread # essai
from zipfile import ZipFile
from StringIO import StringIO
import flask
from flask import request, render_template, redirect
from flask_login import login_required
from app.main import bp
from app.decorators import scodoc7func, admin_required
@bp.route("/")
@bp.route("/index")
def index():
return render_template("main/index.html", title=u"Essai Flask")
@bp.route("/test_vue")
@login_required
def test_vue():
return """Vous avez vu. <a href="/">Retour à l'accueil</a>"""
def get_request_infos():
return [
"<p>request.base_url=%s</p>" % request.base_url,
"<p>request.url_root=%s</p>" % request.url_root,
"<p>request.query_string=%s</p>" % request.query_string,
]
D = {"count": 0}
# @app.route("/")
# @app.route("/index")
# def index():
# sleep(8)
# D["count"] = D.get("count", 0) + 1
# return "Hello, World! %s count=%s" % (thread.get_ident(), D["count"])
@bp.route("/zopefunction", methods=["POST", "GET"])
@login_required
@scodoc7func
def a_zope_function(y, x="defaut", REQUEST=None):
"""Une fonction typique de ScoDoc7"""
H = get_request_infos() + [
"<p><b>x=<tt>%s</tt></b></p>" % x,
"<p><b>y=<tt>%s</tt></b></p>" % y,
"<p><b>URL=<tt>%s</tt></b></p>" % REQUEST.URL,
"<p><b>QUERY_STRING=<tt>%s</tt></b></p>" % REQUEST.QUERY_STRING,
"<p><b>AUTHENTICATED_USER=<tt>%s</tt></b></p>" % REQUEST.AUTHENTICATED_USER,
]
H.append("<p><b>form=<tt>%s</tt></b></p>" % REQUEST.form)
H.append("<p><b>form[x]=<tt>%s</tt></b></p>" % REQUEST.form.get("x", "non fourni"))
return "\n".join(H)
@bp.route("/zopeform_get")
@scodoc7func
def a_zope_form_get(REQUEST=None):
H = [
"""<h2>Formulaire GET</h2>
<form action="%s" method="get">
x : <input type="text" name="x"/><br/>
y : <input type="text" name="y"/><br/>
fichier : <input type="file" name="fichier"/><br/>
<input type="submit" value="Envoyer"/>
</form>
"""
% flask.url_for("main.a_zope_function")
]
return "\n".join(H)
@bp.route("/zopeform_post")
@scodoc7func
def a_zope_form_post(REQUEST=None):
H = [
"""<h2>Formulaire POST</h2>
<form action="%s" method="post" enctype="multipart/form-data">
x : <input type="text" name="x"/><br/>
y : <input type="text" name="y"/><br/>
fichier : <input type="file" name="fichier"/><br/>
<input type="submit" value="Envoyer"/>
</form>
"""
% flask.url_for("main.a_zope_function")
]
return "\n".join(H)
@bp.route("/ScoDoc/<dept_id>/Scolarite/Notes/formsemestre_status")
@scodoc7func
def formsemestre_status(dept_id=None, formsemestre_id=None, REQUEST=None):
"""Essai méthode de département
Le contrôle d'accès doit vérifier les bons rôles : ici Ens<dept_id>
"""
return u"""dept_id=%s , formsemestre_id=%s <a href="/">Retour à l'accueil</a>""" % (
dept_id,
formsemestre_id,
)
@bp.route("/hello/world")
def hello():
H = get_request_infos() + [
"<p>Hello, World! %s count=%s</p>" % (thread.get_ident(), D["count"]),
]
# print(pprint.pformat(dir(request)))
return "\n".join(H)
@bp.route("/getzip")
def getzip():
"""Essai renvoi d'un ZIP en Flask"""
# La version Zope:
# REQUEST.RESPONSE.setHeader("content-type", "application/zip")
# REQUEST.RESPONSE.setHeader("content-length", size)
# REQUEST.RESPONSE.setHeader(
# "content-disposition", 'attachement; filename="monzip.zip"'
# )
zipdata = StringIO()
zipfile = ZipFile(zipdata, "w")
zipfile.writestr("fichier1", "un contenu")
zipfile.writestr("fichier2", "deux contenus")
zipfile.close()
data = zipdata.getvalue()
size = len(data)
# open("/tmp/toto.zip", "w").write(data)
# Flask response:
r = flask.Response(response=data, status=200, mimetype="application/zip")
r.headers["Content-Type"] = "application/zip"
r.headers["content-length"] = size
r.headers["content-disposition"] = 'attachement; filename="monzip.zip"'
return r

7
app/models.py Normal file
View File

@ -0,0 +1,7 @@
# -*- coding: UTF-8 -*
"""ScoDoc8 models
"""
# None, at this point
# see auth.models for user/role related models

View File

@ -25,33 +25,6 @@
#
##############################################################################
from ZScolar import ZScolar, manage_addZScolarForm, manage_addZScolar
from ZScoDoc import ZScoDoc, manage_addZScoDoc
# from sco_zope import *
# from notes_log import log
# log.set_log_directory( INSTANCE_HOME + '/log' )
__version__ = "1.0.0"
def initialize(context):
"""initialize the Scolar products"""
# called at each startup (context is a ProductContext instance, basically useless)
# --- ZScolars
context.registerClass(
ZScolar,
constructors=(
manage_addZScolarForm, # this is called when someone adds the product
manage_addZScolar,
),
icon="static/icons/sco_icon.png",
)
# --- ZScoDoc
context.registerClass(
ZScoDoc, constructors=(manage_addZScoDoc,), icon="static/icons/sco_icon.png"
)
"""ScoDoc core
"""
from app.ScoDoc import sco_core

View File

@ -53,6 +53,7 @@ import shutil
import glob
import sco_utils as scu
from config import Config
import notesdb as ndb
from notes_log import log
import sco_formsemestre
@ -71,7 +72,7 @@ from sco_exceptions import (
class BaseArchiver:
def __init__(self, archive_type=""):
dirs = [os.environ["INSTANCE_HOME"], "var", "scodoc", "archives"]
dirs = [Config.INSTANCE_HOME, "var", "scodoc", "archives"]
if archive_type:
dirs.append(archive_type)
self.root = os.path.join(*dirs)

View File

@ -6,24 +6,23 @@
import os
import sys
import sco_utils
from sco_utils import log, SCODOC_CFG_DIR
from notes_log import log
import sco_config
# scodoc_local defines a CONFIG object
# here we check if there is a local config file
def load_local_configuration():
def load_local_configuration(scodoc_cfg_dir):
"""Load local configuration file (if exists)
and merge it with CONFIG.
"""
# this path should be synced with upgrade.sh
LOCAL_CONFIG_FILENAME = os.path.join(SCODOC_CFG_DIR, "scodoc_local.py")
LOCAL_CONFIG_FILENAME = os.path.join(scodoc_cfg_dir, "scodoc_local.py")
LOCAL_CONFIG = None
if os.path.exists(LOCAL_CONFIG_FILENAME):
if not SCODOC_CFG_DIR in sys.path:
sys.path.insert(1, SCODOC_CFG_DIR)
if not scodoc_cfg_dir in sys.path:
sys.path.insert(1, scodoc_cfg_dir)
try:
from scodoc_local import CONFIG as LOCAL_CONFIG

15
app/scodoc/sco_core.py Normal file
View File

@ -0,0 +1,15 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
"""essai: ceci serait un module ScoDoc/sco_xxx.py
"""
import types
import sco_utils as scu
def sco_get_version(context, REQUEST=None):
"""Une fonction typique de ScoDoc7
"""
return """<html><body><p>%s</p></body></html>""" % scu.SCOVERSION

Some files were not shown because too many files have changed in this diff Show More