diff --git a/.gitignore b/.gitignore index 3f9d4c8..cbda0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,21 @@ -app.db - +# Python files and caches __pycache__/ app/__pycache__/ +app/static/avatars/ -migrations/ +## Devlopement files -# Virtual environment +# virtualenv venv/ .venv/ - +# pipenv Pipfile Pipfile.lock - # Sublime Text files *.sublime-project *.sublime-workspace -# Deployment files +## Deployment files # uWSGI configuration file uwsgi.ini diff --git a/README.md b/README.md index 0adee0e..cee7f2c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Fork de Planète Casio v5 +# Planète Casio v5 ## Style de code -* Merci d'essayer de respecter la PEP 8 +* L'indentation se fait avec des tabulations * Merci d'essayer de respecter les 80 colonnes max diff --git a/V5.py b/V5.py index c26bf9e..c255ae5 100644 --- a/V5.py +++ b/V5.py @@ -1,7 +1,8 @@ from app import app, db -from app.models import User, Post +from app.models.users import User +# from app.models.models import Post @app.shell_context_processor def make_shell_context(): - return {'db': db, 'User': User, 'Post': Post} + return {'db': db, 'User': User} diff --git a/app.db b/app.db deleted file mode 100644 index a4fcc5a..0000000 Binary files a/app.db and /dev/null differ diff --git a/app/__init__.py b/app/__init__.py index 7366fef..44d965e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,7 +8,10 @@ app = Flask(__name__) app.config.from_object(Config) db = SQLAlchemy(app) migrate = Migrate(app, db) + login = LoginManager(app) login.login_view = 'login' +login.login_message = "Veuillez vous authentifier avant de continuer." -from app import routes, models +from app import models +from app.routes import index, login, search, account, admin, users diff --git a/app/forms.py b/app/forms.py deleted file mode 100644 index 6486c06..0000000 --- a/app/forms.py +++ /dev/null @@ -1,29 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo -from app.models import User - - -class LoginForm(FlaskForm): - username = StringField('Pseudonyme :', validators=[DataRequired()]) - password = PasswordField('Mot de passe :', validators=[DataRequired()]) - remember_me = BooleanField('Se souvenir de moi :') - submit = SubmitField('Connexion') - - -class RegistrationForm(FlaskForm): - username = StringField('Pseudonyme :', validators=[DataRequired()]) - email = StringField('Adresse Email :', validators=[DataRequired(), Email()]) - password = PasswordField('Mot de passe :', validators=[DataRequired()]) - password2 = PasswordField('Répéter le mot de passe', validators=[DataRequired(), EqualTo('password')]) - submit = SubmitField('S\'enregistrer') - - def validate_username(self, username): - user = User.query.filter_by(username=username.data).first() - if user is not None: - raise ValidationError('Pseudo indisponible.') - - def validate_email(self, email): - user = User.query.filter_by(email=email.data).first() - if user is not None: - raise ValidationError('Adresse email déjà utilisé.') diff --git a/app/forms/account.py b/app/forms/account.py new file mode 100644 index 0000000..9e6f7ca --- /dev/null +++ b/app/forms/account.py @@ -0,0 +1,32 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField +from wtforms.fields.html5 import DateField +from wtforms.validators import DataRequired, Optional, Email, EqualTo +from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty +import app.utils.validators as vd + +class RegistrationForm(FlaskForm): + username = StringField('Pseudonyme', validators=[DataRequired(), vd.name]) + email = StringField('Adresse Email', validators=[DataRequired(), Email(), vd.email]) + password = PasswordField('Mot de passe', validators=[DataRequired(), vd.password]) + password2 = PasswordField('Répéter le mot de passe', validators=[DataRequired(), EqualTo('password')]) + guidelines = BooleanField('J’accepte les CGU', validators=[DataRequired()]) + newsletter = BooleanField('Inscription à la newsletter', description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.') + submit = SubmitField('S\'enregistrer') + +class UpdateAccountForm(FlaskForm): + avatar = FileField('Avatar', validators=[Optional(), vd.avatar]) + email = StringField('Adresse Email', validators=[Optional(), Email(), vd.email, vd.old_password]) + password = PasswordField('Mot de passe :', validators=[Optional(), vd.password, vd.old_password]) + password2 = PasswordField('Répéter le mot de passe', validators=[Optional(), EqualTo('password')]) + old_password = PasswordField('Mot de passe actuel', validators=[Optional()]) + birthday = DateField('Anniversaire', validators=[Optional()]) + signature = TextAreaField('Signature', validators=[Optional()]) + biography = TextAreaField('Présentation', validators=[Optional()]) + newsletter = BooleanField('Inscription à la newsletter', description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.') + submit = SubmitField('Mettre à jour') + +class DeleteAccountForm(FlaskForm): + delete = BooleanField('Confirmer la suppression', validators=[DataRequired()], description='Attention, cette opération est irréversible !') + old_password = PasswordField('Mot de passe', validators=[DataRequired(), vd.old_password]) + submit = SubmitField('Supprimer le compte') \ No newline at end of file diff --git a/app/forms/login.py b/app/forms/login.py new file mode 100644 index 0000000..2f49f7c --- /dev/null +++ b/app/forms/login.py @@ -0,0 +1,9 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired + +class LoginForm(FlaskForm): + username = StringField('Pseudonyme :', validators=[DataRequired()]) + password = PasswordField('Mot de passe :', validators=[DataRequired()]) + remember_me = BooleanField('Se souvenir de moi :') + submit = SubmitField('Connexion') \ No newline at end of file diff --git a/app/forms/search.py b/app/forms/search.py new file mode 100644 index 0000000..808b387 --- /dev/null +++ b/app/forms/search.py @@ -0,0 +1,14 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.fields.html5 import DateField +from wtforms.validators import DataRequired, Optional + +# TODO: compléter le formulaire de recherche avancée +class AdvancedSearchForm(FlaskForm): + q = StringField('Rechercher :', validators=[DataRequired()]) + date = DateField('Date', validators=[Optional()]) + submit = SubmitField('Affiner la recherche') + +class SearchForm(FlaskForm): + q = StringField('Rechercher', validators=[DataRequired()]) + \ No newline at end of file diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 29d565b..0000000 --- a/app/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from datetime import datetime -from app import db, login -from flask_login import UserMixin -from werkzeug.security import generate_password_hash, check_password_hash - - -class User(UserMixin, db.Model): - 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)) - posts = db.relationship('Post', backref='author', lazy='dynamic') - - def __repr__(self): - return ''.format(self.username) - - def set_password(self, password): - self.password_hash = generate_password_hash(password) - - def check_password(self, password): - return check_password_hash(self.password_hash, password) - - -@login.user_loader -def load_user(id): - return User.query.get(int(id)) - - -class Post(db.Model): - id = db.Column(db.Integer, primary_key=True) - body = db.Column(db.String(140)) - timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - - def __repr__(self): - return ''.format(self.body) diff --git a/app/models/contents.py b/app/models/contents.py new file mode 100644 index 0000000..83ea859 --- /dev/null +++ b/app/models/contents.py @@ -0,0 +1,19 @@ +from datetime import datetime +from app import db +from app.models.users import * + +class Content(db.Model): + __tablename__ = 'content' + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(20)) + __mapper_args__ = { + 'polymorphic_identity':__tablename__, + 'polymorphic_on': type + } + # Standalone properties + data = db.Column(db.Text(convert_unicode=True)) + date_created = db.Column(db.DateTime, default=datetime.now) + date_modified = db.Column(db.DateTime, default=datetime.now) + # Relationships + author_id = db.Column(db.ForeignKey('user.id')) + author = db.relationship("User", back_populates="contents") \ No newline at end of file diff --git a/app/models/privs.py b/app/models/privs.py new file mode 100644 index 0000000..6e0ed2f --- /dev/null +++ b/app/models/privs.py @@ -0,0 +1,76 @@ +# Planète Casio v5 +# models.privs: Database models for groups and privilege management + +from app import db +from config import V5Config + +# Privileges are represented by strings (slugs), for instance "post-news" or +# "delete-own-posts". Belonging to a group automatically grants a user the +# privileges of that group; additionally, administrators (or any people with +# the "grant-special-privileges" privilege) can grant privileges on a per-user +# basis. + +# SpecialPrivilege: Privilege manually granted to a user +class SpecialPrivilege(db.Model): + __tablename__ = 'special_privilege' + id = db.Column(db.Integer, primary_key=True) + + # Member that is granted the privilege + mid = db.Column(db.Integer, db.ForeignKey('member.id'), index=True) + # Privilege name + priv = db.Column(db.String(V5Config.PRIVS_MAXLEN)) + + def __init__(self, member, priv): + self.mid = member.id + self.priv = priv + + def __repr__(self): + return f'' + + # TODO: clean this. filter does not work ootb + # This ensure that refresh the page should sometime fail with a 403 + def filter(*args, **kwargs): + from random import randint + return not not randint(0, 2) + +# Group: User group, corresponds to a community role and a set of privileges +class Group(db.Model): + __tablename__ = 'group' + + # Unique group ID + id = db.Column(db.Integer, primary_key=True) + # Full name, such as "Administrateur" or "Membre d'honneur". + name = db.Column(db.Unicode(50), unique=True) + # The CSS code should not assume any specific layout and typically applies + # to a text node. Use attributes like color, font-style, font-weight, etc. + css = db.Column(db.UnicodeText) + # Textual description + description = db.Column(db.UnicodeText) + # List of members (lambda delays evaluation) + members = db.relationship('Member', secondary=lambda:GroupMember, + back_populates='groups') + + def __init__(self, name, css): + self.name = name + self.css = css + self.members = [] + + def __repr__(self): + return f'' + +# Many-to-many relation for users belonging to groups +GroupMember = db.Table('group_member', db.Model.metadata, + db.Column('gid', db.Integer, db.ForeignKey('group.id')), + db.Column('uid', db.Integer, db.ForeignKey('member.id'))) + +# Meny-to-many relationship for privileges granted to groups +class GroupPrivilege(db.Model): + __tablename__ = 'group_privilege' + id = db.Column(db.Integer, primary_key=True) + + gid = db.Column(db.Integer, db.ForeignKey('group.id')) + priv = db.Column(db.String(V5Config.PRIVS_MAXLEN)) + + def __init__(self, group, priv): + self.gid = group.id + self.priv = priv diff --git a/app/models/users.py b/app/models/users.py new file mode 100644 index 0000000..ab4ff29 --- /dev/null +++ b/app/models/users.py @@ -0,0 +1,221 @@ +from datetime import date, datetime +from app import app, db +from flask_login import UserMixin +from app.models.contents import Content +from app.models.privs import SpecialPrivilege, Group, GroupMember, \ + GroupPrivilege +from config import V5Config + +import werkzeug.security +import app +import re + +# User: Website user that performs actions on the content +class User(UserMixin, db.Model): + __tablename__ = 'user' + + # User ID, should be used to refer to any user. Thea actual user can either + # be a guest (with IP as key) or a member (with this ID as key). + id = db.Column(db.Integer, primary_key=True) + # User type (polymorphic discriminator) + type = db.Column(db.String(30)) + + # TODO: add good relation + contents = db.relationship('Content', back_populates="author") + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + 'polymorphic_on': type + } + + def __repr__(self): + return f'' + + @staticmethod + def valid_name(name): + """ + Checks whether a string is a valid user name. The criteria are: + 1. No whitespace-class character + 2. At least one letter + 3. At least 3 characters and no longer than 32 characters + + Possibily other intresting criteria: + 4. Unicode restriction + """ + + if type(name) != str or len(name) < 3 or len(name) > 32: + return False + if name in V5Config.FORBIDDEN_USERNAMES: + return False + # Reject all Unicode whitespaces. This is important to avoid the most + # common Unicode tricks! + if re.search(r'\s', name) is not None: + return False + # There must be at least one letter (avoid complete garbage) + if re.search(r'\w', name) is None: + return False + + return True + +# Guest: Unregistered user with minimal privileges +class Guest(User, db.Model): + __tablename__ = 'guest' + __mapper_args__ = { 'polymorphic_identity': __tablename__ } + + # ID of the [User] entry + id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + # Reusable username, can be the name of a member (will be distinguished at + # rendering time) + username = db.Column(db.Unicode(64), index=True) + # IP address, 47 characters is the max for an IPv6 address + ip = db.Column(db.String(47)) + + def __repr__(self): + return f'' + +# Member: Registered user with full access to the website's services +class Member(User, db.Model): + __tablename__ = 'member' + __mapper_args__ = { 'polymorphic_identity': __tablename__ } + + # Id of the [User] entry + id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + + # Primary attributes (needed for the system to work) + name = db.Column(db.Unicode(32), index=True, unique=True) + email = db.Column(db.Unicode(120), index=True, unique=True) + password_hash = db.Column(db.String(255)) + xp = db.Column(db.Integer) + innovation = db.Column(db.Integer) + register_date = db.Column(db.Date, default=date.today) + + # Avatars # TODO: rendre ça un peu plus propre + @property + def avatar(self): + return 'avatars/' + str(self.id) + '.png' + + # Groups and related privileges + groups = db.relationship('Group', secondary=GroupMember, + back_populates='members') + + # Personal information, all optional + bio = db.Column(db.UnicodeText) + signature = db.Column(db.UnicodeText) + birthday = db.Column(db.Date) + + # Settings + newsletter = db.Column(db.Boolean, default=False) + + # Relations + # trophies = db.relationship('Trophy', back_populates='member') + # tests = db.relationship('Test', back_populates='author') + + def __init__(self, name, email, password): + """Register a new user.""" + if not User.valid_name(name): + raise Exception(f'{name} is not a valid user name') + + self.name = name + self.email = email + self.set_password(password) + self.xp = 0 + self.innovation = 0 + + self.bio = "" + self.signature = "" + self.birthday = None + + def priv(self, priv): + """Check whether the member has the specified privilege.""" + if SpecialPrivilege.filter(uid=self.id, priv=priv): + return True + return False +# return db.session.query(User, Group, GroupPrivilege).filter( +# Group.id.in_(User.groups), GroupPrivilege.gid==Group.id, +# GroupPrivilege.priv==priv).first() is not None + + def update(self, **data): + """ + Update all or part of the user's metadata. The [data] dictionary + accepts the following keys: + "name" str User name + "email" str User mail ddress + "password" str Raw password + "bio" str Biograpy + "signature" str Post signature + "birthday" date Birthday date + "newsletter" bool Newsletter setting + For future compatibility, other attributes are silently ignored. None + values can be specified and are ignored. + + It is the caller's responsibility to check that the request sender has + the right to change user names, password... otherwise this method will + turn out dangerous! + """ + + data = { key: data[key] for key in data if data[key] is not None } + + if "name" in data: + if not User.valid_name(data["name"]): + raise Exception(f'{data["name"]} is not a valid user name') + self.name = data["name"] + + # TODO: verify good type of those args, think about the password mgt + if "email" in data: + self.email = data["email"] + if "password" in data: + self.set_password(data["password"]) + if "bio" in data: + self.bio = data["bio"] + if "signature" in data: + self.signature = data["signature"] + if "birthday" in data: + self.birthday = data["birthday"] + if "newsletter" in data: + self.newsletter = data["newsletter"] + + def get_public_data(self): + """Returns the public information of the member.""" + return { + "name": self.name, + "xp": self.xp, + "innovation": self.innovation, + "register_date": self.register_date, + "bio": self.bio, + "signature": self.signature, + "birthday": self.birthday, + } + + def add_xp(self, amount): + """ + Reward xp to a member. If [amount] is negative, the xp total of the + member will decrease, down to zero. + """ + self.xp_points = max(self.xp_points + amount, 0) + + def add_innovation(self, n): + """ + Reward innovation points to a member. If [amount] is negative, the + innovation points total will decrease, down to zero. + """ + self.innovation = max(self.innovation + amount, 0) + + def set_password(self, password): + """ + Set the user's password. Check whether the request sender has the right + to do this! + """ + self.password_hash = werkzeug.security.generate_password_hash(password, + method='pbkdf2:sha512', salt_length=10) + + def check_password(self, password): + """Compares password against member hash.""" + return werkzeug.security.check_password_hash(self.password_hash, + password) + + def __repr__(self): + return f'' + +@app.login.user_loader +def load_user(id): + return User.query.get(int(id)) diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index e8fd9b8..0000000 --- a/app/routes.py +++ /dev/null @@ -1,103 +0,0 @@ -from flask import render_template, flash, redirect, url_for, request -from flask_login import login_user, logout_user, current_user, login_required -from werkzeug.urls import url_parse -from app import app, db -from app.forms import LoginForm, RegistrationForm -from app.models import User - - -@app.route('/', methods=['GET', 'POST']) -def 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('pseudo ou mot de passe invalide') - return redirect(url_for('index')) - login_user(user, remember=form.remember_me.data) - - return render_template('index.html.j2', form=form) - - -@app.route('/logout/') -def logout(): - logout_user() - return redirect(url_for('index')) - - -@app.route('/register', methods=['GET', 'POST']) -def register(): - if current_user.is_authenticated: - return redirect(url_for('index')) - form = LoginForm() - form2 = RegistrationForm() - 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('pseudo ou mot de passe invalide') - return redirect(url_for('index')) - login_user(user, remember=form.remember_me.data) - if form2.validate_on_submit(): - user = User(username=form2.username.data, email=form2.email.data) - user.set_password(form2.password.data) - db.session.add(user) - db.session.commit() - flash('Congratulations, you are now a registered user!') - return redirect(url_for('validation')) - return render_template('register.html.j2', title='Register', form=form, - form2=form2) - - -@app.route('/register/validation/') -def validation(): - if current_user.is_authenticated: - return redirect(url_for('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('pseudo ou mot de passe invalide') - return redirect(url_for('index')) - login_user(user, remember=form.remember_me.data) - return render_template('validation.html.j2', form=form) - - -@app.errorhandler(400) -@app.errorhandler(401) -@app.errorhandler(403) -@app.errorhandler(404) -@app.errorhandler(418) -@app.errorhandler(500) -@app.errorhandler(501) -@app.errorhandler(503) -def ma_page_erreur(error): - 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('pseudo ou mot de passe invalide') - return redirect(url_for('index')) - login_user(user, remember=form.remember_me.data) - return render_template("base/errors.html.j2", error_code=error.code, - form=form), error.code - - -@app.route('/error/') -def errors(error): - 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('pseudo ou mot de passe invalide') - return redirect(url_for('index')) - login_user(user, remember=form.remember_me.data) - if(error == 400 or error == 401 or error == 403 or error == 404 or - error == 418 or error == 500 or error == 501 or error == 503): - error_code = error - else: - error_code = 404 - error = 404 - - return render_template("base/errors.html.j2", error_code=error_code, - form=form), error diff --git a/app/routes/account.py b/app/routes/account.py new file mode 100644 index 0000000..4e5b0e7 --- /dev/null +++ b/app/routes/account.py @@ -0,0 +1,67 @@ +from flask import redirect, url_for, request, flash +from flask_login import login_required, current_user, logout_user +from app import app, db +from app.forms.account import UpdateAccountForm, RegistrationForm, DeleteAccountForm +from app.models.users import Member +from app.utils.render import render + +@app.route('/account', methods=['GET', 'POST']) +@login_required +def account(): + form = UpdateAccountForm() + if request.method == "POST": + if form.validate_on_submit(): + if form.avatar.data: + f = form.avatar.data + f.save("./app/static/"+current_user.avatar) + current_user.update( + email = form.email.data or None, + password = form.password.data or None, + birthday = form.birthday.data, + signature = form.signature.data, + bio = form.biography.data, + newsletter = form.newsletter.data + ) + db.session.merge(current_user) + db.session.commit() + flash('Modifications effectuées', 'ok') + else: + flash('Erreur lors de la modification', 'error') + + return render('account.html', form=form) + +@app.route('/account/delete', methods=['GET', 'POST']) +@login_required +def delete_account(): + del_form = DeleteAccountForm() + if request.method == "POST": + if del_form.validate_on_submit(): + db.session.delete(current_user) + logout_user() + db.session.commit() + flash('Compte supprimé', 'ok') + return redirect(url_for('index')) + else: + flash('Erreur lors de la suppression du compte', 'error') + del_form.delete.data = False # Force to tick to delete the account + return render('delete_account.html', del_form=del_form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegistrationForm() + if form.validate_on_submit(): + member = Member(form.username.data, form.email.data, form.password.data) + db.session.add(member) + db.session.commit() + flash('Inscription réussie', 'ok') + return redirect(url_for('validation')) + return render('register.html', title='Register', form=form) + +@app.route('/register/validation/') +def validation(): + if current_user.is_authenticated : + return redirect(url_for('index')) + return render('validation.html') diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..a64b586 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,58 @@ +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import SubmitField +from app.models.users import Member, Group, GroupPrivilege +from app.models.privs import SpecialPrivilege +from app.utils.render import render +from app.utils.priv_required import priv_required +from app import app, db + +@app.route('/admin', methods=['GET', 'POST']) +@priv_required('panel-admin') +def admin(): + class AdminForm(FlaskForm): + submit = SubmitField('Régénérer les groupes, les privilèges, et les ' + + 'membres de test "PlanèteCasio" et "GLaDOS" (mdp "v5-forever")') + + form = AdminForm() + if form.validate_on_submit(): + # Clean up groups + for g in Group.query.all(): + db.session.delete(g) + db.session.commit( ) + + # Create base groups + g_admins = Group('Administrateur', 'color: red') + g_modos = Group('Modérateur', 'color: green') + g_redacs = Group('Rédacteur', 'color: blue') + g_community = Group('Compte communautaire', 'background: #c8c8c8;' + + 'border-radius: 4px; color: #303030; padding: 1px 2px') + db.session.add(g_admins) + db.session.add(g_modos) + db.session.add(g_redacs) + db.session.add(g_community) + + # Clean up test members + for name in "PlanèteCasio GLaDOS".split(): + m = Member.query.filter_by(name=name).first() + if m is not None: + db.session.delete(m) + db.session.commit() + + # Create template members + m = Member('PlanèteCasio','contact@planet-casio.com','v5-forever') + m.groups.append(g_community) + db.session.add(m) + + m = Member('GLaDOS', 'glados@aperture.science', 'v5-forever') + m.groups.append(g_modos) + m.groups.append(g_redacs) + db.session.add(m) + db.session.add(SpecialPrivilege(m, "edit-posts")) + db.session.add(SpecialPrivilege(m, "shoutbox-ban")) + + db.session.commit() + + users = Member.query.all() + groups = Group.query.all() + return render('admin.html', users=users, groups=groups, form=form) diff --git a/app/routes/index.py b/app/routes/index.py new file mode 100644 index 0000000..d43a158 --- /dev/null +++ b/app/routes/index.py @@ -0,0 +1,15 @@ +from app import app + +from app.utils.render import render + +@app.route('/') +def index(): + return render('index.html') + +@app.errorhandler(404) +def file_not_found(e): + return render('errors/404.html'), 404 + +@app.errorhandler(403) +def unauthorized_access(e): + return render('errors/403.html'), 403 diff --git a/app/routes/login.py b/app/routes/login.py new file mode 100644 index 0000000..06055f2 --- /dev/null +++ b/app/routes/login.py @@ -0,0 +1,43 @@ +from flask import redirect, url_for, request, flash +from flask_login import login_user, logout_user, login_required, current_user +from app import app +from app.forms.login import LoginForm +from app.models.users import Member +from app.utils.render import render + +# from app.routes.index import index + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + + form = LoginForm() + if form.validate_on_submit(): + member = Member.query.filter_by(name=form.username.data).first() + if member is None or not member.check_password(form.password.data): + flash('Pseudo ou mot de passe invalide', 'error') + return redirect(request.referrer) + login_user(member, remember=form.remember_me.data) + # TODO: est-ce qu'on garde ce foutu message plus chiant qu'autre chose ? + flash(f'Bon retour parmi nous, {current_user.name} !', 'info') + if request.args.get('next'): + return redirect(request.args.get('next')) + if request.referrer: + return redirect(request.referrer) + return redirect(url_for('index')) + return render('login.html', form=form) + +@app.route('/logout') +@login_required +def logout(): + try: + print(request.referrer) + except Exception as e: + print('No referrer:', e) + + logout_user() + flash('Déconnexion réussie', 'info') + if request.referrer: + return redirect(request.referrer) + return redirect(url_for('index')) \ No newline at end of file diff --git a/app/routes/search.py b/app/routes/search.py new file mode 100644 index 0000000..ef3fb41 --- /dev/null +++ b/app/routes/search.py @@ -0,0 +1,8 @@ +from app import app +from app.forms.search import AdvancedSearchForm +from app.utils.render import render + +@app.route('/search') +def search(): + form = AdvancedSearchForm() + return render('search.html', form=form) diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..111389b --- /dev/null +++ b/app/routes/users.py @@ -0,0 +1,20 @@ +from flask import redirect, url_for, abort +from flask_login import login_required, current_user, logout_user +from app import app, db +from app.forms.account import UpdateAccountForm, RegistrationForm, DeleteAccountForm +from app.models.users import Member +from app.utils.render import render + +@app.route('/user/') +def user(username): + user = Member.query.filter_by(name=username).first() + if not user: + abort(404) + return render('user.html', user=user) + +@app.route('/user/id/') +def user_by_id(user_id): + user = Member.query.filter_by(id=user_id).first() + if not user: + abort(404) + return redirect(url_for('user', username=user.name)) diff --git a/app/static/css/container.css b/app/static/css/container.css index 4e2176b..4ba38df 100644 --- a/app/static/css/container.css +++ b/app/static/css/container.css @@ -2,6 +2,33 @@ margin-left: 60px; } +section { + min-width: 350px; width: 90%; + margin: 20px auto 0; padding: 20px; + background: #ffffff; + border: 1px solid #dddddd; border-radius: 5px; +} + +section h1 { + margin-top: 0; + border-bottom: 1px solid #a0a0a0; + font-family: Raleway; font-size: 32px; + font-weight: 300; color: #242424; +} + +section h2 { + margin-top: 0; + border-bottom: 1px solid #a0a0a0; + font-family: Raleway; font-size: 26px; + font-weight: 300; color: #242424; +} + +section .avatar { + display: block; + border-radius: 100%; + width: 150px; height: 150px; +} + /* #container h1 { margin-left: 5%; font-family: Raleway; font-size: 24px; @@ -13,5 +40,3 @@ font-family: Raleway; font-size: 20px; font-weight: 200; color: #242424; } */ - - diff --git a/app/static/css/flash.css b/app/static/css/flash.css new file mode 100644 index 0000000..e66a1a1 --- /dev/null +++ b/app/static/css/flash.css @@ -0,0 +1,45 @@ +/* + flash overlay +*/ + +.flash { + position: fixed; left: 15%; + display: flex; align-items: center; + width: 70%; z-index: 10; + font-family: NotoSans; font-size: 14px; color: #212121; + background: #ffffff; + border-bottom: 5px solid #4caf50; + border-radius: 1px; box-shadow: 0 1px 12px rgba(0, 0, 0, 0.3); + transition: opacity .15s ease; + transition: top .2s ease; +} +.flash.info { + border-color: #2e7aec; +} +.flash.ok { + border-color: #4caf50; +} +.flash.warning { + border-color: #fbbc26; +} +.flash.error { + border-color: #f44336; +} +.flash span { + flex-grow: 1; margin: 15px 10px 10px 0; +} +.flash input[type="button"] { + margin: 3px 30px 0 0; padding: 10px 15px; + border: none; + background: rgba(0, 0, 0, 0); color: #727272; +} +.flash input[type="button"]:hover { + background: rgba(0, 0, 0, .1); +} +.flash input[type="button"]:focus { + background: rgba(0, 0, 0, .2); +} + +.flash svg { + margin: 15px 20px 10px 30px; +} diff --git a/app/static/css/footer.css b/app/static/css/footer.css index 7f497b8..153c068 100644 --- a/app/static/css/footer.css +++ b/app/static/css/footer.css @@ -1,5 +1,5 @@ /* - footer + Footer */ footer { @@ -10,4 +10,4 @@ footer { } footer p { margin: 3px 0; -} +} \ No newline at end of file diff --git a/app/static/css/form.css b/app/static/css/form.css new file mode 100644 index 0000000..1915d99 --- /dev/null +++ b/app/static/css/form.css @@ -0,0 +1,50 @@ +.form .avatar { + display: inline-block; vertical-align: middle; + border-radius: 100%; + width: 150px; height: 150px; +} + +.form .avatar + input[type="file"] { + display: inline-block; margin-left: 20px; + vertical-align: middle; +} + +.form form > div:not(:last-child) { + margin-bottom: 15px; +} + +.form form label { + display: inline-block; + margin-bottom: 5px; +} + +.form input { + cursor: pointer; /* don't know why it is not a cursor by default */ +} + +.form input[type='text'], +.form input[type='email'], +.form input[type='date'], +.form input[type='password'], +.form textarea { + display: block; + width: 100%; padding: 6px 2.5%; + border: 1px solid #abcdef; +} +.form input[type='text']:focus, +.form input[type='email']:focus, +.form input[type='date']:focus, +.form input[type='password']:focus, +.form textarea:focus { + box-shadow: 0 0 4px rgba(0, 102, 255, .9); +} + +.form input[type="submit"] { + /*width: 20%;*/ +} + +.form form .msgerror { + color: red; + font-weight: 400; + margin-top: 5px; +} \ No newline at end of file diff --git a/app/static/css/global.css b/app/static/css/global.css index 6fe5e6f..7eac437 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -9,81 +9,30 @@ /* - body + ALL +*/ + +* { + box-sizing: border-box; + transition: .15s ease; +} + + + +/* + Body */ body { margin: 0; - background: #fbfbfb; + background: #ffffff; font-family: 'DejaVu Sans', sans-serif; } -/* - header -*/ - -header { - height: 50px; margin: 0; padding: 0 30px; - display: flex; align-items: center; justify-content: space-between; - background: #f8f8fa; border-bottom: 1px solid #d0d0d0; -} - -header h1 { - font-family: Raleway; font-weight: 200; -} - -header svg { - width: 24px; height: 24px; vertical-align: middle; - transition: .15s ease; -} -header a:hover > svg, header a:focus > svg { - filter: brightness(.5); -} - -header input[type="search"] { - width: 250px; - padding: 5px 35px 5px 10px; - border: 0; border-radius: 1px; - font-family: "Segoe UI", Helvetica, "Droid Sans", Arial,sans-serif; - box-shadow: 0 0 1px rgba(0, 0, 0, .4); transition: .15s ease; -} -header input[type="search"] ~ a { - position: relative; left: -33px; -} -header input[type="search"]:focus { - box-shadow: 0 0 4px rgba(0, 102, 255, .9); -} -header input[type="search"] ~ a > svg > path { - fill: #cccccc; transition: .15s ease; -} -header input[type="search"]:focus ~ a > svg > path { - fill: #333333; -} - -#spotlight a { - padding: 8px 18px 6px 18px; - color: #727272; font-size: 15px; - border-bottom: 2px solid rgba(93, 123, 141, 0); - transition: border .15s ease; -} -#spotlight a:hover, header #spotlight a:focus { - border-bottom: 2px solid rgba(93, 123, 141, 1); -} - - -footer { - margin: 20px 10% 5px 10%; padding: 10px 0; - text-align: center; font-size: 11px; font-style: italic; - color: #a0a0a0; - border-top: 1px solid rgba(0, 0, 0, .1); -} -footer p { - margin: 3px 0; -} /* - links + Links */ a { @@ -96,94 +45,68 @@ a:focus { /* - alert overlay + Inputs */ -.alert { - position: fixed; left: 15%; - display: flex; align-items: center; - width: 70%; z-index: 10; - font-family: NotoSans; font-size: 14px; color: #212121; - background: #ffffff; - border-bottom: 5px solid #4caf50; - border-radius: 1px; box-shadow: 0 1px 12px rgba(0, 0, 0, 0.3); - transition: opacity .15s ease; +input, +textarea { + display: block; + background: #FFFFFF; color: #000000; + border: none; } -.alert.ok { - border-color: #4caf50; -} -.alert.error { - border-color: #f44336; -} -.alert span { - flex-grow: 1; margin: 15px 10px 10px 0; -} -.alert input[type="button"] { - margin: 3px 30px 0 0; +input:focus:not(type="button"), +textarea:focus { + box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); } -.alert svg { - margin: 15px 20px 10px 30px; +/* Textarea */ +textarea { + width: 100%; + border: 1px solid #eeeeee; +} + +/* Buttons */ +.button, +input[type="button"], +input[type="submit"] { + padding: 6px 10px; + border: 1px solid transparent; border-radius: 3px; + font-family: 'DejaVu Sans', sans-serif; font-size: 14px; font-weight: 400; +} + +/* Checkbox */ +input[type="checkbox"] { + display: inline; + vertical-align: middle; } -/* - buttons +/* + Bootstrap-style rules */ - -input[type="button"] { - font-family: NotoSans; font-size: 14px; /*font-weight: bold;*/ - text-align: center; - padding: 5px 15px; - transition: .1s ease; +.flex { + display: flex; } -/* flat */ -input[type="button"].flat { - border: none; - background: rgba(0, 0, 0, 0); color: #727272; +.bg-green, +.bg-green { + background-color: #149641; + color: #ffffff; } -input[type="button"].flat:hover { - background: rgba(0, 0, 0, .1); -} -input[type="button"].flat:focus { - background: rgba(0, 0, 0, .2); +.bg-green:hover, +.bg-green:focus, +.bg-green:active { + background-color: #0f7331; } -/* raised */ -input[type="button"].raised { - border: none; - background: #e0e0e0; color: #212121; - box-shadow: 0 1px 2px rgba(0, 0, 0, .3); +.bg-red, +.bg-red { + background-color: #c0341d; + color: #ffffff; } -input[type="button"].raised:hover, -input[type="button"].raised:focus { - background: #d5d5d5; +.bg-red:hover, +.bg-red:focus, +.bg-red:active { + background-color: #aa3421; } -input[type="button"].raised:active { - background: #d6d6d6; - box-shadow: 0 1px 8px rgba(0, 0, 0, .3); -} - -/* Input text */ -input[type="text"]:focus, -input[type="search"]:focus, -input[type="password"]:focus { - -} - - -section { - margin: 10px 5%; -} - -section h1 { - border-bottom: 1px solid #a0a0a0; - font-family: Raleway; font-size: 24px; - font-weight: 200; color: #242424; -} - -section * { - transition: .15s ease; -} \ No newline at end of file diff --git a/app/static/css/header.css b/app/static/css/header.css index 72996d7..22e3d92 100644 --- a/app/static/css/header.css +++ b/app/static/css/header.css @@ -3,31 +3,29 @@ */ header { - margin: 0; padding: 10px 16px; - display: flex; align-items: center; justify-content: flex-end; - background: #ffffff; border-bottom: 1px solid #e0e0e0; + height: 50px; margin: 0; padding: 0 30px; + display: flex; align-items: center; justify-content: space-between; + background: #f8f8fa; border-bottom: 1px solid #d0d0d0; } header h1 { font-family: Raleway; font-weight: 200; } -header input + span { position: relative; left: -32px; cursor: pointer; } -header span > svg { +header svg { width: 24px; height: 24px; vertical-align: middle; - transition: .15s ease; fill: #969696; + transition: .15s ease; } -header span:hover > svg, header span:focus > svg { - border-color: rgba(32, 128, 255, .6); - fill: #484848; +header a:hover > svg, header a:focus > svg { + filter: brightness(.5); } -header form { - flex-shrink: 0; margin-right: -23px; -} header input[type="search"] { - width: 220px; height: 30px; padding: 4px 30px 4px 8px; - border: 1px solid rgba(0, 0, 0, .2); border-radius: 2px; + display: inline-block; width: 250px; + padding: 5px 35px 5px 10px; + border: 0; border-radius: 1px; + font-family: "Segoe UI", Helvetica, "Droid Sans", Arial,sans-serif; + box-shadow: 0 0 1px rgba(0, 0, 0, .4); transition: .15s ease; } header input[type="search"] ~ a { position: relative; left: -33px; @@ -42,54 +40,13 @@ header input[type="search"]:focus ~ a > svg > path { fill: #333333; } -#spotlight { - flex-shrink: 0; +#spotlight a { + padding: 8px 18px 6px 18px; + color: #727272; font-size: 15px; + border-bottom: 2px solid rgba(93, 123, 141, 0); + transition: border .15s ease; } -#spotlight a { - display: inline-block; - height: 24px; line-height: 24px; - padding: 2px 10px; - background: #728bf6; color: white; font-size: 12px; - border-radius: 2px; border: 1px solid rgba(0, 0, 255, .05); -} -#spotlight a:hover { border-bottom: 1px solid rgba(128, 128, 255, .05); - background: #7a93ff; border-color: rgba(128, 128, 255, .03); -} - - -/* - subheader -*/ - -#subheader { - margin: 0; padding: 0 32px; - flex-grow: 1; -} -#subheader * { - margin: 0; padding: 0; -} - -#subheader li { - height: 30px; margin: 5px 0; - display: inline; - /*display: flex; align-items: center;*/ -} -#subheader li:after { - content: " »"; - opacity: 0.3; -} -#subheader li:last-child:after { - content: none; -} - -#subheader a { - padding: 0 3px; - /*border-bottom: 1px solid rgba(93, 123, 141, 0);*/ - color: #727272; -} -#subheader a:hover, #subheader a:focus { - /*border-bottom: 1px solid rgba(93, 123, 141, 1);*/ - color: #22292c; - text-decoration: none +#spotlight a:hover, header #spotlight a:focus { + border-bottom: 2px solid rgba(93, 123, 141, 1); } diff --git a/app/static/css/navbar.css b/app/static/css/navbar.css index d2dd033..c3545b5 100644 --- a/app/static/css/navbar.css +++ b/app/static/css/navbar.css @@ -180,37 +180,34 @@ nav a:focus { margin-right: 10px; } -#menu form p { +#menu form { padding: 0 5%; } -#menu form input[type="text"] { - display: block; width: 96%; +#menu form input[type="text"], +#menu form input[type="password"] { + display: block; width: 100%; margin: 0; padding: 5px 2%; font-size: 14px; color: inherit; background: #e8e8e8; transition: background .15s ease; border: none; - border-top-left-radius: 5px; - border-top-right-radius: 5px; -} -#menu form input[type="password"]{ - display: block; width: 96%; - margin: 0; padding: 5px 2%; - font-size: 14px; color: inherit; - background: #e8e8e8; transition: background .15s ease; - border: none; border-top: 1px solid #dddddd; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; } #menu form input[type="text"]:focus, #menu form input[type="password"]:focus { background: #ffffff; } +#menu form input[type="text"] { + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} +#menu form input[type="password"] { + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +} +#menu form input[type="submit"] { + width: 100%; + margin-top: 10px; margin-bottom: 5px; + border-radius: 5px; +} #menu form label { font-size: 13px; color: #FFFFFF; opacity: .7; } -#menu form input[type="checkbox"] { - vertical-align: middle; -} -#menu form input[type="submit"] { - width: 100%; padding: 5px; -} diff --git a/app/static/fonts/raleway_200.ttf b/app/static/fonts/raleway_200.ttf new file mode 100644 index 0000000..281a001 Binary files /dev/null and b/app/static/fonts/raleway_200.ttf differ diff --git a/app/static/images/403.webp b/app/static/images/403.webp new file mode 100644 index 0000000..6c5ff47 Binary files /dev/null and b/app/static/images/403.webp differ diff --git a/app/static/images/404.webp b/app/static/images/404.webp new file mode 100644 index 0000000..3e125e4 Binary files /dev/null and b/app/static/images/404.webp differ diff --git a/app/static/scripts/pc-utils.js b/app/static/scripts/pc-utils.js index f3dd880..3779769 100644 --- a/app/static/scripts/pc-utils.js +++ b/app/static/scripts/pc-utils.js @@ -12,15 +12,50 @@ function getCookie(name) { return unescape( document.cookie.substring( debut+name.length+1, end ) ); } -function close_important(element) { +/* + Flash messages + TODO: Find a way to have good flash messages in a KISS & DRY way +*/ +function flash_add(type, message) { + template = `
+ + {{ icon }} + + + {{ message }} + + +
`; + paths = { + 'error': '', + 'warning': '', + 'ok': '', + 'info': '' + }; + var top = (document.getElementsByClassName('flash').length + 1) * 70 - 45; + template = template.replace("{{ category }}", type); + template = template.replace("{{ top }}", top); + template = template.replace("{{ icon }}", paths[type]); + template = template.replace("{{ message }}", message); + document.body.innerHTML += template; +} +function flash_close(element) { element.style.opacity = 0; - setTimeout(function(){ element.parentNode.removeChild(element); }, 200); + setTimeout(function(){ + var parent = element.parentNode; + parent.removeChild(element); + var childs = parent.getElementsByClassName('flash'); + for(var i = 0; i < childs.length; i++) { + childs[i].style.top = ((i + 1) * 70 - 45) + 'px'; + } + }, 0); } /* Send post ajax request to url defined in action. Callback the function defined in the callback attribute from the submit type. */ +/* We don't need Ajax at that time. Maybe later function ajaxWrapper(evt){ evt.preventDefault(); var elems = evt.target; @@ -45,9 +80,7 @@ function ajaxWrapper(evt){ req.send(params); } -/* - Add event listener on submit for all form with class with-ajax. -*/ +// Add event listener on submit for all form with class with-ajax. window.onload = function(){ @@ -71,4 +104,5 @@ window.onload = function(){ function login(response){ alert(response); -} \ No newline at end of file +} +//*/ \ No newline at end of file diff --git a/app/templates/account.html b/app/templates/account.html new file mode 100644 index 0000000..7c4aa88 --- /dev/null +++ b/app/templates/account.html @@ -0,0 +1,86 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

Gestion du compte

+ +
+ {{ form.hidden_tag() }} + +

Général

+
+ {{ form.avatar.label }} +
+ + {{ form.avatar }} +
+
+
+ {{ form.email.label }} + {{ form.email(placeholder=current_user.email) }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label }} + {{ form.password(placeholder='************') }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password2.label }} + {{ form.password2(placeholder='************') }} + {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.old_password.label }} + {{ form.old_password(placeholder='************') }} + {% for error in form.old_password.errors %} + {{ error }} + {% endfor %} +
+ +

À propos

+
+ {{ form.birthday.label }} + {{ form.birthday(value=current_user.birthday) }} + {% for error in form.birthday.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.signature.label }} + + {% for error in form.signature.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.biography.label }} + + {% for error in form.biography.errors %} + {{ error }} + {% endfor %} +
+ +

Préférences

+
+ {{ form.newsletter.label }} + {{ form.newsletter(checked=current_user.newsletter) }} +
{{ form.newsletter.description }}
+ {% for error in form.newsletter.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-green") }}
+
+ +

Supprimer le compte

+ Supprimer le compte + +
+{% endblock %} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..d0498e6 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,43 @@ +{% extends "base/container.html" %} + +{% block content %} +
+
+ {{ form.hidden_tag() }} + {{ form.submit }} +
+ +

List of members

+ + + + + + {% for user in users %} + + + + + + {% endfor %} +
NameEmailRegisterXPInn.Newsletter
{{ user.name }}{{ user.email }}{{ user.register_date }}{{ user.xp }}{{ user.innovation }}{{ "Yes" if user.newsletter else "No" }}
+ +

List of groups

+ + + + + {% for group in groups %} + + {% endfor %} +
GroupMembersPrivileges
{{ group.name }} + {% for user in group.members %} + {{ user.name }} + {% endfor %} + + {% for priv in group.privs %} + {{ priv }} + {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/base/alerts.html.j2 b/app/templates/base/alerts.html.j2 deleted file mode 100644 index 51d3b87..0000000 --- a/app/templates/base/alerts.html.j2 +++ /dev/null @@ -1,15 +0,0 @@ -{% with messages = get_flashed_messages() %} -{% if messages %} -{% for message in messages %} -
- - - - - {{ message }} - - -
-{% endfor %} -{% endif %} -{% endwith %} diff --git a/app/templates/base/base.html b/app/templates/base/base.html new file mode 100644 index 0000000..aedf74f --- /dev/null +++ b/app/templates/base/base.html @@ -0,0 +1,16 @@ + + + {% include "base/head.html" %} + + {% include "base/navbar.html" %} + + {% block container %} + {% endblock container %} + + {% include "base/footer.html" %} + + {% include "base/flash.html" %} + + {% include "base/scripts.html" %} + + diff --git a/app/templates/base/base.html.j2 b/app/templates/base/base.html.j2 deleted file mode 100644 index 2650ee8..0000000 --- a/app/templates/base/base.html.j2 +++ /dev/null @@ -1,16 +0,0 @@ - - - {% include "base/head.html.j2" %} - - {% include "base/navbar.html.j2" %} - - {% block container %} - {% endblock container %} - - {% include "base/footer.html.j2" %} - - {% include "base/alerts.html.j2" %} - - {% include "base/scripts.html.j2" %} - - diff --git a/app/templates/base/container.html.j2 b/app/templates/base/container.html similarity index 63% rename from app/templates/base/container.html.j2 rename to app/templates/base/container.html index 9cfa4f7..cf4302e 100644 --- a/app/templates/base/container.html.j2 +++ b/app/templates/base/container.html @@ -1,8 +1,8 @@ -{% extends "base/base.html.j2" %} +{% extends "base/base.html" %} {% block container %}
- {% include "base/header.html.j2" %} + {% include "base/header.html" %} {% block content %} {% endblock content %} diff --git a/app/templates/base/errors.html.j2 b/app/templates/base/errors.html.j2 deleted file mode 100644 index 24ce8ed..0000000 --- a/app/templates/base/errors.html.j2 +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base/container.html.j2" %} - -{% block content %} -
-

- Erreur {{ error_code }} -
- {% if error_code == 400 %} - Bad Request : Votre requête semble mal formée. - {% elif error_code == 401 %} - Unauthorized : Une authentification est nécessaire pour accéder à la ressource. -
- Veuillez vous connecter à l'aide du panneau sur la gauche. - {% elif error_code == 403 %} - Forbidden : Cette page vous est interdite. Désolé, si vous pensez que c'est une erreur ou que vous voulez avoir accès à cette page malgré tout veuillez contacter un administrateur. - {% elif error_code == 404 %} - Not Found : La page n'existe pas ou plus, si vous avez rentré l'url à la main vérifiez de ne pas vous être trompé. -

      /|   /‾‾‾‾‾‾‾\        /|     |\         |   /‾‾‾‾‾\   –––––––––
-     / |  /         \      / |     | \        |  /       \      |
-    /  |  |         |     /  |     |  \       | /         \     |
-   /   |  |         |    /   |     |   \      | |         |     |
-  /    |  |         |   /    |     |    \     | |         |     |
- /     |  |         |  /     |     |     \    | |         |     |
-/______|_ |         | /______|_    |      \   | |         |     |
-       |  |         |        |     |       \  | \         /     |
-       |  \         /        |     |        \ |  \       /      |
-       |   \_______/         |     |         \|   \_____/       |
-
-|‾‾‾‾‾‾‾‾‾‾   /‾‾‾‾‾\   |         | |\         | |‾‾‾‾‾‾‾\
-|            /       \  |         | | \        | |        \
-|           /         \ |         | |  \       | |         \
-|           |         | |         | |   \      | |         |
-|–––––––    |         | |         | |    \     | |         |
-|           |         | |         | |     \    | |         |
-|           |         | |         | |      \   | |         |
-|           \         / \         / |       \  | |         /
-|            \       /   \       /  |        \ | |        /
-|             \_____/     \_____/   |         \| |_______/
- {% elif error_code == 418 %} - Oups! Il semblerai que vous m'ayez demandé du café mais je suis une théière. -
- Vous voulez une tasse de thé à la place de votre café? - {% elif error_code == 500 %} - Internal Server Error : Erreur interne du serveur. Rassurez vous vous n'êtes pas en cause c'est sans doute un développeur qui à fait l'imbécile. Si cette page s'affiche trop souvent essayez de contacter un administrateur par mail à devs@planet-casio.com. - {% elif error_code == 501 %} - Not Implemented : La fonctionnalité réclamée n'est pas supportée par le serveur. Désolé si cette fonctionnalité vous tient à cœur vous pouvez la proposer sur le forum. - {% elif error_code == 503 %} - Service Unavailable : Service temporairement indisponible ou en maintenance. Patientez! - {% endif %} -

-
-{% endblock %} diff --git a/app/templates/base/flash.html b/app/templates/base/flash.html new file mode 100644 index 0000000..795fd74 --- /dev/null +++ b/app/templates/base/flash.html @@ -0,0 +1,18 @@ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ + {% if category=="error" %}{% endif %} + {% if category=="warning" %}{% endif %} + {% if category=="ok" %}{% endif %} + {% if category=="info" %}{% endif %} + + + {{ message }} + + +
+ {% endfor %} + {% endif %} +{% endwith %} diff --git a/app/templates/base/footer.html b/app/templates/base/footer.html new file mode 100644 index 0000000..4880fb9 --- /dev/null +++ b/app/templates/base/footer.html @@ -0,0 +1,5 @@ +
+

Planète Casio est un site communautaire non affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.

+

Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou des copyrights.

+

CASIO est une marque déposée par CASIO Computer Co., Ltd.

+
diff --git a/app/templates/base/footer.html.j2 b/app/templates/base/footer.html.j2 deleted file mode 100644 index c4f1f15..0000000 --- a/app/templates/base/footer.html.j2 +++ /dev/null @@ -1,8 +0,0 @@ -
-

Planète Casio est un site communautaire indépendant, géré bénévolement et n'est donc pas - affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.

-

Les fichiers, programmes et autres publications présents sur Planète Casio restent la - propriété de leurs auteurs respectifs et peuvent être soumis à des licences ou des - copyrights.

-

CASIO est une marque déposée par CASIO Computer Co., Ltd.

-
diff --git a/app/templates/base/head.html.j2 b/app/templates/base/head.html similarity index 60% rename from app/templates/base/head.html.j2 rename to app/templates/base/head.html index 9be58bc..a3fe2eb 100644 --- a/app/templates/base/head.html.j2 +++ b/app/templates/base/head.html @@ -6,7 +6,11 @@ + + + + diff --git a/app/templates/base/header.html.j2 b/app/templates/base/header.html similarity index 80% rename from app/templates/base/header.html.j2 rename to app/templates/base/header.html index 2d4558e..b0e9837 100644 --- a/app/templates/base/header.html.j2 +++ b/app/templates/base/header.html @@ -1,6 +1,6 @@
-
- + + diff --git a/app/templates/base/navbar.html.j2 b/app/templates/base/navbar.html similarity index 88% rename from app/templates/base/navbar.html.j2 rename to app/templates/base/navbar.html index 967f2b9..c526f14 100644 --- a/app/templates/base/navbar.html.j2 +++ b/app/templates/base/navbar.html @@ -69,22 +69,18 @@ diff --git a/app/templates/base/navbar/account.html.j2 b/app/templates/base/navbar/account.html similarity index 85% rename from app/templates/base/navbar/account.html.j2 rename to app/templates/base/navbar/account.html index 7794e84..f00fa42 100644 --- a/app/templates/base/navbar/account.html.j2 +++ b/app/templates/base/navbar/account.html @@ -1,8 +1,8 @@ {% if current_user.is_authenticated %}

- - {{ current_user.username }} + + {{ current_user.name }}

@@ -22,7 +22,7 @@ Topics favoris - + @@ -31,7 +31,7 @@
-
+ @@ -49,14 +49,12 @@

Invité

- - {{ form.hidden_tag() }} -

- {{ form.username(size=32, placeholder="Identifiant") }} - {{ form.password(size=32, placeholder="Mot de passe") }} -

-

{{ form.submit() }}

-

{{ form.remember_me.label }} {{ form.remember_me() }}

+ + {{ login_form.hidden_tag() }} + {{ login_form.username(size=32, placeholder="Identifiant") }} + {{ login_form.password(size=32, placeholder="Mot de passe") }} + {{ login_form.submit(class_="bg-green") }} + {{ login_form.remember_me.label }} {{ login_form.remember_me() }}
Mot de passe oublié ? diff --git a/app/templates/base/navbar/forum.html b/app/templates/base/navbar/forum.html new file mode 100644 index 0000000..491a272 --- /dev/null +++ b/app/templates/base/navbar/forum.html @@ -0,0 +1,25 @@ + diff --git a/app/templates/base/navbar/forum.html.j2 b/app/templates/base/navbar/forum.html.j2 deleted file mode 100644 index abbf80a..0000000 --- a/app/templates/base/navbar/forum.html.j2 +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/app/templates/base/navbar/news.html.j2 b/app/templates/base/navbar/news.html similarity index 100% rename from app/templates/base/navbar/news.html.j2 rename to app/templates/base/navbar/news.html diff --git a/app/templates/base/navbar/programs.html.j2 b/app/templates/base/navbar/programs.html similarity index 100% rename from app/templates/base/navbar/programs.html.j2 rename to app/templates/base/navbar/programs.html diff --git a/app/templates/base/navbar/sprites.html.j2 b/app/templates/base/navbar/sprites.html similarity index 100% rename from app/templates/base/navbar/sprites.html.j2 rename to app/templates/base/navbar/sprites.html diff --git a/app/templates/base/navbar/tools.html.j2 b/app/templates/base/navbar/tools.html similarity index 98% rename from app/templates/base/navbar/tools.html.j2 rename to app/templates/base/navbar/tools.html index dc1c4f4..1f08e77 100644 --- a/app/templates/base/navbar/tools.html.j2 +++ b/app/templates/base/navbar/tools.html @@ -6,7 +6,7 @@ Outils - + diff --git a/app/templates/base/navbar/tutorials.html.j2 b/app/templates/base/navbar/tutorials.html similarity index 50% rename from app/templates/base/navbar/tutorials.html.j2 rename to app/templates/base/navbar/tutorials.html index 8b09976..0fe2d66 100644 --- a/app/templates/base/navbar/tutorials.html.j2 +++ b/app/templates/base/navbar/tutorials.html @@ -5,9 +5,9 @@ Tutoriels - Basic Casio - C/C++ Casio - Arduino + Basic Casio + C/C++ Casio + Arduino
- Foire aux questions (FAQ) + Foire aux questions (FAQ)
diff --git a/app/templates/base/scripts.html.j2 b/app/templates/base/scripts.html similarity index 100% rename from app/templates/base/scripts.html.j2 rename to app/templates/base/scripts.html diff --git a/app/templates/delete_account.html b/app/templates/delete_account.html new file mode 100644 index 0000000..447edef --- /dev/null +++ b/app/templates/delete_account.html @@ -0,0 +1,26 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

Suppression du compte

+
+ {{ del_form.hidden_tag() }} +
+ {{ del_form.delete.label }} + {{ del_form.delete(checked=False) }} +
{{ del_form.delete.description }}
+ {% for error in del_form.delete.errors %} + {{ error }} + {% endfor %} +
+
+ {{ del_form.old_password.label }} + {{ del_form.old_password(placeholder='************') }} + {% for error in del_form.old_password.errors %} + {{ error }} + {% endfor %} +
+
{{ del_form.submit(class_="bg-red") }}
+
+
+{% endblock %} diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html new file mode 100644 index 0000000..ad49def --- /dev/null +++ b/app/templates/errors/403.html @@ -0,0 +1,9 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

403 - Accès non autorisé

+ + +
+{% endblock %} diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..6282c07 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,9 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

404 - Page non trouvée

+ + +
+{% endblock %} diff --git a/app/templates/index.html.j2 b/app/templates/index.html similarity index 61% rename from app/templates/index.html.j2 rename to app/templates/index.html index 0a99b9e..4905fee 100644 --- a/app/templates/index.html.j2 +++ b/app/templates/index.html @@ -1,9 +1,9 @@ -{% extends "base/container.html.j2" %} +{% extends "base/container.html" %} {% block content %}
-

du contenu....

+

du contenu....

{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..4e4e4ce --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

Connexion

+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.remember_me() }} {{ form.remember_me.label }}

+

{{ form.submit() }}

+
+

Pas encore de compte ? Créé-en un !

+ +{% endblock %} diff --git a/app/templates/login.html.j2 b/app/templates/login.html.j2 deleted file mode 100644 index c0d5405..0000000 --- a/app/templates/login.html.j2 +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "container.html.j2" %} - -{% block content %} -

Sign In

-
- {{ form.hidden_tag() }} -

- {{ form.username.label }}
- {{ form.username(size=32) }}
- {% for error in form.username.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form.password.label }}
- {{ form.password(size=32) }}
- {% for error in form.password.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form.remember_me() }} {{ form.remember_me.label }}

-

{{ form.submit() }}

-
-

New User? Click to Register!

-{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..fd5088f --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,56 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

Inscription

+ +
+ {{ form.hidden_tag() }} +
+ {{ form.username.label }} + {{ form.username() }} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.email.label }} + {{ form.email() }} + {% for error in form.email.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password.label }} + {{ form.password() }} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.password2.label }} + {{ form.password2() }} + {% for error in form.password2.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.guidelines.label }} + {{ form.guidelines() }} + {% for error in form.guidelines.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.newsletter.label }} + {{ form.newsletter() }} +
{{ form.newsletter.description }}
+ {% for error in form.newsletter.errors %} + {{ error }} + {% endfor %} +
+
{{ form.submit(class_="bg-green") }}
+
+
+ +{% endblock %} diff --git a/app/templates/register.html.j2 b/app/templates/register.html.j2 deleted file mode 100644 index ab20a74..0000000 --- a/app/templates/register.html.j2 +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "base/container.html.j2" %} - -{% block content %} -
- -

Inscription :

- -
- {{ form2.hidden_tag() }} -

- {{ form2.username.label }}
- {{ form2.username(size=32) }}
- {% for error in form2.username.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form2.email.label }}
- {{ form2.email(size=64) }}
- {% for error in form2.email.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form2.password.label }}
- {{ form2.password(size=32) }}
- {% for error in form2.password.errors %} - [{{ error }}] - {% endfor %} -

-

- {{ form2.password2.label }}
- {{ form2.password2(size=32) }}
- {% for error in form2.password2.errors %} - [{{ error }}] - {% endfor %} -

-

{{ form2.submit() }}

-
-
-{% endblock %} diff --git a/app/templates/search.html b/app/templates/search.html new file mode 100644 index 0000000..aefa203 --- /dev/null +++ b/app/templates/search.html @@ -0,0 +1,19 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

Recherche avancée

+ +
+
+ {{ form.q.label }} + {{ form.q(value=request.args.get('q')) }} +
+
+ {{ form.date.label }} + {{ form.date }} +
+
{{ form.submit(class_="bg-green") }}
+
+
+{% endblock %} diff --git a/app/templates/user.html b/app/templates/user.html new file mode 100644 index 0000000..861dabf --- /dev/null +++ b/app/templates/user.html @@ -0,0 +1,9 @@ +{% extends "base/container.html" %} + +{% block content %} +
+

+ {{ user.name }} + Profil de {{ user.name }}

+
+{% endblock %} diff --git a/app/templates/validation.html b/app/templates/validation.html new file mode 100644 index 0000000..bfba28f --- /dev/null +++ b/app/templates/validation.html @@ -0,0 +1,10 @@ +{% extends "base/container.html" %} + +{% block content %} +
+
+

ici il y aura la page qui demande de checker les mails pour valider


+ Retour à la page d'accueil +
+
+{% endblock content %} diff --git a/app/templates/validation.html.j2 b/app/templates/validation.html.j2 deleted file mode 100644 index 552266f..0000000 --- a/app/templates/validation.html.j2 +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base/container.html.j2" %} - -{% block content %} -
- -
-

ici il y aura la page qui demande de checker les mails pour valider


- Retour à la page d'acceuil -
-
-{% endblock content %} diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..d36344e --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,17 @@ +from flask import redirect, url_for, flash +from flask import current_user +import functools + +# Use only with @login_required. +def privilege_required(priv): + def privilege_decorator(f): + @functools.wraps(f) + def wrapper(): + if not current_user.priv(priv): + flash(f'Cette page est protégée par le privilège {priv}'+ + '', 'error') + return redirect(url_for('index')) + else: + f() + return wrapper + return privilege_decorator diff --git a/app/utils/priv_required.py b/app/utils/priv_required.py new file mode 100644 index 0000000..7a5fd21 --- /dev/null +++ b/app/utils/priv_required.py @@ -0,0 +1,38 @@ +from functools import wraps +from flask import redirect, url_for, request, flash, abort +from flask_login import current_user +from flask_login.config import EXEMPT_METHODS +from app import app + +def priv_required(*perms): + """ + If you decorate a view with this, it will ensure that the current user is + authenticated and has required permissions before calling the actual view. + (If they are not, it calls the :attr:`LoginManager.unauthorized` callback.) + For example:: + + @app.route('/admin') + @priv_required('access-admin-board') + def admin_board(): + pass + + It can be convenient to globally turn off authentication when unit testing. + To enable this, if the application configuration variable `LOGIN_DISABLED` + is set to `True`, this decorator will be ignored. + """ + def decorated_view(func): + @wraps(func) + def wrapped(*args, **kwargs): + if request.method in EXEMPT_METHODS: + return func(*args, **kwargs) + elif app.config.get('LOGIN_DISABLED'): + return func(*args, **kwargs) + elif not current_user.is_authenticated: + return app.login_manager.unauthorized() + else: + for p in perms: + if not current_user.priv(p): + abort(403) + return func(*args, **kwargs) + return wrapped + return decorated_view diff --git a/app/utils/render.py b/app/utils/render.py new file mode 100644 index 0000000..b7b918f --- /dev/null +++ b/app/utils/render.py @@ -0,0 +1,14 @@ +from flask import render_template +from app.forms.login import LoginForm +from app.forms.search import SearchForm + +def render(*args, **kwargs): + # TODO: debugguer cette merde : au logout, ça foire + # if current_user.is_authenticated: + # login_form = LoginForm() + # return render_template(*args, **kwargs, login_form=login_form) + # return render_template(*args, **kwargs) + login_form = LoginForm() + search_form = SearchForm() + return render_template(*args, **kwargs, + login_form=login_form, search_form=search_form) \ No newline at end of file diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000..3f58c3b --- /dev/null +++ b/app/utils/validators.py @@ -0,0 +1,30 @@ +from flask_login import current_user +from wtforms.validators import ValidationError +from app.models.users import User, Member + +def name(form, name): + member = Member.query.filter_by(name=name.data).first() + if member is not None: + raise ValidationError('Pseudo indisponible.') + if not User.valid_name(name.data): + raise ValidationError("Nom d'utilisateur invalide.") + +def email(form, email): + member = Member.query.filter_by(email=email.data).first() + if member is not None: + raise ValidationError('Adresse email déjà utilisée.') + +def password(form, password): + if len(password.data) != 0 and len(password.data) < 10: + raise ValidationError('Mot de passe est trop court (10 caractères minimum).') + # TODO: add more rules >:] + +def avatar(form, avatar): + pass + +def old_password(form, field): + if field.data: + if not form.old_password.data: + raise ValidationError('Votre ancien mot de passe est requis pour cette modification.') + if not current_user.check_password(form.old_password.data): + raise ValidationError('Mot de passe actuel erroné.') diff --git a/assets/diagramme_1.dia b/assets/diagramme_1.dia new file mode 100644 index 0000000..4e72781 Binary files /dev/null and b/assets/diagramme_1.dia differ diff --git a/assets/diagramme_1.dia~ b/assets/diagramme_1.dia~ new file mode 100644 index 0000000..e4d1e75 Binary files /dev/null and b/assets/diagramme_1.dia~ differ diff --git a/assets/diagramme_1.png b/assets/diagramme_1.png new file mode 100644 index 0000000..34e114d Binary files /dev/null and b/assets/diagramme_1.png differ diff --git a/assets/privs.txt b/assets/privs.txt new file mode 100644 index 0000000..6a1d5ac --- /dev/null +++ b/assets/privs.txt @@ -0,0 +1,38 @@ +# Privileges + +Read/write access to forum boards: + access-admin-board Administration board of the forum + access-assoc-board CreativeCalc discussion board + write-news Post articles on the news board + +Shared file upload (like /Fr/adpc/img.php for any file): + upload-shared-files Upload files and images on the website server + delete-shared-files Delete files uploaded with upload-shared-files + +Post management: + edit-posts Edit any post on the website + delete-posts Remove any post from the website + scheduled-posting Schedule a post or content creation in the future + +Content (topic, progs, tutos, etc) management: + delete-content Delete whole topics, program pages, or tutorials + move-public-content Change the section of a page in a public section + move-private-content Change the section of a page in a private section + showcase-content Manage stocky content (post-its) + edit-static-content Edit static content pages + +Program evaluation: + delete-notes Delete program notes + delete-tests Delete program tests + +Shoutbox: + shoutbox-post Write messages in the shoutbox + shoutbox-kick Kick people using the shoutbox + shoutbox-ban Ban people using the shoutbox + +Miscellaenous: + unlimited-pms Removes the limit on the number of private messages + footer-statistics View performance statistics in the page footer + community-login Automatically login as a community account + +Administration panel... diff --git a/assets/users.txt b/assets/users.txt new file mode 100644 index 0000000..ba432c1 --- /dev/null +++ b/assets/users.txt @@ -0,0 +1,73 @@ +# User management + +User information: + + Name Unique, no space, at least one letter + Avatar Stored in a server folder. Size limit? + Password Hashed, of course + Email Mail address, used to send newsletters + + Points Participation measure (mainly number of posts) + Innovation points A different kind of participation measure + + Biography Description of the user + Signature Short signature added at the end of every post + Birthday Birthday date + + Newsletter Subscription to newsletter + Settings... + +Relations: + + Notifications 1 to many + Groups 1 to many + Sent PMs 1 to many + Received PMs 1 to many + Trophies/Titles 1 to many + + All posts many to many (tutorials can have several authors) + Privileges many to many + +Rest API for users: + Requests where "Search" is set to "Yes" accept search patterns. The syntax + needs to be chosen, it could be something like "/users[name~=/*storm*/i]". + + Method URL Search Description + ----------------------------------------------------------------------------- + GET /users Yes Query users + POST /users - Create new user + ----------------------------------------------------------------------------- + GET /users/ - Get user information/settings + PATCH /users/ - Update user information/settings + DELETE /users/ - Delete user account + ----------------------------------------------------------------------------- + GET /users//trophies - Get unlocked trophies + ----------------------------------------------------------------------------- + GET /users//messages Yes Query private messages + POST /users//messages - Send PM ( is sender) + DELETE /users//messages Required Delete PMs matching pattern + ----------------------------------------------------------------------------- + GET /users//groups - Get user groups + PATCH /users//groups - Add or remove group memberships (*) + ----------------------------------------------------------------------------- + GET /users//privs - Get user privileges + PATCH /users//privs - Grant/revoke special privileges (*) + ----------------------------------------------------------------------------- + + Updating the participation scores is not a request, it's tied to posting + contents, so it has nothing to do in the API. + + (*) Not sure if this is relevant, since these are administrator duties. + +Rest API for groups: + + Method URL Search Description + ----------------------------------------------------------------------------- + GET /groups - Get list of groups + POST /groups - Create group + ----------------------------------------------------------------------------- + GET /groups/ - Get list of users in groups + ----------------------------------------------------------------------------- + + There are no methods to change the privileges associated to each groups + because this task is clearly for administrators, not API users. diff --git a/config.py b/config.py index b8f5717..cf6b501 100644 --- a/config.py +++ b/config.py @@ -1,9 +1,17 @@ import os -basedir = os.path.abspath(os.path.dirname(__file__)) - class Config(object): - SECRET_KEY = os.environ.get('SECRET_KEY') or 'a-random-secret-key' - SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ - 'sqlite:///' + os.path.join(basedir, 'app.db') - SQLALCHEMY_TRACK_MODIFICATIONS = False + SECRET_KEY = os.environ.get('SECRET_KEY') or 'a-random-secret-key' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'postgresql+psycopg2://' + os.environ.get('USER') + ':@/pcv5' + SQLALCHEMY_TRACK_MODIFICATIONS = False + UPLOAD_FOLDER = './app/static/avatars' + +class V5Config(object): + # Length allocated to privilege names (slugs) + PRIVS_MAXLEN = 64 + # Forbidden user names + FORBIDDEN_USERNAMES = [ "admin", "root", "webmaster", "contact" ] + # Unauthorized message (@priv_required) + UNAUTHORIZED_MSG = "Vous n'avez pas l'autorisation d'effectuer cette action !" + diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..2d31c5a --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,90 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + except Exception as exception: + logger.error(exception) + raise exception + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/27c0fff58193_.py b/migrations/versions/27c0fff58193_.py new file mode 100644 index 0000000..350a217 --- /dev/null +++ b/migrations/versions/27c0fff58193_.py @@ -0,0 +1,73 @@ +"""empty message + +Revision ID: 27c0fff58193 +Revises: +Create Date: 2019-02-02 16:06:49.372395 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '27c0fff58193' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('content', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=20), nullable=True), + sa.Column('data', sa.Text(convert_unicode=True), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('guest', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.Unicode(length=64), nullable=True), + sa.Column('last_post', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_guest_username'), 'guest', ['username'], unique=False) + op.create_table('member', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.Unicode(length=64), nullable=True), + sa.Column('email', sa.String(length=120), nullable=True), + sa.Column('password_hash', sa.String(length=255), nullable=True), + sa.Column('xp_points', sa.Integer(), nullable=True), + sa.Column('innovation_points', sa.Integer(), nullable=True), + sa.Column('biography', sa.Text(convert_unicode=True), nullable=True), + sa.Column('signature', sa.Text(convert_unicode=True), nullable=True), + sa.Column('birthday', sa.Date(), nullable=True), + sa.Column('register_date', sa.Date(), nullable=True), + sa.Column('receive_newsletter', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_member_email'), 'member', ['email'], unique=True) + op.create_index(op.f('ix_member_username'), 'member', ['username'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_member_username'), table_name='member') + op.drop_index(op.f('ix_member_email'), table_name='member') + op.drop_table('member') + op.drop_index(op.f('ix_guest_username'), table_name='guest') + op.drop_table('guest') + op.drop_table('content') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/migrations/versions/29ca8250bd4a_.py b/migrations/versions/29ca8250bd4a_.py new file mode 100644 index 0000000..d509635 --- /dev/null +++ b/migrations/versions/29ca8250bd4a_.py @@ -0,0 +1,53 @@ +"""empty message + +Revision ID: 29ca8250bd4a +Revises: d2c96bebc596 +Create Date: 2019-02-03 14:45:18.339043 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '29ca8250bd4a' +down_revision = 'd2c96bebc596' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group_privilege', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('gid', sa.Integer(), nullable=True), + sa.Column('priv', sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(['gid'], ['group.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_group_privilege_gid'), 'group_privilege', ['gid'], unique=False) + op.create_table('group_user', + sa.Column('gid', sa.Integer(), nullable=True), + sa.Column('uid', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['gid'], ['group.id'], ), + sa.ForeignKeyConstraint(['uid'], ['user.id'], ) + ) + op.create_table('special_privilege', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uid', sa.Integer(), nullable=True), + sa.Column('priv', sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(['uid'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_special_privilege_uid'), 'special_privilege', ['uid'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_special_privilege_uid'), table_name='special_privilege') + op.drop_table('special_privilege') + op.drop_table('group_user') + op.drop_index(op.f('ix_group_privilege_gid'), table_name='group_privilege') + op.drop_table('group_privilege') + # ### end Alembic commands ### diff --git a/migrations/versions/53f5ab9da859_.py b/migrations/versions/53f5ab9da859_.py new file mode 100644 index 0000000..50ab4b8 --- /dev/null +++ b/migrations/versions/53f5ab9da859_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 53f5ab9da859 +Revises: 8b2cd63804b3 +Create Date: 2019-02-04 15:09:58.315410 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '53f5ab9da859' +down_revision = '8b2cd63804b3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('group', sa.Column('description', sa.UnicodeText(), nullable=True)) + op.drop_index('ix_group_privilege_gid', table_name='group_privilege') + op.add_column('special_privilege', sa.Column('mid', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_special_privilege_mid'), 'special_privilege', ['mid'], unique=False) + op.drop_index('ix_special_privilege_uid', table_name='special_privilege') + op.drop_constraint('special_privilege_uid_fkey', 'special_privilege', type_='foreignkey') + op.create_foreign_key(None, 'special_privilege', 'member', ['mid'], ['id']) + op.drop_column('special_privilege', 'uid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('special_privilege', sa.Column('uid', sa.INTEGER(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'special_privilege', type_='foreignkey') + op.create_foreign_key('special_privilege_uid_fkey', 'special_privilege', 'user', ['uid'], ['id']) + op.create_index('ix_special_privilege_uid', 'special_privilege', ['uid'], unique=False) + op.drop_index(op.f('ix_special_privilege_mid'), table_name='special_privilege') + op.drop_column('special_privilege', 'mid') + op.create_index('ix_group_privilege_gid', 'group_privilege', ['gid'], unique=False) + op.drop_column('group', 'description') + # ### end Alembic commands ### diff --git a/migrations/versions/7697154d0a4b_.py b/migrations/versions/7697154d0a4b_.py new file mode 100644 index 0000000..e4a11dc --- /dev/null +++ b/migrations/versions/7697154d0a4b_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 7697154d0a4b +Revises: 7dfd5e3aa1fb +Create Date: 2019-02-02 20:54:59.683353 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '7697154d0a4b' +down_revision = '7dfd5e3aa1fb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(length=50), nullable=True), + sa.Column('css', sa.UnicodeText(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.drop_column('guest', 'last_post') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('guest', sa.Column('last_post', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.drop_table('group') + # ### end Alembic commands ### diff --git a/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py b/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py new file mode 100644 index 0000000..b9efe89 --- /dev/null +++ b/migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py @@ -0,0 +1,28 @@ +"""Fix a typo in a field name + +Revision ID: 7dfd5e3aa1fb +Revises: 27c0fff58193 +Create Date: 2019-02-02 16:13:13.229250 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7dfd5e3aa1fb' +down_revision = '27c0fff58193' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('guest', sa.Column('ip', sa.String(length=47), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('guest', 'ip') + # ### end Alembic commands ### diff --git a/migrations/versions/8b2cd63804b3_.py b/migrations/versions/8b2cd63804b3_.py new file mode 100644 index 0000000..7d06be5 --- /dev/null +++ b/migrations/versions/8b2cd63804b3_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 8b2cd63804b3 +Revises: 29ca8250bd4a +Create Date: 2019-02-03 14:54:10.804975 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8b2cd63804b3' +down_revision = '29ca8250bd4a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group_member', + sa.Column('gid', sa.Integer(), nullable=True), + sa.Column('uid', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['gid'], ['group.id'], ), + sa.ForeignKeyConstraint(['uid'], ['member.id'], ) + ) + op.drop_table('group_user') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group_user', + sa.Column('gid', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('uid', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['gid'], ['group.id'], name='group_user_gid_fkey'), + sa.ForeignKeyConstraint(['uid'], ['user.id'], name='group_user_uid_fkey') + ) + op.drop_table('group_member') + # ### end Alembic commands ### diff --git a/migrations/versions/d2c96bebc596_.py b/migrations/versions/d2c96bebc596_.py new file mode 100644 index 0000000..5730aad --- /dev/null +++ b/migrations/versions/d2c96bebc596_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: d2c96bebc596 +Revises: 7697154d0a4b +Create Date: 2019-02-02 21:37:53.802259 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2c96bebc596' +down_revision = '7697154d0a4b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('bio', sa.UnicodeText(), nullable=True)) + op.add_column('member', sa.Column('innovation', sa.Integer(), nullable=True)) + op.add_column('member', sa.Column('name', sa.Unicode(length=32), nullable=True)) + op.add_column('member', sa.Column('newsletter', sa.Boolean(), nullable=True)) + op.add_column('member', sa.Column('xp', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_member_name'), 'member', ['name'], unique=True) + op.drop_index('ix_member_username', table_name='member') + op.drop_column('member', 'xp_points') + op.drop_column('member', 'receive_newsletter') + op.drop_column('member', 'biography') + op.drop_column('member', 'username') + op.drop_column('member', 'innovation_points') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('innovation_points', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('username', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('biography', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('receive_newsletter', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('member', sa.Column('xp_points', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_index('ix_member_username', 'member', ['username'], unique=True) + op.drop_index(op.f('ix_member_name'), table_name='member') + op.drop_column('member', 'xp') + op.drop_column('member', 'newsletter') + op.drop_column('member', 'name') + op.drop_column('member', 'innovation') + op.drop_column('member', 'bio') + # ### end Alembic commands ### diff --git a/scripts/init.sh b/scripts/init.sh old mode 100644 new mode 100755 index bb89dd6..64476b7 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env sh flask db init flask db migrate -m "initialisation" flask db upgrade diff --git a/scripts/migrate.sh b/scripts/migrate.sh old mode 100644 new mode 100755 index 43e104f..843deb8 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -1,2 +1,3 @@ +#!/usr/bin/env sh flask db migrate -m $1 flask db upgrade diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh old mode 100644 new mode 100755 index e19c044..a25ceec --- a/scripts/run_dev.sh +++ b/scripts/run_dev.sh @@ -1,2 +1,3 @@ +#!/usr/bin/env sh FLASK_DEBUG=1 flask run