diff --git a/.gitignore b/.gitignore index f69b51fe4..6d49cf2c8 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 b5a53fcac..e9c058b16 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 Documentation utilisateur: -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 4041532f0..000000000 --- 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 000000000..61fa4045c --- /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 000000000..7bb621abd --- /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 000000000..4fb9575d9 --- /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 000000000..d067bef27 --- /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 000000000..4dcb11831 --- /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 000000000..57622ba8f --- /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 "".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 "".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 "".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 000000000..96cc40c6f --- /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/", 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 000000000..25ae4d488 --- /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 000000000..9816e53a6 --- /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 `//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 000000000..f2d8164d8 --- /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 000000000..0f4acc044 --- /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 000000000..a27dd27d7 --- /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 000000000..d36545b94 --- /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. Retour à l'accueil""" + + +def get_request_infos(): + return [ + "

request.base_url=%s

" % request.base_url, + "

request.url_root=%s

" % request.url_root, + "

request.query_string=%s

" % 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() + [ + "

x=%s

" % x, + "

y=%s

" % y, + "

URL=%s

" % REQUEST.URL, + "

QUERY_STRING=%s

" % REQUEST.QUERY_STRING, + "

AUTHENTICATED_USER=%s

" % REQUEST.AUTHENTICATED_USER, + ] + H.append("

form=%s

" % REQUEST.form) + H.append("

form[x]=%s

" % REQUEST.form.get("x", "non fourni")) + + return "\n".join(H) + + +@bp.route("/zopeform_get") +@scodoc7func +def a_zope_form_get(REQUEST=None): + H = [ + """

Formulaire GET

+
+ x :
+ y :
+ fichier :
+ +
+ """ + % flask.url_for("main.a_zope_function") + ] + return "\n".join(H) + + +@bp.route("/zopeform_post") +@scodoc7func +def a_zope_form_post(REQUEST=None): + H = [ + """

Formulaire POST

+
+ x :
+ y :
+ fichier :
+ +
+ """ + % flask.url_for("main.a_zope_function") + ] + return "\n".join(H) + + +@bp.route("/ScoDoc//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 + """ + return u"""dept_id=%s , formsemestre_id=%s Retour à l'accueil""" % ( + dept_id, + formsemestre_id, + ) + + +@bp.route("/hello/world") +def hello(): + H = get_request_infos() + [ + "

Hello, World! %s count=%s

" % (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 000000000..0205be7bf --- /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 98fd40238..56e94e321 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 70a0553b0..2650f0e92 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 b980c9d49..e91e2f203 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 000000000..8e8c2e5f2 --- /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 """

%s

""" % 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 000000000..9d8cf8933 --- /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 9b48b9d14..d6ecb9188 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 000000000..406daee19 --- /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 022a3075d..4a78f7783 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") +# ----- 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(os.environ["INSTANCE_HOME"], "Products", "ScoDoc") - # - Les outils distribués - SCO_TOOLS_DIR = os.path.join(SCO_SRC_DIR, "config") +# 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 000000000..6b775b738 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Sign In

+
+
+ {{ wtf.quick_form(form) }} +
+
+
+Forgot Your Password? +Click to Reset It +

+{% 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 000000000..35e6a2abd --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Création utilisateur

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% 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 000000000..d054674f6 --- /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 %} +

Reset Your Password

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% 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 000000000..6fc7329f3 --- /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 %} +

Reset Password

+
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 000000000..15a9ee46b --- /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 %} + +{% endblock %} + +{% block content %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {# application content needs to be provided in the app_content block #} + {% block app_content %}{% endblock %} +
+{% endblock %} + +{% block scripts %} +{{ super() }} +{{ moment.include_moment() }} +{{ moment.lang(g.locale) }} + +{% 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 000000000..e928c6c9f --- /dev/null +++ b/app/templates/email/reset_password.html @@ -0,0 +1,16 @@ +

Bonjour {{ user.username }},

+

+ Pour réinitialiser votre mot de passe ScoDoc, + + cliquez sur ce lien + . +

+

Vous pouvez aussi copier ce lien dans votre navigateur Web::

+

{{ 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 !

\ 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 000000000..ea5f14fda --- /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 000000000..efee516ed --- /dev/null +++ b/app/templates/main/index.html @@ -0,0 +1,43 @@ +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Essais Flask pour ScoDoc 8: accueil

+
+

Avec login requis

+ +

Sans login

+ +
+ +{% endblock %} \ No newline at end of file diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 000000000..a42092477 --- /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 000000000..f2aa981d8 --- /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("//Scolarite/sco_exemple") +@scodoc7func +def sco_exemple(etudid="NON"): + """Un exemple de fonction ScoDoc 7""" + return """ +

ScoDoc 7 rules !

+

etudid=%(etudid)s

+

g.scodoc_dept=%(scodoc_dept)s

+ + + """ % { + "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("//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("//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("//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 000000000..d6db8dd9d --- /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 fd46ec9f8..000000000 --- a/dtml/docLogin.dtml +++ /dev/null @@ -1,49 +0,0 @@ - -
- - - - - - -

- -

- - - - - Added by Emmanuel for ScoDoc - - - - - - - - - - - - - - - -
- Nom - - -
- Mot de passe - - -
-
-
-Ok "> -
- -
- -
- diff --git a/dtml/docLogout.dtml b/dtml/docLogout.dtml deleted file mode 100644 index 5210fc745..000000000 --- a/dtml/docLogout.dtml +++ /dev/null @@ -1,16 +0,0 @@ - -

-

-

Vous êtes déconnecté de ScoDoc. -

- -

">revenir à l'accueil

- -
-

(Attention: si vous êtes administrateur, vous ne pouvez vous déconnecter complètement qu'en relançant votre navigateur) -

-
- - - - diff --git a/dtml/manage_addZNotesForm.dtml b/dtml/manage_addZNotesForm.dtml deleted file mode 100644 index e58b49e02..000000000 --- a/dtml/manage_addZNotesForm.dtml +++ /dev/null @@ -1,49 +0,0 @@ - - - - -
-

-Notes Objects are very usefull thus not documented yet... -

-
- -

- - - - - - - - - - - - -
- -
- - - diff --git a/dtml/manage_addZScolarForm.dtml b/dtml/manage_addZScolarForm.dtml deleted file mode 100644 index 580a83e18..000000000 --- a/dtml/manage_addZScolarForm.dtml +++ /dev/null @@ -1,62 +0,0 @@ - - - - -
-

-ZScolar: gestion scolarite d'un departement -

-
- -

- -
-
- Id -
-
- -
-
- Title -
-
- -
- - - - - - - - - - - - - - - - - -
- -
- - - diff --git a/dtml/manage_editZNotesForm.dtml b/dtml/manage_editZNotesForm.dtml deleted file mode 100644 index 456b631f2..000000000 --- a/dtml/manage_editZNotesForm.dtml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - -

- id:
- title:
-
- -
- - - - - diff --git a/dtml/manage_editZScolarForm.dtml b/dtml/manage_editZScolarForm.dtml deleted file mode 100644 index aab8859e1..000000000 --- a/dtml/manage_editZScolarForm.dtml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - -

- id:
- title:
-
- -
- - - - - 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 000000000..849b88ee7 --- /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 7a05f3a7c..000000000 --- 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 000000000..68b7b0e86 --- /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 000000000..adc64cee7 --- /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 000000000..e0a2f1159 --- /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)
-
- Id -
-
- -
-
- Title -
-
- -
-
- DB connexion string -
-
- -