Browse Source

Merge branch 'master-fork'

master
Samuel 8 months ago
parent
commit
870ec32887
Signed by: Eragonfr <sam.vzh@orange.fr> GPG Key ID: B2B1BF4DA61BBB85
89 changed files with 1951 additions and 637 deletions
  1. 6
    7
      .gitignore
  2. 2
    2
      README.md
  3. 3
    2
      V5.py
  4. BIN
      app.db
  5. 4
    1
      app/__init__.py
  6. 0
    29
      app/forms.py
  7. 32
    0
      app/forms/account.py
  8. 9
    0
      app/forms/login.py
  9. 14
    0
      app/forms/search.py
  10. 0
    36
      app/models.py
  11. 19
    0
      app/models/contents.py
  12. 76
    0
      app/models/privs.py
  13. 221
    0
      app/models/users.py
  14. 0
    103
      app/routes.py
  15. 67
    0
      app/routes/account.py
  16. 58
    0
      app/routes/admin.py
  17. 15
    0
      app/routes/index.py
  18. 43
    0
      app/routes/login.py
  19. 8
    0
      app/routes/search.py
  20. 20
    0
      app/routes/users.py
  21. 27
    2
      app/static/css/container.css
  22. 45
    0
      app/static/css/flash.css
  23. 2
    2
      app/static/css/footer.css
  24. 50
    0
      app/static/css/form.css
  25. 57
    134
      app/static/css/global.css
  26. 19
    62
      app/static/css/header.css
  27. 15
    18
      app/static/css/navbar.css
  28. BIN
      app/static/fonts/raleway_200.ttf
  29. BIN
      app/static/images/403.webp
  30. BIN
      app/static/images/404.webp
  31. 40
    6
      app/static/scripts/pc-utils.js
  32. 86
    0
      app/templates/account.html
  33. 43
    0
      app/templates/admin.html
  34. 0
    15
      app/templates/base/alerts.html.j2
  35. 16
    0
      app/templates/base/base.html
  36. 0
    16
      app/templates/base/base.html.j2
  37. 2
    2
      app/templates/base/container.html
  38. 0
    52
      app/templates/base/errors.html.j2
  39. 18
    0
      app/templates/base/flash.html
  40. 5
    0
      app/templates/base/footer.html
  41. 0
    8
      app/templates/base/footer.html.j2
  42. 4
    0
      app/templates/base/head.html
  43. 2
    2
      app/templates/base/header.html
  44. 7
    11
      app/templates/base/navbar.html
  45. 10
    12
      app/templates/base/navbar/account.html
  46. 25
    0
      app/templates/base/navbar/forum.html
  47. 0
    25
      app/templates/base/navbar/forum.html.j2
  48. 0
    0
      app/templates/base/navbar/news.html
  49. 0
    0
      app/templates/base/navbar/programs.html
  50. 0
    0
      app/templates/base/navbar/sprites.html
  51. 1
    1
      app/templates/base/navbar/tools.html
  52. 4
    4
      app/templates/base/navbar/tutorials.html
  53. 0
    0
      app/templates/base/scripts.html
  54. 26
    0
      app/templates/delete_account.html
  55. 9
    0
      app/templates/errors/403.html
  56. 9
    0
      app/templates/errors/404.html
  57. 2
    2
      app/templates/index.html
  58. 27
    0
      app/templates/login.html
  59. 0
    25
      app/templates/login.html.j2
  60. 56
    0
      app/templates/register.html
  61. 0
    41
      app/templates/register.html.j2
  62. 19
    0
      app/templates/search.html
  63. 9
    0
      app/templates/user.html
  64. 10
    0
      app/templates/validation.html
  65. 0
    11
      app/templates/validation.html.j2
  66. 17
    0
      app/utils/decorators.py
  67. 38
    0
      app/utils/priv_required.py
  68. 14
    0
      app/utils/render.py
  69. 30
    0
      app/utils/validators.py
  70. BIN
      assets/diagramme_1.dia
  71. BIN
      assets/diagramme_1.dia~
  72. BIN
      assets/diagramme_1.png
  73. 38
    0
      assets/privs.txt
  74. 73
    0
      assets/users.txt
  75. 14
    6
      config.py
  76. 1
    0
      migrations/README
  77. 45
    0
      migrations/alembic.ini
  78. 90
    0
      migrations/env.py
  79. 24
    0
      migrations/script.py.mako
  80. 73
    0
      migrations/versions/27c0fff58193_.py
  81. 53
    0
      migrations/versions/29ca8250bd4a_.py
  82. 42
    0
      migrations/versions/53f5ab9da859_.py
  83. 36
    0
      migrations/versions/7697154d0a4b_.py
  84. 28
    0
      migrations/versions/7dfd5e3aa1fb_fix_a_typo_in_a_field_name.py
  85. 40
    0
      migrations/versions/8b2cd63804b3_.py
  86. 50
    0
      migrations/versions/d2c96bebc596_.py
  87. 1
    0
      scripts/init.sh
  88. 1
    0
      scripts/migrate.sh
  89. 1
    0
      scripts/run_dev.sh

+ 6
- 7
.gitignore 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

+ 2
- 2
README.md 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

+ 3
- 2
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 View File


+ 4
- 1
app/__init__.py 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

+ 0
- 29
app/forms.py 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
- 0
app/forms/account.py 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('J’accepte 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
- 0
app/forms/login.py 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
- 0
app/forms/search.py 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()])

+ 0
- 36
app/models.py 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
- 0
app/models/contents.py 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
- 0
app/models/privs.py 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
- 0
app/models/users.py 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))

+ 0
- 103
app/routes.py 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
- 0
app/routes/account.py 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
- 0
app/routes/admin.py 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
- 0
app/routes/index.py 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
- 0
app/routes/login.py 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
- 0
app/routes/search.py 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
- 0
app/routes/users.py 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))

+ 27
- 2
app/static/css/container.css 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
- 0
app/static/css/flash.css 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;
}

+ 2
- 2
app/static/css/footer.css View File

@@ -1,5 +1,5 @@
/*
footer
Footer
*/

footer {
@@ -10,4 +10,4 @@ footer {
}
footer p {
margin: 3px 0;
}
}

+ 50
- 0
app/static/css/form.css 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;
}

+ 57
- 134
app/static/css/global.css View File

@@ -9,81 +9,30 @@


/*
body
ALL
*/

body {
margin: 0;
background: #fbfbfb;
font-family: 'DejaVu Sans', sans-serif;
* {
box-sizing: border-box;
transition: .15s ease;
}



/*
header
Body
*/

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);
body {
margin: 0;
background: #ffffff;
font-family: 'DejaVu Sans', sans-serif;
}


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;
}
.alert.ok {
border-color: #4caf50;
input,
textarea {
display: block;
background: #FFFFFF; color: #000000;
border: none;
}
.alert.error {
border-color: #f44336;
input:focus:not(type="button"),
textarea:focus {
box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25);
}
.alert span {
flex-grow: 1; margin: 15px 10px 10px 0;

/* Textarea */
textarea {
width: 100%;
border: 1px solid #eeeeee;
}
.alert input[type="button"] {
margin: 3px 30px 0 0;

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

.alert svg {
margin: 15px 20px 10px 30px;
/* 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;
}
input[type="button"].flat:hover {
background: rgba(0, 0, 0, .1);
.bg-green,
.bg-green {
background-color: #149641;
color: #ffffff;
}
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);
}
input[type="button"].raised:hover,
input[type="button"].raised:focus {
background: #d5d5d5;
}
input[type="button"].raised:active {
background: #d6d6d6;
box-shadow: 0 1px 8px rgba(0, 0, 0, .3);
.bg-red,
.bg-red {
background-color: #c0341d;
color: #ffffff;
}

/* Input text */
input[type="text"]:focus,
input[type="search"]:focus,
input[type="password"]:focus {
.bg-red:hover,
.bg-red:focus,
.bg-red:active {
background-color: #aa3421;
}


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

+ 19
- 62
app/static/css/header.css 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 {
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;
#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;
}
#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);
}


+ 15
- 18
app/static/css/navbar.css 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;
}
#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"]{
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;
#menu form input[type="password"] {
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="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;
}

BIN
app/static/fonts/raleway_200.ttf View File


BIN
app/static/images/403.webp View File


BIN
app/static/images/404.webp View File


+ 40
- 6
app/static/scripts/pc-utils.js 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);
}
}
//*/

+ 86
- 0
app/templates/account.html 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
- 0
app/templates/admin.html 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 %}

+ 0
- 15
app/templates/base/alerts.html.j2 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 %}

+ 16
- 0
app/templates/base/base.html 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>

+ 0
- 16
app/templates/base/base.html.j2 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>

app/templates/base/container.html.j2 → app/templates/base/container.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 %}

+ 0
- 52
app/templates/base/errors.html.j2 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 %}

+ 18
- 0
app/templates/base/flash.html 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 %}

+ 5
- 0
app/templates/base/footer.html 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>

+ 0
- 8
app/templates/base/footer.html.j2 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>

app/templates/base/head.html.j2 → app/templates/base/head.html 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>

app/templates/base/header.html.j2 → app/templates/base/header.html 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>

app/templates/base/navbar.html.j2 → app/templates/base/navbar.html 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>

app/templates/base/navbar/account.html.j2 → app/templates/base/navbar/account.html 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>

+ 25
- 0
app/templates/base/navbar/forum.html 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>

+ 0
- 25
app/templates/base/navbar/forum.html.j2 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>

app/templates/base/navbar/news.html.j2 → app/templates/base/navbar/news.html View File


app/templates/base/navbar/programs.html.j2 → app/templates/base/navbar/programs.html View File


app/templates/base/navbar/sprites.html.j2 → app/templates/base/navbar/sprites.html View File


app/templates/base/navbar/tools.html.j2 → app/templates/base/navbar/tools.html 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>

app/templates/base/navbar/tutorials.html.j2 → app/templates/base/navbar/tutorials.html 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>

app/templates/base/scripts.html.j2 → app/templates/base/scripts.html View File


+ 26
- 0
app/templates/delete_account.html 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 %}

+ 9
- 0
app/templates/errors/403.html 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 %}

+ 9
- 0
app/templates/errors/404.html 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 %}

app/templates/index.html.j2 → app/templates/index.html 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
- 0
app/templates/login.html 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>