diff --git a/.gitignore b/.gitignore
index f69b51fe413808eea182900c56ef9a7956c397ce..6d49cf2c81a5fac9532fd6742007e605f6f7cf2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,6 +131,7 @@ venv/
 ENV/
 env.bak/
 venv.bak/
+envsco8/
 
 # Spyder project settings
 .spyderproject
diff --git a/README.md b/README.md
index b5a53fcacff5ffb2f25c172b41477bf6da001eab..e9c058b16f44f45d4db4c4f3d1bc3574c686d80b 100644
--- a/README.md
+++ b/README.md
@@ -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
 
 
 
diff --git a/TODO b/TODO
deleted file mode 100644
index 4041532f07cd29b446ebd2a4c6e90932b7185044..0000000000000000000000000000000000000000
--- a/TODO
+++ /dev/null
@@ -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
-
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100755
index 0000000000000000000000000000000000000000..61fa4045c12b985b05c103a2be8b2a3d3ee0f6da
--- /dev/null
+++ b/app/__init__.py
@@ -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
diff --git a/app/auth/README.md b/app/auth/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7bb621abdbcae5fdd955337ac107d5ff0f083ca9
--- /dev/null
+++ b/app/auth/README.md
@@ -0,0 +1,6 @@
+# ScoDoc User Authentication Blueprint
+
+Code borrowed and adapted from 
+https://courses.miguelgrinberg.com/p/flask-mega-tutorial
+
+
diff --git a/app/auth/__init__.py b/app/auth/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4fb9575d9a21607d73a4f6eb0e43733c53245e3e
--- /dev/null
+++ b/app/auth/__init__.py
@@ -0,0 +1,8 @@
+"""auth.__init__
+"""
+
+from flask import Blueprint
+
+bp = Blueprint("auth", __name__)
+
+from app.auth import routes
diff --git a/app/auth/email.py b/app/auth/email.py
new file mode 100644
index 0000000000000000000000000000000000000000..d067bef27ab4c82e3b96864b5658c8e9fbdc58ff
--- /dev/null
+++ b/app/auth/email.py
@@ -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),
+    )
diff --git a/app/auth/forms.py b/app/auth/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..4dcb11831f069e74bb6041c0d182666c1c7ee874
--- /dev/null
+++ b/app/auth/forms.py
@@ -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"))
diff --git a/app/auth/models.py b/app/auth/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..57622ba8f1a02ebe3c9c4f4865b6322a8127b9e1
--- /dev/null
+++ b/app/auth/models.py
@@ -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))
diff --git a/app/auth/routes.py b/app/auth/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..96cc40c6f0bee853b008c15dd3f5715e0faf3f7a
--- /dev/null
+++ b/app/auth/routes.py
@@ -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)
diff --git a/app/cli.py b/app/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..25ae4d4880ceca290c724900132eeb93cf9ffb3d
--- /dev/null
+++ b/app/cli.py
@@ -0,0 +1,7 @@
+# -*- coding: UTF-8 -*
+import os
+import click
+
+
+def register(app):
+    pass
diff --git a/app/decorators.py b/app/decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..9816e53a6711fd5e4830a9b8ea2e8d377d97d8c9
--- /dev/null
+++ b/app/decorators.py
@@ -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
diff --git a/app/email.py b/app/email.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2d8164d8cdb9c08498c0241b73f1a3a259e34b9
--- /dev/null
+++ b/app/email.py
@@ -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()
diff --git a/app/main/README.md b/app/main/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0f4acc0447581e20551d74fe7a3934adb10f14ba
--- /dev/null
+++ b/app/main/README.md
@@ -0,0 +1,8 @@
+# main Blueprint
+
+Quelques essais pour la migration.
+
+TODO: Ne sera pas conservé.
+
+
+
diff --git a/app/main/__init__.py b/app/main/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a27dd27d7cc4fc1e2158146b5f56d62a2253f8c7
--- /dev/null
+++ b/app/main/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: UTF-8 -*
+from flask import Blueprint
+
+bp = Blueprint("main", __name__)
+
+from app.main import routes
diff --git a/app/main/routes.py b/app/main/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..d36545b9428d535bf729a57238563998ab4a8f12
--- /dev/null
+++ b/app/main/routes.py
@@ -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
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..0205be7bfcccee90c41d1c4f72d28e0fc3abb359
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,7 @@
+# -*- coding: UTF-8 -*
+
+"""ScoDoc8 models
+"""
+
+# None, at this point
+# see auth.models for user/role related models
diff --git a/ImportScolars.py b/app/scodoc/ImportScolars.py
similarity index 100%
rename from ImportScolars.py
rename to app/scodoc/ImportScolars.py
diff --git a/SuppressAccents.py b/app/scodoc/SuppressAccents.py
similarity index 100%
rename from SuppressAccents.py
rename to app/scodoc/SuppressAccents.py
diff --git a/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py
similarity index 100%
rename from TrivialFormulator.py
rename to app/scodoc/TrivialFormulator.py
diff --git a/VERSION.py b/app/scodoc/VERSION.py
similarity index 100%
rename from VERSION.py
rename to app/scodoc/VERSION.py
diff --git a/ZAbsences.py b/app/scodoc/ZAbsences.py
similarity index 100%
rename from ZAbsences.py
rename to app/scodoc/ZAbsences.py
diff --git a/ZEntreprises.py b/app/scodoc/ZEntreprises.py
similarity index 100%
rename from ZEntreprises.py
rename to app/scodoc/ZEntreprises.py
diff --git a/ZNotes.py b/app/scodoc/ZNotes.py
similarity index 100%
rename from ZNotes.py
rename to app/scodoc/ZNotes.py
diff --git a/ZScoDoc.py b/app/scodoc/ZScoDoc.py
similarity index 100%
rename from ZScoDoc.py
rename to app/scodoc/ZScoDoc.py
diff --git a/ZScoUsers.py b/app/scodoc/ZScoUsers.py
similarity index 100%
rename from ZScoUsers.py
rename to app/scodoc/ZScoUsers.py
diff --git a/ZScolar.py b/app/scodoc/ZScolar.py
similarity index 100%
rename from ZScolar.py
rename to app/scodoc/ZScolar.py
diff --git a/__init__.py b/app/scodoc/__init__.py
similarity index 57%
rename from __init__.py
rename to app/scodoc/__init__.py
index 98fd4023811ab2389012953d54063ed9bc68c09a..56e94e3211b6b7c7707154e01bdd0b0f572c73dd 100644
--- a/__init__.py
+++ b/app/scodoc/__init__.py
@@ -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
diff --git a/bonus_sport.py b/app/scodoc/bonus_sport.py
similarity index 100%
rename from bonus_sport.py
rename to app/scodoc/bonus_sport.py
diff --git a/debug.py b/app/scodoc/debug.py
similarity index 100%
rename from debug.py
rename to app/scodoc/debug.py
diff --git a/dutrules.py b/app/scodoc/dutrules.py
similarity index 100%
rename from dutrules.py
rename to app/scodoc/dutrules.py
diff --git a/gen_tables.py b/app/scodoc/gen_tables.py
similarity index 100%
rename from gen_tables.py
rename to app/scodoc/gen_tables.py
diff --git a/html_sco_header.py b/app/scodoc/html_sco_header.py
similarity index 100%
rename from html_sco_header.py
rename to app/scodoc/html_sco_header.py
diff --git a/html_sidebar.py b/app/scodoc/html_sidebar.py
similarity index 100%
rename from html_sidebar.py
rename to app/scodoc/html_sidebar.py
diff --git a/htmlutils.py b/app/scodoc/htmlutils.py
similarity index 100%
rename from htmlutils.py
rename to app/scodoc/htmlutils.py
diff --git a/imageresize.py b/app/scodoc/imageresize.py
similarity index 100%
rename from imageresize.py
rename to app/scodoc/imageresize.py
diff --git a/intervals.py b/app/scodoc/intervals.py
similarity index 100%
rename from intervals.py
rename to app/scodoc/intervals.py
diff --git a/listhistogram.py b/app/scodoc/listhistogram.py
similarity index 100%
rename from listhistogram.py
rename to app/scodoc/listhistogram.py
diff --git a/notes_cache.py b/app/scodoc/notes_cache.py
similarity index 100%
rename from notes_cache.py
rename to app/scodoc/notes_cache.py
diff --git a/notes_log.py b/app/scodoc/notes_log.py
similarity index 100%
rename from notes_log.py
rename to app/scodoc/notes_log.py
diff --git a/notes_table.py b/app/scodoc/notes_table.py
similarity index 100%
rename from notes_table.py
rename to app/scodoc/notes_table.py
diff --git a/notes_users.py b/app/scodoc/notes_users.py
similarity index 100%
rename from notes_users.py
rename to app/scodoc/notes_users.py
diff --git a/notesdb.py b/app/scodoc/notesdb.py
similarity index 100%
rename from notesdb.py
rename to app/scodoc/notesdb.py
diff --git a/pe_avislatex.py b/app/scodoc/pe_avislatex.py
similarity index 100%
rename from pe_avislatex.py
rename to app/scodoc/pe_avislatex.py
diff --git a/pe_jurype.py b/app/scodoc/pe_jurype.py
similarity index 100%
rename from pe_jurype.py
rename to app/scodoc/pe_jurype.py
diff --git a/pe_semestretag.py b/app/scodoc/pe_semestretag.py
similarity index 100%
rename from pe_semestretag.py
rename to app/scodoc/pe_semestretag.py
diff --git a/pe_settag.py b/app/scodoc/pe_settag.py
similarity index 100%
rename from pe_settag.py
rename to app/scodoc/pe_settag.py
diff --git a/pe_tagtable.py b/app/scodoc/pe_tagtable.py
similarity index 100%
rename from pe_tagtable.py
rename to app/scodoc/pe_tagtable.py
diff --git a/pe_tools.py b/app/scodoc/pe_tools.py
similarity index 100%
rename from pe_tools.py
rename to app/scodoc/pe_tools.py
diff --git a/pe_view.py b/app/scodoc/pe_view.py
similarity index 100%
rename from pe_view.py
rename to app/scodoc/pe_view.py
diff --git a/safehtml.py b/app/scodoc/safehtml.py
similarity index 100%
rename from safehtml.py
rename to app/scodoc/safehtml.py
diff --git a/sco_abs.py b/app/scodoc/sco_abs.py
similarity index 100%
rename from sco_abs.py
rename to app/scodoc/sco_abs.py
diff --git a/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py
similarity index 100%
rename from sco_abs_notification.py
rename to app/scodoc/sco_abs_notification.py
diff --git a/sco_abs_views.py b/app/scodoc/sco_abs_views.py
similarity index 100%
rename from sco_abs_views.py
rename to app/scodoc/sco_abs_views.py
diff --git a/sco_apogee_compare.py b/app/scodoc/sco_apogee_compare.py
similarity index 100%
rename from sco_apogee_compare.py
rename to app/scodoc/sco_apogee_compare.py
diff --git a/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py
similarity index 100%
rename from sco_apogee_csv.py
rename to app/scodoc/sco_apogee_csv.py
diff --git a/sco_archives.py b/app/scodoc/sco_archives.py
similarity index 99%
rename from sco_archives.py
rename to app/scodoc/sco_archives.py
index 70a0553b085be76c4ba43df3b1dfdebd36dd5a12..2650f0e92ef82226061bf2f2663de271c7082f1b 100644
--- a/sco_archives.py
+++ b/app/scodoc/sco_archives.py
@@ -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)
diff --git a/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py
similarity index 100%
rename from sco_archives_etud.py
rename to app/scodoc/sco_archives_etud.py
diff --git a/sco_bac.py b/app/scodoc/sco_bac.py
similarity index 100%
rename from sco_bac.py
rename to app/scodoc/sco_bac.py
diff --git a/sco_bulletins.py b/app/scodoc/sco_bulletins.py
similarity index 100%
rename from sco_bulletins.py
rename to app/scodoc/sco_bulletins.py
diff --git a/sco_bulletins_example.py b/app/scodoc/sco_bulletins_example.py
similarity index 100%
rename from sco_bulletins_example.py
rename to app/scodoc/sco_bulletins_example.py
diff --git a/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py
similarity index 100%
rename from sco_bulletins_generator.py
rename to app/scodoc/sco_bulletins_generator.py
diff --git a/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
similarity index 100%
rename from sco_bulletins_json.py
rename to app/scodoc/sco_bulletins_json.py
diff --git a/sco_bulletins_legacy.py b/app/scodoc/sco_bulletins_legacy.py
similarity index 100%
rename from sco_bulletins_legacy.py
rename to app/scodoc/sco_bulletins_legacy.py
diff --git a/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py
similarity index 100%
rename from sco_bulletins_pdf.py
rename to app/scodoc/sco_bulletins_pdf.py
diff --git a/sco_bulletins_signature.py b/app/scodoc/sco_bulletins_signature.py
similarity index 100%
rename from sco_bulletins_signature.py
rename to app/scodoc/sco_bulletins_signature.py
diff --git a/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py
similarity index 100%
rename from sco_bulletins_standard.py
rename to app/scodoc/sco_bulletins_standard.py
diff --git a/sco_bulletins_ucac.py b/app/scodoc/sco_bulletins_ucac.py
similarity index 100%
rename from sco_bulletins_ucac.py
rename to app/scodoc/sco_bulletins_ucac.py
diff --git a/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py
similarity index 100%
rename from sco_bulletins_xml.py
rename to app/scodoc/sco_bulletins_xml.py
diff --git a/sco_cache.py b/app/scodoc/sco_cache.py
similarity index 100%
rename from sco_cache.py
rename to app/scodoc/sco_cache.py
diff --git a/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py
similarity index 100%
rename from sco_codes_parcours.py
rename to app/scodoc/sco_codes_parcours.py
diff --git a/sco_compute_moy.py b/app/scodoc/sco_compute_moy.py
similarity index 100%
rename from sco_compute_moy.py
rename to app/scodoc/sco_compute_moy.py
diff --git a/sco_config.py b/app/scodoc/sco_config.py
similarity index 100%
rename from sco_config.py
rename to app/scodoc/sco_config.py
diff --git a/sco_config_load.py b/app/scodoc/sco_config_load.py
similarity index 82%
rename from sco_config_load.py
rename to app/scodoc/sco_config_load.py
index b980c9d493fcdc33ce4c59a807bfdac1bee56c3a..e91e2f2032384ef864e9ebf421fb655d278be043 100644
--- a/sco_config_load.py
+++ b/app/scodoc/sco_config_load.py
@@ -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
 
diff --git a/app/scodoc/sco_core.py b/app/scodoc/sco_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e8c2e5f2e2a8f0ac07cbce43a77c71950660ab5
--- /dev/null
+++ b/app/scodoc/sco_core.py
@@ -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
+
diff --git a/sco_cost_formation.py b/app/scodoc/sco_cost_formation.py
similarity index 100%
rename from sco_cost_formation.py
rename to app/scodoc/sco_cost_formation.py
diff --git a/sco_debouche.py b/app/scodoc/sco_debouche.py
similarity index 100%
rename from sco_debouche.py
rename to app/scodoc/sco_debouche.py
diff --git a/sco_dept.py b/app/scodoc/sco_dept.py
similarity index 100%
rename from sco_dept.py
rename to app/scodoc/sco_dept.py
diff --git a/sco_dump_db.py b/app/scodoc/sco_dump_db.py
similarity index 100%
rename from sco_dump_db.py
rename to app/scodoc/sco_dump_db.py
diff --git a/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py
similarity index 100%
rename from sco_edit_formation.py
rename to app/scodoc/sco_edit_formation.py
diff --git a/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py
similarity index 100%
rename from sco_edit_matiere.py
rename to app/scodoc/sco_edit_matiere.py
diff --git a/sco_edit_module.py b/app/scodoc/sco_edit_module.py
similarity index 100%
rename from sco_edit_module.py
rename to app/scodoc/sco_edit_module.py
diff --git a/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
similarity index 100%
rename from sco_edit_ue.py
rename to app/scodoc/sco_edit_ue.py
diff --git a/sco_edt_cal.py b/app/scodoc/sco_edt_cal.py
similarity index 100%
rename from sco_edt_cal.py
rename to app/scodoc/sco_edt_cal.py
diff --git a/sco_entreprises.py b/app/scodoc/sco_entreprises.py
similarity index 100%
rename from sco_entreprises.py
rename to app/scodoc/sco_entreprises.py
diff --git a/sco_etape_apogee.py b/app/scodoc/sco_etape_apogee.py
similarity index 100%
rename from sco_etape_apogee.py
rename to app/scodoc/sco_etape_apogee.py
diff --git a/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py
similarity index 100%
rename from sco_etape_apogee_view.py
rename to app/scodoc/sco_etape_apogee_view.py
diff --git a/sco_etape_bilan.py b/app/scodoc/sco_etape_bilan.py
similarity index 100%
rename from sco_etape_bilan.py
rename to app/scodoc/sco_etape_bilan.py
diff --git a/sco_evaluations.py b/app/scodoc/sco_evaluations.py
similarity index 100%
rename from sco_evaluations.py
rename to app/scodoc/sco_evaluations.py
diff --git a/sco_excel.py b/app/scodoc/sco_excel.py
similarity index 100%
rename from sco_excel.py
rename to app/scodoc/sco_excel.py
diff --git a/sco_exceptions.py b/app/scodoc/sco_exceptions.py
similarity index 100%
rename from sco_exceptions.py
rename to app/scodoc/sco_exceptions.py
diff --git a/sco_export_results.py b/app/scodoc/sco_export_results.py
similarity index 100%
rename from sco_export_results.py
rename to app/scodoc/sco_export_results.py
diff --git a/sco_find_etud.py b/app/scodoc/sco_find_etud.py
similarity index 100%
rename from sco_find_etud.py
rename to app/scodoc/sco_find_etud.py
diff --git a/sco_formations.py b/app/scodoc/sco_formations.py
similarity index 100%
rename from sco_formations.py
rename to app/scodoc/sco_formations.py
diff --git a/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
similarity index 100%
rename from sco_formsemestre.py
rename to app/scodoc/sco_formsemestre.py
diff --git a/sco_formsemestre_custommenu.py b/app/scodoc/sco_formsemestre_custommenu.py
similarity index 100%
rename from sco_formsemestre_custommenu.py
rename to app/scodoc/sco_formsemestre_custommenu.py
diff --git a/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
similarity index 100%
rename from sco_formsemestre_edit.py
rename to app/scodoc/sco_formsemestre_edit.py
diff --git a/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py
similarity index 100%
rename from sco_formsemestre_exterieurs.py
rename to app/scodoc/sco_formsemestre_exterieurs.py
diff --git a/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py
similarity index 100%
rename from sco_formsemestre_inscriptions.py
rename to app/scodoc/sco_formsemestre_inscriptions.py
diff --git a/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
similarity index 100%
rename from sco_formsemestre_status.py
rename to app/scodoc/sco_formsemestre_status.py
diff --git a/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py
similarity index 100%
rename from sco_formsemestre_validation.py
rename to app/scodoc/sco_formsemestre_validation.py
diff --git a/sco_formulas.py b/app/scodoc/sco_formulas.py
similarity index 100%
rename from sco_formulas.py
rename to app/scodoc/sco_formulas.py
diff --git a/sco_groups.py b/app/scodoc/sco_groups.py
similarity index 100%
rename from sco_groups.py
rename to app/scodoc/sco_groups.py
diff --git a/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py
similarity index 100%
rename from sco_groups_edit.py
rename to app/scodoc/sco_groups_edit.py
diff --git a/sco_groups_view.py b/app/scodoc/sco_groups_view.py
similarity index 100%
rename from sco_groups_view.py
rename to app/scodoc/sco_groups_view.py
diff --git a/sco_import_users.py b/app/scodoc/sco_import_users.py
similarity index 100%
rename from sco_import_users.py
rename to app/scodoc/sco_import_users.py
diff --git a/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py
similarity index 100%
rename from sco_inscr_passage.py
rename to app/scodoc/sco_inscr_passage.py
diff --git a/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
similarity index 100%
rename from sco_liste_notes.py
rename to app/scodoc/sco_liste_notes.py
diff --git a/sco_lycee.py b/app/scodoc/sco_lycee.py
similarity index 100%
rename from sco_lycee.py
rename to app/scodoc/sco_lycee.py
diff --git a/sco_modalites.py b/app/scodoc/sco_modalites.py
similarity index 100%
rename from sco_modalites.py
rename to app/scodoc/sco_modalites.py
diff --git a/sco_moduleimpl.py b/app/scodoc/sco_moduleimpl.py
similarity index 100%
rename from sco_moduleimpl.py
rename to app/scodoc/sco_moduleimpl.py
diff --git a/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py
similarity index 100%
rename from sco_moduleimpl_inscriptions.py
rename to app/scodoc/sco_moduleimpl_inscriptions.py
diff --git a/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py
similarity index 100%
rename from sco_moduleimpl_status.py
rename to app/scodoc/sco_moduleimpl_status.py
diff --git a/sco_news.py b/app/scodoc/sco_news.py
similarity index 100%
rename from sco_news.py
rename to app/scodoc/sco_news.py
diff --git a/sco_page_etud.py b/app/scodoc/sco_page_etud.py
similarity index 100%
rename from sco_page_etud.py
rename to app/scodoc/sco_page_etud.py
diff --git a/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py
similarity index 100%
rename from sco_parcours_dut.py
rename to app/scodoc/sco_parcours_dut.py
diff --git a/sco_pdf.py b/app/scodoc/sco_pdf.py
similarity index 100%
rename from sco_pdf.py
rename to app/scodoc/sco_pdf.py
diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d8cf8933e372da6a421e74013032df7363e661f
--- /dev/null
+++ b/app/scodoc/sco_permissions.py
@@ -0,0 +1,56 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+"""Definition of ScoDoc 8 permissions
+    used by auth
+"""
+# Définition des permissions: ne pas changer les numéros ou l'ordre des lignes !
+_SCO_PERMISSIONS = (
+    # permission bit, symbol, description
+    # ScoSuperAdmin est utilisé pour:
+    #   - ZScoDoc: add/delete departments
+    #   - tous rôles lors creation utilisateurs
+    (1 << 1, "ScoSuperAdmin", "Super Administrateur"),
+    (1 << 2, "ScoView", "Voir"),
+    (1 << 3, "ScoEnsView", "Voir les parties pour les enseignants"),
+    (1 << 4, "ScoObservateur", "Observer (accès lecture restreint aux bulletins)"),
+    (1 << 5, "ScoUsersAdmin", "Gérer les utilisateurs"),
+    (1 << 6, "ScoUsersView", "Voir les utilisateurs"),
+    (1 << 7, "ScoChangePreferences", "Modifier les préférences"),
+    (1 << 8, "ScoChangeFormation", "Changer les formations"),
+    (1 << 9, "ScoEditFormationTags", "Tagguer les formations"),
+    (1 << 10, "ScoEditAllNotes", "Modifier toutes les notes"),
+    (1 << 11, "ScoEditAllEvals", "Modifier toutes les evaluations"),
+    (1 << 12, "ScoImplement", "Mettre en place une formation (créer un semestre)"),
+    (1 << 13, "ScoAbsChange", "Saisir des absences"),
+    (1 << 14, "ScoAbsAddBillet", "Saisir des billets d'absences"),
+    # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche
+    (1 << 15, "ScoEtudChangeAdr", "Changer les addresses d'étudiants"),
+    (1 << 16, "ScoEtudChangeGroups", "Modifier les groupes"),
+    # aussi pour demissions, diplomes:
+    (1 << 17, "ScoEtudInscrit", "Inscrire des étudiants"),
+    # aussi pour archives:
+    (1 << 18, "ScoEtudAddAnnotations", "Éditer les annotations"),
+    (1 << 19, "ScoEntrepriseView", "Voir la section 'entreprises'"),
+    (1 << 20, "ScoEntrepriseChange", "Modifier les entreprises"),
+    (1 << 21, "ScoEditPVJury", "Éditer les PV de jury"),
+    # ajouter maquettes Apogee (=> chef dept et secr):
+    (1 << 22, "ScoEditApo", "Ajouter des maquettes Apogées"),
+)
+
+
+class Permission:
+    "Permissions for ScoDoc"
+    NBITS = 1  # maximum bits used (for formatting)
+    ALL_PERMISSIONS = [-1]
+    description = {}  # { symbol : blah blah }
+
+    @staticmethod
+    def init_permissions():
+        for (perm, symbol, description) in _SCO_PERMISSIONS:
+            setattr(Permission, symbol, perm)
+            Permission.description[symbol] = description
+        Permission.NBITS = len(_SCO_PERMISSIONS)
+
+
+Permission.init_permissions()
diff --git a/sco_photos.py b/app/scodoc/sco_photos.py
similarity index 99%
rename from sco_photos.py
rename to app/scodoc/sco_photos.py
index 9b48b9d142b1d5fe93c96925b6237fa62b092931..d6ecb9188a9be17742bf8b447c646952245cc95d 100644
--- a/sco_photos.py
+++ b/app/scodoc/sco_photos.py
@@ -53,6 +53,7 @@ from PIL import Image as PILImage
 from cStringIO import StringIO
 import glob
 
+from config import Config
 from sco_utils import CONFIG, SCO_SRC_DIR
 from notes_log import log
 
@@ -61,7 +62,7 @@ import sco_portal_apogee
 from scolog import logdb
 
 # Full paths on server's filesystem. Something like "/opt/scodoc/var/scodoc/photos"
-PHOTO_DIR = os.path.join(os.environ["INSTANCE_HOME"], "var", "scodoc", "photos")
+PHOTO_DIR = os.path.join(Config.INSTANCE_HOME, "var", "scodoc", "photos")
 ICONS_DIR = os.path.join(SCO_SRC_DIR, "static", "icons")
 UNKNOWN_IMAGE_PATH = os.path.join(ICONS_DIR, "unknown.jpg")
 UNKNOWN_IMAGE_URL = "get_photo_image?etudid="  # with empty etudid => unknown face image
diff --git a/sco_placement.py b/app/scodoc/sco_placement.py
similarity index 100%
rename from sco_placement.py
rename to app/scodoc/sco_placement.py
diff --git a/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py
similarity index 100%
rename from sco_portal_apogee.py
rename to app/scodoc/sco_portal_apogee.py
diff --git a/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py
similarity index 100%
rename from sco_poursuite_dut.py
rename to app/scodoc/sco_poursuite_dut.py
diff --git a/sco_preferences.py b/app/scodoc/sco_preferences.py
similarity index 100%
rename from sco_preferences.py
rename to app/scodoc/sco_preferences.py
diff --git a/sco_prepajury.py b/app/scodoc/sco_prepajury.py
similarity index 100%
rename from sco_prepajury.py
rename to app/scodoc/sco_prepajury.py
diff --git a/sco_pvjury.py b/app/scodoc/sco_pvjury.py
similarity index 100%
rename from sco_pvjury.py
rename to app/scodoc/sco_pvjury.py
diff --git a/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py
similarity index 100%
rename from sco_pvpdf.py
rename to app/scodoc/sco_pvpdf.py
diff --git a/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
similarity index 100%
rename from sco_recapcomplet.py
rename to app/scodoc/sco_recapcomplet.py
diff --git a/sco_report.py b/app/scodoc/sco_report.py
similarity index 100%
rename from sco_report.py
rename to app/scodoc/sco_report.py
diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py
new file mode 100644
index 0000000000000000000000000000000000000000..406daee1934ce789a7779dc041bee844abacb781
--- /dev/null
+++ b/app/scodoc/sco_roles_default.py
@@ -0,0 +1,58 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+"""Definition of ScoDoc default roles
+"""
+
+from sco_permissions import Permission as p
+
+SCO_ROLES_DEFAULTS = {
+    "Observateur": (p.ScoObservateur,),
+    "Ens": (
+        p.ScoObservateur,
+        p.ScoView,
+        p.ScoEnsView,
+        p.ScoUsersView,
+        p.ScoEtudAddAnnotations,
+        p.ScoAbsChange,
+        p.ScoAbsAddBillet,
+        p.ScoEntrepriseView,
+    ),
+    "Secr": (
+        p.ScoObservateur,
+        p.ScoView,
+        p.ScoUsersView,
+        p.ScoEtudAddAnnotations,
+        p.ScoAbsChange,
+        p.ScoAbsAddBillet,
+        p.ScoEntrepriseView,
+        p.ScoEntrepriseChange,
+        p.ScoEtudChangeAdr,
+    ),
+    # Admin est le chef du département, pas le "super admin"
+    # on dit donc lister toutes ses permissions:
+    "Admin": (
+        p.ScoObservateur,
+        p.ScoView,
+        p.ScoEnsView,
+        p.ScoUsersView,
+        p.ScoEtudAddAnnotations,
+        p.ScoAbsChange,
+        p.ScoAbsAddBillet,
+        p.ScoEntrepriseView,
+        p.ScoEntrepriseChange,
+        p.ScoEtudChangeAdr,
+        p.ScoChangeFormation,
+        p.ScoEditFormationTags,
+        p.ScoEditAllNotes,
+        p.ScoEditAllEvals,
+        p.ScoImplement,
+        p.ScoEtudChangeGroups,
+        p.ScoEtudInscrit,
+        p.ScoUsersAdmin,
+        p.ScoChangePreferences,
+    ),
+    # Super Admin est un root: création/suppression de départements
+    # _tous_ les droits
+    "SuperAdmin": p.ALL_PERMISSIONS,
+}
\ No newline at end of file
diff --git a/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
similarity index 100%
rename from sco_saisie_notes.py
rename to app/scodoc/sco_saisie_notes.py
diff --git a/sco_semset.py b/app/scodoc/sco_semset.py
similarity index 100%
rename from sco_semset.py
rename to app/scodoc/sco_semset.py
diff --git a/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py
similarity index 100%
rename from sco_synchro_etuds.py
rename to app/scodoc/sco_synchro_etuds.py
diff --git a/sco_tag_module.py b/app/scodoc/sco_tag_module.py
similarity index 100%
rename from sco_tag_module.py
rename to app/scodoc/sco_tag_module.py
diff --git a/sco_trombino.py b/app/scodoc/sco_trombino.py
similarity index 100%
rename from sco_trombino.py
rename to app/scodoc/sco_trombino.py
diff --git a/sco_trombino_tours.py b/app/scodoc/sco_trombino_tours.py
similarity index 100%
rename from sco_trombino_tours.py
rename to app/scodoc/sco_trombino_tours.py
diff --git a/sco_ue_external.py b/app/scodoc/sco_ue_external.py
similarity index 100%
rename from sco_ue_external.py
rename to app/scodoc/sco_ue_external.py
diff --git a/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py
similarity index 100%
rename from sco_undo_notes.py
rename to app/scodoc/sco_undo_notes.py
diff --git a/sco_up_to_date.py b/app/scodoc/sco_up_to_date.py
similarity index 100%
rename from sco_up_to_date.py
rename to app/scodoc/sco_up_to_date.py
diff --git a/sco_users.py b/app/scodoc/sco_users.py
similarity index 100%
rename from sco_users.py
rename to app/scodoc/sco_users.py
diff --git a/sco_utils.py b/app/scodoc/sco_utils.py
similarity index 96%
rename from sco_utils.py
rename to app/scodoc/sco_utils.py
index 022a3075dce71b3d0038962ac38cfbb77471f0e7..4a78f77835b4a2bc294fea222502809c67fc1da9 100644
--- a/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -58,6 +58,8 @@ from PIL import Image as PILImage
 from VERSION import SCOVERSION
 import VERSION
 
+from config import Config
+
 from SuppressAccents import suppression_diacritics
 from notes_log import log
 from sco_codes_parcours import NOTES_TOLERANCE, CODES_EXPL
@@ -216,32 +218,31 @@ def group_by_key(d, key):
 # ----- Global lock for critical sections (except notes_tables caches)
 GSL = thread.allocate_lock()  # Global ScoDoc Lock
 
-if "INSTANCE_HOME" in os.environ:
-    # ----- Repertoire "var" (local)
-    SCODOC_VAR_DIR = os.path.join(os.environ["INSTANCE_HOME"], "var", "scodoc")
-    # ----- Repertoire "config" modifiable
-    #        /opt/scodoc/var/scodoc/config
-    SCODOC_CFG_DIR = os.path.join(SCODOC_VAR_DIR, "config")
-    # ----- Version information
-    SCODOC_VERSION_DIR = os.path.join(SCODOC_CFG_DIR, "version")
-    # ----- Repertoire tmp
-    SCO_TMP_DIR = os.path.join(SCODOC_VAR_DIR, "tmp")
-    if not os.path.exists(SCO_TMP_DIR):
-        os.mkdir(SCO_TMP_DIR, 0o755)
-    # ----- Les logos: /opt/scodoc/var/scodoc/config/logos
-    SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos")
-
-    # Dans les sources:
-    SCO_SRC_DIR = os.path.join(os.environ["INSTANCE_HOME"], "Products", "ScoDoc")
-    #  - Les outils distribués
-    SCO_TOOLS_DIR = os.path.join(SCO_SRC_DIR, "config")
+# ----- Repertoire "var" (local)
+SCODOC_VAR_DIR = os.path.join(Config.INSTANCE_HOME, "var", "scodoc")
+# ----- Repertoire "config" modifiable
+#        /opt/scodoc/var/scodoc/config
+SCODOC_CFG_DIR = os.path.join(SCODOC_VAR_DIR, "config")
+# ----- Version information
+SCODOC_VERSION_DIR = os.path.join(SCODOC_CFG_DIR, "version")
+# ----- Repertoire tmp
+SCO_TMP_DIR = os.path.join(SCODOC_VAR_DIR, "tmp")
+if not os.path.exists(SCO_TMP_DIR):
+    os.mkdir(SCO_TMP_DIR, 0o755)
+# ----- Les logos: /opt/scodoc/var/scodoc/config/logos
+SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos")
+
+# Dans les sources:
+SCO_SRC_DIR = os.path.join(Config.INSTANCE_HOME, "Products", "ScoDoc")
+#  - Les outils distribués
+SCO_TOOLS_DIR = os.path.join(SCO_SRC_DIR, "config")
 
 
 # ----- Lecture du fichier de configuration
 import sco_config
 import sco_config_load
 
-sco_config_load.load_local_configuration()
+sco_config_load.load_local_configuration(SCODOC_CFG_DIR)
 CONFIG = sco_config.CONFIG
 if hasattr(CONFIG, "CODES_EXPL"):
     CODES_EXPL.update(
diff --git a/sco_zope.py b/app/scodoc/sco_zope.py
similarity index 100%
rename from sco_zope.py
rename to app/scodoc/sco_zope.py
diff --git a/scolars.py b/app/scodoc/scolars.py
similarity index 100%
rename from scolars.py
rename to app/scodoc/scolars.py
diff --git a/scolog.py b/app/scodoc/scolog.py
similarity index 100%
rename from scolog.py
rename to app/scodoc/scolog.py
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
new file mode 100644
index 0000000000000000000000000000000000000000..6b775b738303ea4d244b6272ec38342f86a60313
--- /dev/null
+++ b/app/templates/auth/login.html
@@ -0,0 +1,15 @@
+{% extends 'base.html' %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+<h1>Sign In</h1>
+<div class="row">
+    <div class="col-md-4">
+        {{ wtf.quick_form(form) }}
+    </div>
+</div>
+<br>
+Forgot Your Password?
+<a href="{{ url_for('auth.reset_password_request') }}">Click to Reset It</a>
+</p>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html
new file mode 100644
index 0000000000000000000000000000000000000000..35e6a2abd1649fe4b35af43f146067f98e54ac85
--- /dev/null
+++ b/app/templates/auth/register.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+<h1>Création utilisateur</h1>
+<div class="row">
+    <div class="col-md-4">
+        {{ wtf.quick_form(form) }}
+    </div>
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html
new file mode 100644
index 0000000000000000000000000000000000000000..d054674f68ef813650c9ae05fd21cfba870c9fe3
--- /dev/null
+++ b/app/templates/auth/reset_password.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+<h1>Reset Your Password</h1>
+<div class="row">
+    <div class="col-md-4">
+        {{ wtf.quick_form(form) }}
+    </div>
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/auth/reset_password_request.html b/app/templates/auth/reset_password_request.html
new file mode 100644
index 0000000000000000000000000000000000000000..6fc7329f3ddb0a63cc43a95926ca03f1bc0e3b1c
--- /dev/null
+++ b/app/templates/auth/reset_password_request.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+<h1>Reset Password</h1>
+<div class="row">
+    <div class="col-md-4">
+        {{ wtf.quick_form(form) }}
+    </div>
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/base.html b/app/templates/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..15a9ee46b6e72fcdc992b23c84734eaa16a8e0f2
--- /dev/null
+++ b/app/templates/base.html
@@ -0,0 +1,60 @@
+{% extends 'bootstrap/base.html' %}
+
+{% block title %}
+{% if title %}{{ title }} - ScoDoc{% else %}Welcome to ScoDoc{% endif %}
+{% endblock %}
+
+{% block navbar %}
+<nav class="navbar navbar-default">
+    <div class="container">
+        <div class="navbar-header">
+            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse"
+                data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
+                <span class="sr-only">Toggle navigation</span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+            </button>
+            <a class="navbar-brand" href="{{ url_for('main.index') }}">ScoDoc</a>
+        </div>
+        <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
+            <ul class="nav navbar-nav">
+                <li><a href="{{ url_for('main.index') }}">Home</a></li>
+                <li><a href="{{ url_for('main.hello') }}">Hello</a></li>
+            </ul>
+            <ul class="nav navbar-nav navbar-right">
+                {% if current_user.is_anonymous %}
+                <li><a href="{{ url_for('auth.login') }}">Login</a></li>
+                {% else %}
+                <li>{{current_user.username}}</li>
+                <li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
+                {% endif %}
+            </ul>
+        </div>
+    </div>
+</nav>
+{% endblock %}
+
+{% block content %}
+<div class="container">
+    {% with messages = get_flashed_messages() %}
+    {% if messages %}
+    {% for message in messages %}
+    <div class="alert alert-info" role="alert">{{ message }}</div>
+    {% endfor %}
+    {% endif %}
+    {% endwith %}
+
+    {# application content needs to be provided in the app_content block #}
+    {% block app_content %}{% endblock %}
+</div>
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{{ moment.include_moment() }}
+{{ moment.lang(g.locale) }}
+<script>
+
+</script>
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/email/reset_password.html b/app/templates/email/reset_password.html
new file mode 100644
index 0000000000000000000000000000000000000000..e928c6c9f0a6ccd1624049f4e9d8e352fee3ed31
--- /dev/null
+++ b/app/templates/email/reset_password.html
@@ -0,0 +1,16 @@
+<p>Bonjour {{ user.username }},</p>
+<p>
+    Pour réinitialiser votre mot de passe ScoDoc,
+    <a href="{{ url_for('auth.reset_password', token=token, _external=True) }}">
+        cliquez sur ce lien
+    </a>.
+</p>
+<p>Vous pouvez aussi copier ce lien dans votre navigateur Web::</p>
+<p>{{ url_for('auth.reset_password', token=token, _external=True) }}</p>
+
+<p>Si vous n'avez pas demandé à réinitialiser votre mot de passe sur
+    ScoDoc, vous pouvez simplement ignorer ce message.
+</p>
+
+
+<p>A bientôt !</p>
\ No newline at end of file
diff --git a/app/templates/email/reset_password.txt b/app/templates/email/reset_password.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ea5f14fda078ef6ddae051ce3c48614219818ff3
--- /dev/null
+++ b/app/templates/email/reset_password.txt
@@ -0,0 +1,12 @@
+Bonjour {{ user.username }},
+
+Pour réinitialiser votre mot de passe ScoDoc, suivre le lien:
+
+{{ url_for('auth.reset_password', token=token, _external=True) }}
+
+
+Si vous n'avez pas demandé à réinitialiser votre mot de passe sur
+ScoDoc, vous pouvez simplement ignorer ce message.
+
+A bientôt !
+
diff --git a/app/templates/main/index.html b/app/templates/main/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..efee516ededbd6081671bbd2cb16ee9f05746a54
--- /dev/null
+++ b/app/templates/main/index.html
@@ -0,0 +1,43 @@
+{% extends 'base.html' %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+<h1>Essais Flask pour ScoDoc 8: accueil</h1>
+<div class="row">
+    <h2>Avec login requis</h2>
+    <ul>
+        <li><a href="{{ url_for('main.test_vue') }}"><tt>test_vue</tt>, login requis</a></li>
+        <li><a href="{{ url_for('main.a_zope_function', y=22) }}">a_zope_function</a> : affichage objets "Zope"</li>
+        <li><a href="{{ url_for('main.a_zope_function', x=11, y=22) }}">a_zope_function?x=22</a> : avec parametre
+            <tt>x=11, y=22</tt>
+        </li>
+    </ul>
+    <h2>Sans login</h2>
+    <ul>
+        <li><a href="{{ url_for('main.a_zope_form_get') }}"><tt>a_zope_form_get</tt></a> : formulaire GET ala ScoDoc non
+            protégé renvoyant vers une page protégée</li>
+        <li><a href="{{ url_for('main.a_zope_form_post') }}"><tt>a_zope_form_post</tt></a> : formulaire POST ala ScoDoc
+            non protégé renvoyant vers une page protégée</li>
+
+
+        <li><a href="{{ url_for('main.formsemestre_status', dept_id='RT') }}"><tt>formsemestre_status</tt></a> : a-t-on
+            le rôle EnsRT ?
+        </li>
+        <li><a href="ScoDoc/RO/Scolarite/sco_exemple?etudid=E123"><tt>sco_exemple?etudid=E123</tt> : test "scodoc", url
+                manuelle</a></li>
+        <li><a href="{{ url_for('notes.sco_exemple2' , scodoc_dept='RT') }}"><tt>sco_exemple2</tt></a> : test appel
+            entre vues
+        </li>
+
+        <li><a href="{{ url_for('notes.sco_get_version' , scodoc_dept='RT') }}"><tt>sco_get_version</tt></a> : test
+            appel vers ScoDoc interne (arg. REQUEST positionnel).
+        </li>
+
+        <li><a href="{{ url_for('notes.sco_test_view' , scodoc_dept='RT') }}"><tt>sco_get_version</tt></a> : vue
+            protégée par perm. VIEW.
+        </li>
+
+    </ul>
+</div>
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/__init__.py b/app/views/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4209247752f820edc5891d45489bb8797b7afa5
--- /dev/null
+++ b/app/views/__init__.py
@@ -0,0 +1,8 @@
+# -*- coding: UTF-8 -*
+"""ScoDoc Flask views
+"""
+from flask import Blueprint
+
+notes_bp = Blueprint("notes", __name__)
+
+from app.views import notes
diff --git a/app/views/notes.py b/app/views/notes.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2aa981d8075cd1dcb0d0c4a780c4fd1d1a21ab1
--- /dev/null
+++ b/app/views/notes.py
@@ -0,0 +1,61 @@
+# -*- coding: UTF-8 -*
+"""Module scodoc: un exemple de fonctions
+"""
+from flask import g
+from flask import current_app
+
+from app.decorators import (
+    scodoc7func,
+    ScoDoc7Context,
+    permission_required,
+    admin_required,
+    login_required,
+)
+from app.auth.models import Permission
+
+from app.views import notes_bp as bp
+
+# import sco_core deviendra:
+from app.ScoDoc import sco_core
+
+context = ScoDoc7Context(globals())
+
+
+@bp.route("/<scodoc_dept>/Scolarite/sco_exemple")
+@scodoc7func
+def sco_exemple(etudid="NON"):
+    """Un exemple de fonction ScoDoc 7"""
+    return """<html>
+        <body><h1>ScoDoc 7 rules !</h1>
+        <p>etudid=%(etudid)s</p>
+        <p>g.scodoc_dept=%(scodoc_dept)s</p>
+        </body>
+        </html>
+        """ % {
+        "etudid": etudid,
+        "scodoc_dept": g.scodoc_dept,
+    }
+
+
+# En ScoDoc 7, on a souvent des vues qui en appellent d'autres
+# avec context.sco_exemple( etudid="E12" )
+@bp.route("/<scodoc_dept>/Scolarite/sco_exemple2")
+@login_required
+@scodoc7func
+def sco_exemple2():
+    return "Exemple 2" + context.sco_exemple(etudid="deux")
+
+
+# Test avec un seul argument REQUEST positionnel
+@bp.route("/<scodoc_dept>/Scolarite/sco_get_version")
+@scodoc7func
+def sco_get_version(REQUEST):
+    return sco_core.sco_get_version(REQUEST)
+
+
+# Fonction ressemblant à une méthode Zope protégée
+@bp.route("/<scodoc_dept>/Scolarite/sco_test_view")
+@scodoc7func
+@permission_required(Permission.ScoView)
+def sco_test_view(REQUEST=None):
+    return """Vous avez vu sco_test_view !"""
diff --git a/config.py b/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6db8dd9dc1db4c31d46ce9a1afc95b15891d2aa
--- /dev/null
+++ b/config.py
@@ -0,0 +1,31 @@
+# -*- coding: UTF-8 -*
+
+import os
+from dotenv import load_dotenv
+
+BASEDIR = os.path.abspath(os.path.dirname(__file__))
+load_dotenv(os.path.join(BASEDIR, ".env"))
+
+
+class Config(object):
+    """General configution. Mostly loaded from environment via .env"""
+
+    SECRET_KEY = os.environ.get("SECRET_KEY") or "un-grand-secret-introuvable"
+    SQLALCHEMY_DATABASE_URI = (
+        os.environ.get("DATABASE_URL") or "postgresql://scodoc@localhost:5432/SCO8USERS"
+    )
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+    LOG_TO_STDOUT = os.environ.get("LOG_TO_STDOUT")
+    MAIL_SERVER = os.environ.get("MAIL_SERVER")
+    MAIL_PORT = int(os.environ.get("MAIL_PORT") or 25)
+    MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None
+    MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
+    MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
+    LANGUAGES = ["fr", "en"]  # unused for now
+    SCODOC_ADMIN_MAIL = os.environ.get("SCODOC_ADMIN_MAIL")
+    SCODOC_ADMIN_LOGIN = os.environ.get("SCODOC_ADMIN_LOGIN") or "admin"
+    ADMINS = [SCODOC_ADMIN_MAIL]
+    SCODOC_ERR_MAIL = os.environ.get("SCODOC_ERR_MAIL")
+    BOOTSTRAP_SERVE_LOCAL = os.environ.get("BOOTSTRAP_SERVE_LOCAL")
+    # for ScoDoc 7 compat (à changer)
+    INSTANCE_HOME = os.environ.get("INSTANCE_HOME", "/opt/scodoc")
\ No newline at end of file
diff --git a/dtml/docLogin.dtml b/dtml/docLogin.dtml
deleted file mode 100644
index fd46ec9f8bebb0af92dcca1c6957828f3f7db4a2..0000000000000000000000000000000000000000
--- a/dtml/docLogin.dtml
+++ /dev/null
@@ -1,49 +0,0 @@
-<dtml-var standard_html_header>
-<center>
-<dtml-if authFailedCode>
-<dtml-call "REQUEST.set('loginTitle', getAuthFailedMessage(authFailedCode))">
-<dtml-else>
-<dtml-call "REQUEST.set('loginTitle', 'Identifiez vous sur ScoDoc')">
-</dtml-if>
-<dtml-var "DialogHeader(_.None,_,DialogTitle=loginTitle)">
-<P>
-<dtml-if destination>
-<FORM ACTION="&dtml-destination;" METHOD="POST">
-<dtml-else>
-<FORM ACTION="&dtml-URL;" METHOD="POST">
-</dtml-if>
-
-<dtml-var "query_string_to_form_inputs(QUERY_STRING)"> <dtml-comment> Added by Emmanuel for ScoDoc</dtml-comment>
-
-
-<TABLE>
-<TR>
-  <TD ALIGN="LEFT" VALIGN="TOP">
-  <STRONG><dtml-babel src="'en'">Nom</dtml-babel></STRONG>
-  </TD>
-  <TD ALIGN="LEFT" VALIGN="TOP">
-  <INPUT TYPE="TEXT" NAME="__ac_name" SIZE="20">
-  </TD>
-</TR>
-
-<TR>
-  <TD ALIGN="LEFT" VALIGN="TOP">
-  <STRONG><dtml-babel src="'en'">Mot de passe</dtml-babel></STRONG>
-  </TD>
-  <TD ALIGN="LEFT" VALIGN="TOP">
-  <INPUT TYPE="PASSWORD" NAME="__ac_password" SIZE="20">
-  </TD>
-</TR>
-<TR>
-  <TD ALIGN="LEFT" VALIGN="TOP">
-  </TD>
-</TR>
-</TABLE>
-<center>
-<INPUT TYPE="SUBMIT" NAME="submit" VALUE=" <dtml-babel src="'en'">Ok</dtml-babel> ">
-</center>
-</FORM>
-<br>
-<dtml-var DialogFooter>
-</center>
-<dtml-var standard_html_footer>
diff --git a/dtml/docLogout.dtml b/dtml/docLogout.dtml
deleted file mode 100644
index 5210fc745ded506c448389a3b7e1dfc4a4703ff3..0000000000000000000000000000000000000000
--- a/dtml/docLogout.dtml
+++ /dev/null
@@ -1,16 +0,0 @@
-<dtml-var standard_html_header>
-<P>
-<CENTER>
-<p>Vous êtes déconnecté de ScoDoc.
-</p>
-
-<p><a href="<dtml-var "BASE0">">revenir à l'accueil</a></p>
-
-<br/>
-<p><em style="color: red;">(Attention: si vous êtes administrateur, vous ne pouvez vous déconnecter complètement qu'en relançant votre navigateur)
-</em></p>
-</CENTER>
-
-
-
-<dtml-var standard_html_footer>
diff --git a/dtml/manage_addZNotesForm.dtml b/dtml/manage_addZNotesForm.dtml
deleted file mode 100644
index e58b49e021b7d954a87bd2d89e6881bc1ef186fa..0000000000000000000000000000000000000000
--- a/dtml/manage_addZNotesForm.dtml
+++ /dev/null
@@ -1,49 +0,0 @@
-<dtml-var manage_page_header>
-
-<dtml-var "manage_form_title(this(), _,
-           form_title='Add Notes',
-           help_product='ZNotes',
-           help_topic='ZNotes-add.stx'
-           )">
-
-<div class="form-help">
-<p>
-Notes Objects are very usefull thus not documented yet...
-</p>
-</div>
-
-<form name="form" action="manage_addZNotes"><br>
-
-<table cellspacing="0" cellpadding="2" border="0">
-  <tr>
-    <td align="left" valign="top">
-    <div class="form-label">
-    Id
-    </div>
-    </td>
-    <td align="left" valign="top">
-    <input type="text" name="id" size="40" value="" />
-    </td>
-  </tr>
-
-  <tr>
-    <td align="left" valign="top">
-    <div class="form-optional">
-    Title
-    </div>
-    </td>
-    <td align="left" valign="top">
-    <input type="text" name="title" size="40" />
-    </td>
-  </tr>
-
-  <div class="form-element">
-    <input class="form-element" 
-           type="submit" 
-  	   name="submit" 
-	   value=" Add " 
-	   />
-  </div>
-</form>
-
-<dtml-var manage_page_header>
diff --git a/dtml/manage_addZScolarForm.dtml b/dtml/manage_addZScolarForm.dtml
deleted file mode 100644
index 580a83e18ab7a693debdffedf6aa3a431b4d78cc..0000000000000000000000000000000000000000
--- a/dtml/manage_addZScolarForm.dtml
+++ /dev/null
@@ -1,62 +0,0 @@
-<dtml-var manage_page_header>
-
-<dtml-var "manage_form_title(this(), _,
-           form_title='Add ZScolar',
-           help_product='ZScolar',
-           help_topic='ZScolar-add.stx'
-           )">
-
-<div class="form-help">
-<p>
-ZScolar: gestion scolarite d'un departement
-</p>
-</div>
-
-<form name="form" action="manage_addZScolar"><br>
-
-<table cellspacing="0" cellpadding="2" border="0">
-  <tr>
-    <td align="left" valign="top">
-    <div class="form-label">
-    Id
-    </div>
-    </td>
-    <td align="left" valign="top">
-    <input type="text" name="id" size="40" value="" />
-    </td>
-  </tr>
-
-  <tr>
-    <td align="left" valign="top">
-    <div class="form-optional">
-    Title
-    </div>
-    </td>
-    <td align="left" valign="top">
-    <input type="text" name="title" size="40" />
-    </td>
-  </tr>
-
-
-  <tr>
-    <td align="left" valign="top">
-    <div class="form-optional">
-    DB connexion string
-    </div>
-    </td>
-    <td align="left" valign="top">
-    <input type="text" name="db_cnx_string" size="80" value="user=zopeuser dbname=SCOGTR password=XXXX host=localhost" />
-    </td>
-  </tr>
-
-
-  <div class="form-element">
-    <input class="form-element" 
-           type="submit" 
-  	   name="submit" 
-	   value=" Add " 
-	   />
-  </div>
-</form>
-
-<dtml-var manage_page_header>
diff --git a/dtml/manage_editZNotesForm.dtml b/dtml/manage_editZNotesForm.dtml
deleted file mode 100644
index 456b631f2b3e8865022f1b00e27753b1dfda21d9..0000000000000000000000000000000000000000
--- a/dtml/manage_editZNotesForm.dtml
+++ /dev/null
@@ -1,21 +0,0 @@
-<dtml-var manage_page_header>
-<dtml-var manage_tabs>
-
-<dtml-var "manage_form_title(this(), _,
-           form_title='Edit ZNotes',
-	   help_product='ZNotes',
-	   help_topic='ZNotes-edit.stx'
-	   )">
-
-
-<form name="form" action="manage_editAction" method="post"><br/>
-  id: <dtml-var id><br/>
-  title: <input type="text" name="title:string" size="30" value="<dtml-var title>"><br/>
-  <div class="form-element">
-    <input class="form-element" type="submit" value="Save Changes">
-  </div>
-</form>
-
-
-<dtml-var manage_page_footer>
-
diff --git a/dtml/manage_editZScolarForm.dtml b/dtml/manage_editZScolarForm.dtml
deleted file mode 100644
index aab8859e102c5c6fbfb4bb698f8bf1291db77289..0000000000000000000000000000000000000000
--- a/dtml/manage_editZScolarForm.dtml
+++ /dev/null
@@ -1,21 +0,0 @@
-<dtml-var manage_page_header>
-<dtml-var manage_tabs>
-
-<dtml-var "manage_form_title(this(), _,
-           form_title='Edit ZScolar',
-	   help_product='ZScolar',
-	   help_topic='ZScolar-edit.stx'
-	   )">
-
-
-<form name="form" action="manage_editAction" method="post"><br/>
-  id: <dtml-var id><br/>
-  title: <input type="text" name="title:string" size="30" value="<dtml-var title>"><br/>
-  <div class="form-element">
-    <input class="form-element" type="submit" value="Save Changes">
-  </div>
-</form>
-
-
-<dtml-var manage_page_footer>
-
diff --git a/csv2rules.py b/misc/csv2rules.py
similarity index 100%
rename from csv2rules.py
rename to misc/csv2rules.py
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..849b88ee720950062527c36dca78c127ffbc7c4c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,37 @@
+alembic==1.5.5
+attrdict==2.0.1
+Babel==2.9.0
+blinker==1.4
+click==7.1.2
+dnspython==1.16.0
+dominate==2.6.0
+email-validator==1.1.2
+Flask==1.1.4
+Flask-Babel==2.0.0
+Flask-Bootstrap==3.3.7.1
+Flask-Login==0.5.0
+Flask-Mail==0.9.1
+Flask-Migrate==2.7.0
+Flask-Moment==0.11.0
+Flask-SQLAlchemy==2.4.4
+Flask-WTF==0.14.3
+idna==2.10
+itsdangerous==1.1.0
+jaxml==3.2
+Jinja2==2.11.2
+Mako==1.1.4
+MarkupSafe==1.1.1
+Pillow==6.2.2
+pkg-resources==0.0.0
+psycopg2==2.8.6
+PyJWT==1.7.1
+python-dateutil==2.8.1
+python-dotenv==0.15.0
+python-editor==1.0.4
+pytz==2021.1
+six==1.15.0
+SQLAlchemy==1.3.23
+typing==3.7.4.3
+visitor==0.1.3
+Werkzeug==1.0.1
+WTForms==2.3.3
diff --git a/sco_permissions.py b/sco_permissions.py
deleted file mode 100644
index 7a05f3a7cdd7263cd29061ecaec71f7345b15821..0000000000000000000000000000000000000000
--- a/sco_permissions.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# -*- mode: python -*-
-# -*- coding: utf-8 -*-
-
-"""Definitions of Zope permissions used by ScoDoc"""
-
-# prefix all permissions by "Sco" to group them in Zope management tab
-
-ScoChangeFormation = "Sco Change Formation"
-ScoEditAllNotes = "Sco Modifier toutes notes"
-ScoEditAllEvals = "Sco Modifier toutes les evaluations"
-
-ScoImplement = "Sco Implement Formation"
-
-ScoAbsChange = "Sco Change Absences"
-ScoAbsAddBillet = (
-    "Sco Add Abs Billet"  # ajouter un billet d'absence via AddBilletAbsence
-)
-ScoEtudChangeAdr = "Sco Change Etud Address"  # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche
-ScoEtudChangeGroups = "Sco Change Etud Groups"
-ScoEtudInscrit = "Sco Inscrire Etud"  # aussi pour demissions, diplomes
-ScoEtudAddAnnotations = "Sco Etud Add Annotations"  # aussi pour archives
-ScoEtudSupprAnnotations = "Sco Etud Suppr Annotations"  # XXX inutile: utiliser Add !
-ScoEntrepriseView = "Sco View Entreprises"
-ScoEntrepriseChange = "Sco Change Entreprises"
-ScoEditPVJury = "Sco Edit PV Jury"
-
-ScoEditApo = ScoEtudChangeAdr  # ajouter maquettes Apogee (=> chef dept et secr)
-
-ScoEditFormationTags = (
-    "Sco Tagguer les formations"  # mettre/modifier des tags sur les modules
-)
-
-ScoView = "Sco View"
-ScoEnsView = "Sco View Ens"  # parties visibles par enseignants slt
-ScoObservateur = "Sco Observateur"  # accès lecture restreint aux bulletins
-ScoUsersAdmin = "Sco Users Manage"
-ScoUsersView = "Sco Users View"
-
-ScoChangePreferences = "Sco Change Preferences"
-
-ScoSuperAdmin = "Sco Super Admin"
-# ScoSuperAdmin est utilisé pour:
-#   - ZScoDoc: add/delete departments
-#   - tous rôles lors creation utilisateurs
-#
-
-
-# Default permissions for default roles
-# (set once on instance creation):
-Sco_Default_Permissions = {
-    ScoObservateur: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoView: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoEnsView: ("Ens", "Admin", "RespPe"),
-    ScoUsersView: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoEtudAddAnnotations: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoEtudSupprAnnotations: ("Admin",),
-    ScoAbsChange: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoAbsAddBillet: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoEntrepriseView: ("Ens", "Secr", "Admin", "RespPe"),
-    ScoEntrepriseChange: ("Secr", "Admin"),
-    ScoEtudChangeAdr: ("Secr", "Admin"),  # utilisé aussi pour pv jury secretariats
-    ScoChangeFormation: ("Admin",),
-    ScoEditFormationTags: ("Admin", "RespPe"),
-    ScoEditAllNotes: ("Admin",),
-    ScoEditAllEvals: ("Admin",),
-    ScoImplement: ("Admin",),
-    ScoEtudChangeGroups: ("Admin",),
-    ScoEtudInscrit: ("Admin",),
-    ScoUsersAdmin: ("Admin",),
-    ScoChangePreferences: ("Admin",),
-    ScoSuperAdmin: (),  # lister tt les permissions
-}
diff --git a/scodoc.py b/scodoc.py
new file mode 100755
index 0000000000000000000000000000000000000000..68b7b0e86d188ecdda8a92f0e0c04840caa54298
--- /dev/null
+++ b/scodoc.py
@@ -0,0 +1,75 @@
+# -*- coding: UTF-8 -*
+
+
+"""Application Flask: ScoDoc
+
+
+"""
+
+
+from __future__ import print_function
+
+from pprint import pprint as pp
+
+
+import click
+import flask
+
+from app import create_app, cli, db
+from app.auth.models import User, Role, UserRole
+
+from config import Config
+
+
+app = create_app()
+cli.register(app)
+
+
+@app.shell_context_processor
+def make_shell_context():
+    return {
+        "db": db,
+        "User": User,
+        "Role": Role,
+        "UserRole": UserRole,
+        "pp": pp,
+        "flask": flask,
+        "current_app": flask.current_app,
+        "cleardb": _cleardb,
+    }
+
+
+@app.cli.command()
+def inituserdb():
+    """Initialize the users database."""
+    click.echo("Init the db")
+    # Create roles:
+    Role.insert_roles()
+    click.echo("created initial roles")
+    # Ensure that admin exists
+    if Config.SCODOC_ADMIN_MAIL:
+        admin_username = Config.SCODOC_ADMIN_LOGIN
+        user = User.query.filter_by(username=admin_username).first()
+        if not user:
+            user = User(username=admin_username, email=Config.SCODOC_ADMIN_MAIL)
+            db.session.add(user)
+            db.session.commit()
+            click.echo(
+                "created initial admin user, login: {u.username}, email: {u.email}".format(
+                    u=user
+                )
+            )
+
+
+@app.cli.command()
+def clearuserdb():
+    """Erase (drop) all tables of users database !"""
+    click.echo("Erasing the db !")
+    _cleardb()
+
+
+def _cleardb():
+    """Erase (drop) all tables of users database !"""
+    db.reflect()
+    db.drop_all()
+    db.session.commit()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..adc64cee78bbbc99a726f08ca2e398bd3dabe09c
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,2 @@
+#
+import tests.test_users
diff --git a/tests/test_users.py b/tests/test_users.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0a2f1159c6b99366bb4b1ca4d485d24b4f8dcb1
--- /dev/null
+++ b/tests/test_users.py
@@ -0,0 +1,115 @@
+# -*- coding: UTF-8 -*
+
+"""Unit tests for auth (users/roles/permission management)
+
+Usage: python -m unittest tests.test_users
+"""
+
+import os
+import unittest
+
+from flask import current_app
+
+from app import app, db
+from app.auth.models import User, Role, Permission
+from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
+
+
+DEPT = "XX"
+
+
+class UserModelCase(unittest.TestCase):
+    def setUp(self):
+        app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://"
+        app.app_context().push()
+        db.create_all()
+        Role.insert_roles()
+
+    def tearDown(self):
+        db.session.remove()
+        db.drop_all()
+
+    def test_password_hashing(self):
+        u = User(username="susan")
+        u.set_password("cat")
+        self.assertFalse(u.check_password("dog"))
+        self.assertTrue(u.check_password("cat"))
+
+    def test_roles_permissions(self):
+        perm = Permission.ScoAbsChange  # une permission au hasard
+        role = Role(name="test")
+        self.assertFalse(role.has_permission(perm))
+        role.add_permission(perm)
+        self.assertTrue(role.has_permission(perm))
+        role.remove_permission(perm)
+        self.assertFalse(role.has_permission(perm))
+        # Default roles:
+        Role.insert_roles()
+        # Bien présents ?
+        role_names = [r.name for r in Role.query.filter_by().all()]
+        self.assertTrue(len(role_names) == len(SCO_ROLES_DEFAULTS))
+        self.assertTrue("Ens" in role_names)
+        self.assertTrue("Secr" in role_names)
+        self.assertTrue("Admin" in role_names)
+        # Les permissions de "Ens":
+        role = Role.query.filter_by(name="Ens").first()
+        self.assertTrue(role)
+        self.assertTrue(role.has_permission(Permission.ScoView))
+        self.assertTrue(role.has_permission(Permission.ScoAbsChange))
+        # Permissions de Admin
+        role = Role.query.filter_by(name="Admin").first()
+        self.assertTrue(role.has_permission(Permission.ScoEtudChangeAdr))
+        # Permissions de Secr
+        role = Role.query.filter_by(name="Secr").first()
+        self.assertTrue(role.has_permission(Permission.ScoEtudChangeAdr))
+        self.assertFalse(role.has_permission(Permission.ScoEditAllNotes))
+
+    def test_users_roles(self):
+        dept = "XX"
+        perm = Permission.ScoAbsChange
+        perm2 = Permission.ScoView
+        u = User(username="un enseignant")
+        db.session.add(u)
+        self.assertFalse(u.has_permission(perm, dept))
+        r = Role.get_named_role("Ens")
+        if not r:
+            r = Role(name="Ens", permissions=perm)
+        u.add_role(r, dept)
+        self.assertTrue(u.has_permission(perm, dept))
+        u = User(username="un autre")
+        u.add_role(r, dept)
+        db.session.add(u)
+        db.session.commit()
+        self.assertTrue(u.has_permission(perm, dept))
+        r2 = Role.get_named_role("Secr")
+        if not r2:
+            r2 = Role(name="Secr", dept=dept, permissions=perm2)
+        u.add_roles([r, r2], dept)
+        self.assertTrue(len(u.roles) == 2)
+        u = User(username="encore un")
+        db.session.add(u)
+        db.session.commit()
+        u.set_roles([r, r2], dept)
+        print(u.roles)
+        self.assertTrue(len(u.roles) == 2)
+        self.assertTrue(u.has_permission(perm, dept))
+        self.assertTrue(u.has_permission(perm2, dept))
+        # et pas accès aux autres dept:
+        self.assertFalse(u.has_permission(perm, dept + "X"))
+        self.assertFalse(u.has_permission(perm, None))
+
+    def test_user_admin(self):
+        dept = "XX"
+        perm = 0x1234  # a random perm
+        u = User(username="un admin", email=current_app.config["SCODOC_ADMIN_MAIL"])
+        db.session.add(u)
+        self.assertTrue(len(u.roles) == 1)
+        self.assertTrue(u.has_permission(perm, dept))
+        # Le grand admin a accès à tous les départements:
+        self.assertTrue(u.has_permission(perm, dept + "XX"))
+        self.assertTrue("Admin" == u.roles[0].name)
+
+
+if __name__ == "__main__":
+    app.app_context().push()
+    unittest.main(verbosity=2)