forked from ScoDoc/ScoDoc
Application Flask pour ScoDoc 8
This commit is contained in:
parent
078e0e85e0
commit
4864fa5040
1
.gitignore
vendored
1
.gitignore
vendored
@ -131,6 +131,7 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
envsco8/
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
|
37
README.md
37
README.md
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
# SCODOC - gestion de la scolarité
|
# ScoDoc - Gestion de la scolarité
|
||||||
|
|
||||||
(c) Emmanuel Viennet 1999 - 2021 (voir LICENCE.txt)
|
(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>
|
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
238
TODO
@ -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
92
app/__init__.py
Executable 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
6
app/auth/README.md
Normal 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
8
app/auth/__init__.py
Normal 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
15
app/auth/email.py
Normal 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
55
app/auth/forms.py
Normal 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
262
app/auth/models.py
Normal 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
100
app/auth/routes.py
Normal 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
7
app/cli.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# -*- coding: UTF-8 -*
|
||||||
|
import os
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
pass
|
195
app/decorators.py
Normal file
195
app/decorators.py
Normal 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
19
app/email.py
Normal 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
8
app/main/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# main Blueprint
|
||||||
|
|
||||||
|
Quelques essais pour la migration.
|
||||||
|
|
||||||
|
TODO: Ne sera pas conservé.
|
||||||
|
|
||||||
|
|
||||||
|
|
6
app/main/__init__.py
Normal file
6
app/main/__init__.py
Normal 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
143
app/main/routes.py
Normal 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
7
app/models.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# -*- coding: UTF-8 -*
|
||||||
|
|
||||||
|
"""ScoDoc8 models
|
||||||
|
"""
|
||||||
|
|
||||||
|
# None, at this point
|
||||||
|
# see auth.models for user/role related models
|
@ -25,33 +25,6 @@
|
|||||||
#
|
#
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
from ZScolar import ZScolar, manage_addZScolarForm, manage_addZScolar
|
"""ScoDoc core
|
||||||
|
"""
|
||||||
from ZScoDoc import ZScoDoc, manage_addZScoDoc
|
from app.ScoDoc import sco_core
|
||||||
|
|
||||||
# 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"
|
|
||||||
)
|
|
@ -53,6 +53,7 @@ import shutil
|
|||||||
import glob
|
import glob
|
||||||
|
|
||||||
import sco_utils as scu
|
import sco_utils as scu
|
||||||
|
from config import Config
|
||||||
import notesdb as ndb
|
import notesdb as ndb
|
||||||
from notes_log import log
|
from notes_log import log
|
||||||
import sco_formsemestre
|
import sco_formsemestre
|
||||||
@ -71,7 +72,7 @@ from sco_exceptions import (
|
|||||||
|
|
||||||
class BaseArchiver:
|
class BaseArchiver:
|
||||||
def __init__(self, archive_type=""):
|
def __init__(self, archive_type=""):
|
||||||
dirs = [os.environ["INSTANCE_HOME"], "var", "scodoc", "archives"]
|
dirs = [Config.INSTANCE_HOME, "var", "scodoc", "archives"]
|
||||||
if archive_type:
|
if archive_type:
|
||||||
dirs.append(archive_type)
|
dirs.append(archive_type)
|
||||||
self.root = os.path.join(*dirs)
|
self.root = os.path.join(*dirs)
|
@ -6,24 +6,23 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import sco_utils
|
from notes_log import log
|
||||||
from sco_utils import log, SCODOC_CFG_DIR
|
|
||||||
import sco_config
|
import sco_config
|
||||||
|
|
||||||
# scodoc_local defines a CONFIG object
|
# scodoc_local defines a CONFIG object
|
||||||
# here we check if there is a local config file
|
# 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)
|
"""Load local configuration file (if exists)
|
||||||
and merge it with CONFIG.
|
and merge it with CONFIG.
|
||||||
"""
|
"""
|
||||||
# this path should be synced with upgrade.sh
|
# 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
|
LOCAL_CONFIG = None
|
||||||
if os.path.exists(LOCAL_CONFIG_FILENAME):
|
if os.path.exists(LOCAL_CONFIG_FILENAME):
|
||||||
if not SCODOC_CFG_DIR in sys.path:
|
if not scodoc_cfg_dir in sys.path:
|
||||||
sys.path.insert(1, SCODOC_CFG_DIR)
|
sys.path.insert(1, scodoc_cfg_dir)
|
||||||
try:
|
try:
|
||||||
from scodoc_local import CONFIG as LOCAL_CONFIG
|
from scodoc_local import CONFIG as LOCAL_CONFIG
|
||||||
|
|
15
app/scodoc/sco_core.py
Normal file
15
app/scodoc/sco_core.py
Normal 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
Loading…
Reference in New Issue
Block a user