Merge branch 'master-fork'

This commit is contained in:
Eragon 2019-02-06 11:27:41 +01:00
commit 870ec32887
No known key found for this signature in database
GPG Key ID: B2B1BF4DA61BBB85
89 changed files with 1958 additions and 644 deletions

13
.gitignore vendored
View File

@ -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

View File

@ -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

5
V5.py
View File

@ -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}

BIN
app.db

Binary file not shown.

View File

@ -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

View File

@ -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é.')

32
app/forms/account.py Normal file
View File

@ -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('Jaccepte les <a href="#">CGU</a>', 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')

9
app/forms/login.py Normal file
View File

@ -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')

14
app/forms/search.py Normal file
View File

@ -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()])

View File

@ -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 '<User {}>'.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 '<Post {}>'.format(self.body)

19
app/models/contents.py Normal file
View File

@ -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")

76
app/models/privs.py Normal file
View File

@ -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'<Privilege "{self.priv}" of member #{mid}>'
# 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'<Group "{self.name}">'
# 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

221
app/models/users.py Normal file
View File

@ -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'<User #{self.id}>'
@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'<Guest: {self.username} ({self.ip})>'
# 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'<Member: {self.name}>'
@app.login.user_loader
def load_user(id):
return User.query.get(int(id))

View File

@ -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/<int: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

67
app/routes/account.py Normal file
View File

@ -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')

58
app/routes/admin.py Normal file
View File

@ -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)

15
app/routes/index.py Normal file
View File

@ -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

43
app/routes/login.py Normal file
View File

@ -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'))

8
app/routes/search.py Normal file
View File

@ -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)

20
app/routes/users.py Normal file
View File

@ -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/<username>')
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/<int: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))

View File

@ -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;
} */

45
app/static/css/flash.css Normal file
View File

@ -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;
}

View File

@ -1,5 +1,5 @@
/*
footer
Footer
*/
footer {
@ -10,4 +10,4 @@ footer {
}
footer p {
margin: 3px 0;
}
}

50
app/static/css/form.css Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

Binary file not shown.

BIN
app/static/images/403.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
app/static/images/404.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -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 = `<div class="flash {{ category }}" style="top: {{ top }}px;" onclick="flash_close(this)">
<svg style='width:24px;height:24px' viewBox='0 0 24 24'>
{{ icon }}
</svg>
<span>
{{ message }}
</span>
<input type="button" value="MASQUER"></input>
</div>`;
paths = {
'error': '<path fill="#727272" d="M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z"></path>',
'warning': '<path fill="#727272" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16"></path>',
'ok': '<path fill="#727272" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"></path>',
'info': '<path fill="#727272" d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"></path>'
};
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);
}
}
//*/

View File

@ -0,0 +1,86 @@
{% extends "base/container.html" %}
{% block content %}
<section class="form">
<h1>Gestion du compte</h1>
<form action="{{ url_for('account') }}" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<h2>Général</h2>
<div>
{{ form.avatar.label }}
<div>
<img class="avatar" src="{{ url_for('static', filename=current_user.avatar) }}" meta="{{ current_user.avatar }}" />
{{ form.avatar }}
</div>
</div>
<div>
{{ form.email.label }}
{{ form.email(placeholder=current_user.email) }}
{% for error in form.email.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password.label }}
{{ form.password(placeholder='************') }}
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password2.label }}
{{ form.password2(placeholder='************') }}
{% for error in form.password2.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.old_password.label }}
{{ form.old_password(placeholder='************') }}
{% for error in form.old_password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<h2>À propos</h2>
<div>
{{ form.birthday.label }}
{{ form.birthday(value=current_user.birthday) }}
{% for error in form.birthday.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.signature.label }}
<textarea id="{{ form.signature.name }}" name="{{ form.signature.name }}">{{ current_user.signature }}</textarea>
{% for error in form.signature.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.biography.label }}
<textarea id="{{ form.biography.name }}" name="{{ form.biography.name }}">{{ current_user.bio }}</textarea>
{% for error in form.biography.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<h2>Préférences</h2>
<div>
{{ form.newsletter.label }}
{{ form.newsletter(checked=current_user.newsletter) }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
{% for error in form.newsletter.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
</form>
<h2 style="margin-top:30px;">Supprimer le compte</h2>
<a href="{{ url_for('delete_account') }}" class="button bg-red">Supprimer le compte</a>
</section>
{% endblock %}

43
app/templates/admin.html Normal file
View File

@ -0,0 +1,43 @@
{% extends "base/container.html" %}
{% block content %}
<section>
<form action='' method='POST'>
{{ form.hidden_tag() }}
{{ form.submit }}
</form>
<h2>List of members</h2>
<table style="width:70%; margin: auto;">
<tr><th>Name</th><th>Email</th><th>Register</th><th>XP</th><th>Inn.</th>
<th>Newsletter</th></tr>
{% for user in users %}
<tr><td><a href="{{ url_for('user_by_id', user_id=user.id) }}" >{{ user.name }}</a></td>
<td>{{ user.email }}</td>
<td>{{ user.register_date }}</td><td>{{ user.xp }}</td>
<td>{{ user.innovation }}</td>
<td>{{ "Yes" if user.newsletter else "No" }}</td></tr>
{% endfor %}
</table>
<h2>List of groups</h2>
<table style="width:70%; margin: auto;">
<tr><th>Group</th><th>Members</th><th>Privileges</th></tr>
{% for group in groups %}
<tr><td><span style="{{ group.css }}">{{ group.name }}</span></td><td>
{% for user in group.members %}
{{ user.name }}
{% endfor %}
</td><td>
{% for priv in group.privs %}
<code>{{ priv }}</code>
{% endfor %}
</td></tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -1,15 +0,0 @@
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert error" style="top: 95px;" onclick="close_important(this)">
<svg style='width:24px;height:24px' viewBox='0 0 24 24'>
<path fill="#727272" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16"></path>
</svg>
<span>
{{ message }}
</span>
<input type="button" class="flat" value="MASQUER" onclick="setCookie('pc_notif_2', 'true');"></input>
</div>
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="fr-FR">
{% include "base/head.html" %}
<body>
{% include "base/navbar.html" %}
{% block container %}
{% endblock container %}
{% include "base/footer.html" %}
{% include "base/flash.html" %}
{% include "base/scripts.html" %}
</body>
</html>

View File

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="fr-FR">
{% include "base/head.html.j2" %}
<body>
{% include "base/navbar.html.j2" %}
{% block container %}
{% endblock container %}
{% include "base/footer.html.j2" %}
{% include "base/alerts.html.j2" %}
{% include "base/scripts.html.j2" %}
</body>
</html>

View File

@ -1,8 +1,8 @@
{% extends "base/base.html.j2" %}
{% extends "base/base.html" %}
{% block container %}
<div id="container">
{% include "base/header.html.j2" %}
{% include "base/header.html" %}
{% block content %}
{% endblock content %}

View File

@ -1,52 +0,0 @@
{% extends "base/container.html.j2" %}
{% block content %}
<section class="home-pinned-content">
<p>
Erreur {{ error_code }}
<br>
{% 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.
<br>
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é.
<pre> /| /‾‾‾‾‾‾‾\ /| |\ | /‾‾‾‾‾\
/ | / \ / | | \ | / \ |
/ | | | / | | \ | / \ |
/ | | | / | | \ | | | |
/ | | | / | | \ | | | |
/ | | | / | | \ | | | |
/______|_ | | /______|_ | \ | | | |
| | | | | \ | \ / |
| \ / | | \ | \ / |
| \_______/ | | \| \_____/ |
|‾‾‾‾‾‾‾‾‾‾ /‾‾‾‾‾\ | | |\ | |‾‾‾‾‾‾‾\
| / \ | | | \ | | \
| / \ | | | \ | | \
| | | | | | \ | | |
| | | | | | \ | | |
| | | | | | \ | | |
| | | | | | \ | | |
| \ / \ / | \ | | /
| \ / \ / | \ | | /
| \_____/ \_____/ | \| |_______/</pre>
{% elif error_code == 418 %}
Oups! Il semblerai que vous m'ayez demandé du café mais je suis une théière.
<br>
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 à <a href="mailto: devs@planet-casio.com">devs@planet-casio.com</a>.
{% 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 <a href="/#">forum</a>.
{% elif error_code == 503 %}
Service Unavailable : Service temporairement indisponible ou en maintenance. Patientez!
{% endif %}
</p>
</section>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}" style="top: {{ loop.index * 70 - 45 }}px;" onclick="flash_close(this)">
<svg style='width:24px;height:24px' viewBox='0 0 24 24'>
{% if category=="error" %}<path fill="#727272" d="M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z"></path>{% endif %}
{% if category=="warning" %}<path fill="#727272" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16"></path>{% endif %}
{% if category=="ok" %}<path fill="#727272" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"></path>{% endif %}
{% if category=="info" %}<path fill="#727272" d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"></path>{% endif %}
</svg>
<span>
{{ message }}
</span>
<input type="button" value="MASQUER"></input>
</div>
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,5 @@
<footer>
<p>Planète Casio est un site communautaire non affilié à Casio | Toute reproduction de Planète Casio, même partielle, est interdite.</p>
<p>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.</p>
<p>CASIO est une marque déposée par CASIO Computer Co., Ltd.</p>
</footer>

View File

@ -1,8 +0,0 @@
<footer>
<p>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.</p>
<p>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.</p>
<p>CASIO est une marque déposée par CASIO Computer Co., Ltd.</p>
</footer>

View File

@ -6,7 +6,11 @@
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/global.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/navbar.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/header.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/container.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/form.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/footer.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/flash.css')}}>
<link rel="stylesheet" media="all and (min-width: 700px)" type="text/css" href={{url_for('static', filename = 'css/responsive.css')}}>
<link rel="stylesheet" media="all and (max-width: 699px)" type="text/css" href={{url_for('static', filename = 'css/light.css')}}>
</head>

View File

@ -1,6 +1,6 @@
<header>
<form>
<input type="search" placeholder="Recherche" />
<form action={{url_for('search')}} method="get">
<input type="search" name="q" id="q" placeholder="{{search_form.label}}" />
<a role="button" onclick="this.parentNode.submit();" href="#" class="light-hidden">
<svg viewBox="0 0 24 24">
<path fill="#adb0b4"d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>

View File

@ -69,22 +69,18 @@
</ul>
<div id="menu">
{% include "base/navbar/account.html" %}
{% include "base/navbar/account.html.j2" %}
{% include "base/navbar/news.html" %}
{% include "base/navbar/news.html.j2" %}
{% include "base/navbar/forum.html" %}
{% include "base/navbar/forum.html.j2" %}
{% include "base/navbar/programs.html" %}
{% include "base/navbar/programs.html.j2" %}
{% include "base/navbar/tutorials.html" %}
{% include "base/navbar/tutorials.html.j2" %}
{% include "base/navbar/sprites.html" %}
{% include "base/navbar/sprites.html.j2" %}
{% include "base/navbar/tools.html.j2" %}
{% if current_user.is_authenticated %}
{% endif %}
{% include "base/navbar/tools.html" %}
</div>
</nav>

View File

@ -1,8 +1,8 @@
{% if current_user.is_authenticated %}
<div>
<h2>
<img src="{{ url_for('static', filename= 'images/3864.png') }}">
{{ current_user.username }}
<img src="{{ url_for('static', filename=current_user.avatar) }}">
{{ current_user.name }}
</h2>
<a href="#">
<svg viewBox="0 0 24 24">
@ -22,7 +22,7 @@
</svg>
Topics favoris
</a>
<a href="#">
<a href="{{ url_for('admin') }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M3,1H19A1,1 0 0,1 20,2V6A1,1 0 0,1 19,7H3A1,1 0 0,1 2,6V2A1,1 0 0,1 3,1M3,9H19A1,1 0 0,1 20,10V10.67L17.5,9.56L11,12.44V15H3A1,1 0 0,1 2,14V10A1,1 0 0,1 3,9M3,17H11C11.06,19.25 12,21.4 13.46,23H3A1,1 0 0,1 2,22V18A1,1 0 0,1 3,17M8,5H9V3H8V5M8,13H9V11H8V13M8,21H9V19H8V21M4,3V5H6V3H4M4,11V13H6V11H4M4,19V21H6V19H4M17.5,12L22,14V17C22,19.78 20.08,22.37 17.5,23C14.92,22.37 13,19.78 13,17V14L17.5,12M17.5,13.94L15,15.06V17.72C15,19.26 16.07,20.7 17.5,21.06V13.94Z"></path>
</svg>
@ -31,7 +31,7 @@
<hr />
<a href="account.html.j2">
<a href="{{ url_for('account') }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"></path>
</svg>
@ -49,14 +49,12 @@
<h2>
Invité
</h2>
<form method="post" action="" class="login with-ajax">
{{ form.hidden_tag() }}
<p>
{{ form.username(size=32, placeholder="Identifiant") }}
{{ form.password(size=32, placeholder="Mot de passe") }}
</p>
<p>{{ form.submit() }}</p>
<p>{{ form.remember_me.label }} {{ form.remember_me() }}</p>
<form method="post" action="{{url_for('login')}}" class="login">
{{ 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() }}
</form>
<hr />
<a href="{{ url_for('register') }}">Mot de passe oublié ?</a>

View File

@ -0,0 +1,25 @@
<div>
<h2>
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z"></path>
</svg>
Forum
</h2>
<a href="#">Vie communautaire</a>
<a href="#">Projets de programmation</a>
<a href="#">Questions et problèmes</a>
<a href="#">Discussions</a>
<a href="#">Administration</a>
<a href="#">CreativeCalc</a>
<hr />
<h3>Derniers commentaires</h3>
<ul>
<li><a href="#">Legolas</a> sur <a href="#">Bugs de la v5</a></li>
<li><a href="#">Dark Storm</a> sur <a href="#">fxSDK support</a></li>
<li><a href="#">Gollum</a> sur <a href="#">Le nom de topic qui fout le bordel car il est trop long…</a></li>
<li><a href="#">Lephenixnoir</a> sur <a href="#">fxSDK support</a></li>
<li><a href="#">Kristaba</a> sur <a href="#">FiXos, le retour</a></li>
</ul>
</div>

View File

@ -1,25 +0,0 @@
<div>
<h2>
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z"></path>
</svg>
Forum
</h2>
<a href="/forum">Vie communautaire</a>
<a href="/forum">Projets de programmation</a>
<a href="/forum">Questions et problèmes</a>
<a href="/forum">Discussions</a>
<a href="/forum">Administration</a>
<a href="/forum">CreativeCalc</a>
<hr />
<h3>Derniers commentaires</h3>
<ul>
<li><a href="/forum">Legolas</a> sur <a href="#">Bugs de la v5</a></li>
<li><a href="/forum">Dark Storm</a> sur <a href="#">fxSDK support</a></li>
<li><a href="/forum">Gollum</a> sur <a href="#">Le nom de topic qui fout le bordel car il est trop long…</a></li>
<li><a href="/forum">Lephenixnoir</a> sur <a href="#">fxSDK support</a></li>
<li><a href="/forum">Kristaba</a> sur <a href="#">FiXos, le retour</a></li>
</ul>
</div>

View File

@ -6,7 +6,7 @@
Outils
</h2>
<a href="https://git.planet-casio.com/">
<a href="#">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M2.6,10.59L8.38,4.8L10.07,6.5C9.83,7.35 10.22,8.28 11,8.73V14.27C10.4,14.61 10,15.26 10,16A2,2 0 0,0 12,18A2,2 0 0,0 14,16C14,15.26 13.6,14.61 13,14.27V9.41L15.07,11.5C15,11.65 15,11.82 15,12A2,2 0 0,0 17,14A2,2 0 0,0 19,12A2,2 0 0,0 17,10C16.82,10 16.65,10 16.5,10.07L13.93,7.5C14.19,6.57 13.71,5.55 12.78,5.16C12.35,5 11.9,4.96 11.5,5.07L9.8,3.38L10.59,2.6C11.37,1.81 12.63,1.81 13.41,2.6L21.4,10.59C22.19,11.37 22.19,12.63 21.4,13.41L13.41,21.4C12.63,22.19 11.37,22.19 10.59,21.4L2.6,13.41C1.81,12.63 1.81,11.37 2.6,10.59Z"></path>
</svg>

View File

@ -5,9 +5,9 @@
</svg>
Tutoriels
</h2>
<a href="/tutoriels/basiccasio">Basic Casio</a>
<a href="/tutoriels/ccplusplus">C/C++ Casio</a>
<a href="/tutoriels/arduino">Arduino</a>
<a href="#">Basic Casio</a>
<a href="#">C/C++ Casio</a>
<a href="#">Arduino</a>
<hr>
<a href="/tutoriels/faq">Foire aux questions (FAQ)</a>
<a href="#">Foire aux questions (FAQ)</a>
</div>

View File

@ -0,0 +1,26 @@
{% extends "base/container.html" %}
{% block content %}
<section class="form">
<h1>Suppression du compte</h2>
<form action="{{ url_for('delete_account') }}" method="post">
{{ del_form.hidden_tag() }}
<div>
{{ del_form.delete.label }}
{{ del_form.delete(checked=False) }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ del_form.delete.description }}</div>
{% for error in del_form.delete.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ del_form.old_password.label }}
{{ del_form.old_password(placeholder='************') }}
{% for error in del_form.old_password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-red") }}</div>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "base/container.html" %}
{% block content %}
<section>
<h1>403 - Accès non autorisé</h1>
<img src="{{url_for('static', filename = 'images/403.webp')}}" style="display:block;margin:auto"; />
</section>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "base/container.html" %}
{% block content %}
<section>
<h1>404 - Page non trouvée</h1>
<img src="{{url_for('static', filename = 'images/404.webp')}}" style="display:block;margin:auto"; />
</section>
{% endblock %}

View File

@ -1,9 +1,9 @@
{% extends "base/container.html.j2" %}
{% extends "base/container.html" %}
{% block content %}
<section class="home-pinned-content">
<div>
<p>du contenu....</p>
<p>du contenu....</p>
</div>
</section>
{% endblock %}

27
app/templates/login.html Normal file
View File

@ -0,0 +1,27 @@
{% extends "base/container.html" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Connexion</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}<br>
{% for error in form.password.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
<p>Pas encore de compte&nbsp;? <a href="{{ url_for('register') }}">Créé-en un&nbsp;!</a></p>
</form>
{% endblock %}

View File

@ -1,25 +0,0 @@
{% extends "container.html.j2" %}
{% block content %}
<h1>Sign In</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}<br>
{% for error in form.password.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "base/container.html" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Inscription</h1>
<form action="" method="post">
{{ form.hidden_tag() }}
<div>
{{ form.username.label }}
{{ form.username() }}
{% for error in form.username.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.email.label }}
{{ form.email() }}
{% for error in form.email.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password.label }}
{{ form.password() }}
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.password2.label }}
{{ form.password2() }}
{% for error in form.password2.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.guidelines.label }}
{{ form.guidelines() }}
{% for error in form.guidelines.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.newsletter.label }}
{{ form.newsletter() }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
{% for error in form.newsletter.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
</form>
</div>
</section>
{% endblock %}

View File

@ -1,41 +0,0 @@
{% extends "base/container.html.j2" %}
{% block content %}
<section class="home-pinned-content">
<h1>Inscription :</h1>
<form action="" method="post">
{{ form2.hidden_tag() }}
<p>
{{ form2.username.label }}<br>
{{ form2.username(size=32) }}<br>
{% for error in form2.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form2.email.label }}<br>
{{ form2.email(size=64) }}<br>
{% for error in form2.email.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form2.password.label }}<br>
{{ form2.password(size=32) }}<br>
{% for error in form2.password.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form2.password2.label }}<br>
{{ form2.password2(size=32) }}<br>
{% for error in form2.password2.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form2.submit() }}</p>
</form>
</section>
{% endblock %}

19
app/templates/search.html Normal file
View File

@ -0,0 +1,19 @@
{% extends "base/container.html" %}
{% block content %}
<section class="form">
<h1>Recherche avancée</h1>
<form action="" method="get">
<div>
{{ form.q.label }}
{{ form.q(value=request.args.get('q')) }}
</div>
<div>
{{ form.date.label }}
{{ form.date }}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
</form>
</section>
{% endblock %}

9
app/templates/user.html Normal file
View File

@ -0,0 +1,9 @@
{% extends "base/container.html" %}
{% block content %}
<section>
<h1>
<img class="avatar" src="{{ url_for('static', filename=user.avatar) }}" alt="{{ user.name }}" style="display:inline; vertical-align:middle;"/>
Profil de {{ user.name }}</h1>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base/container.html" %}
{% block content %}
<section>
<div>
<p>ici il y aura la page qui demande de checker les mails pour valider</p><br>
<a href="{{url_for('index')}}">Retour à la page d'accueil</a>
</div>
</section>
{% endblock content %}

View File

@ -1,11 +0,0 @@
{% extends "base/container.html.j2" %}
{% block content %}
<section class="home-pinned-content">
<div>
<p>ici il y aura la page qui demande de checker les mails pour valider</p><br>
<a href="{{url_for('index')}}">Retour à la page d'acceuil</a>
</div>
</section>
{% endblock content %}

17
app/utils/decorators.py Normal file
View File

@ -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 <code>{priv}'+
'</code>', 'error')
return redirect(url_for('index'))
else:
f()
return wrapper
return privilege_decorator

View File

@ -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

14
app/utils/render.py Normal file
View File

@ -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)

30
app/utils/validators.py Normal file
View File

@ -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é.')

BIN
assets/diagramme_1.dia Normal file

Binary file not shown.

BIN
assets/diagramme_1.dia~ Normal file

Binary file not shown.

BIN
assets/diagramme_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

38
assets/privs.txt Normal file
View File

@ -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...

73
assets/users.txt Normal file
View File

@ -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/<id> - Get user information/settings
PATCH /users/<id> - Update user information/settings
DELETE /users/<id> - Delete user account
-----------------------------------------------------------------------------
GET /users/<id>/trophies - Get unlocked trophies
-----------------------------------------------------------------------------
GET /users/<id>/messages Yes Query private messages
POST /users/<id>/messages - Send PM (<id> is sender)
DELETE /users/<id>/messages Required Delete PMs matching pattern
-----------------------------------------------------------------------------
GET /users/<id>/groups - Get user groups
PATCH /users/<id>/groups - Add or remove group memberships (*)
-----------------------------------------------------------------------------
GET /users/<id>/privs - Get user privileges
PATCH /users/<id>/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/<id> - 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.

View File

@ -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!"

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View File

@ -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

90
migrations/env.py Normal file
View File

@ -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()

24
migrations/script.py.mako Normal file
View File

@ -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"}

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

1
scripts/init.sh Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env sh
flask db init
flask db migrate -m "initialisation"
flask db upgrade

1
scripts/migrate.sh Normal file → Executable file
View File

@ -1,2 +1,3 @@
#!/usr/bin/env sh
flask db migrate -m $1
flask db upgrade

1
scripts/run_dev.sh Normal file → Executable file
View File

@ -1,2 +1,3 @@
#!/usr/bin/env sh
FLASK_DEBUG=1
flask run