Merge branch 'preprod' on master

This commit is contained in:
Eldeberen 2021-02-23 00:15:29 +01:00
commit 41eaaa4c30
Signed by: Darks
GPG Key ID: 7515644268BE1433
122 changed files with 3062 additions and 688 deletions

11
.gitignore vendored
View File

@ -4,8 +4,12 @@ app/__pycache__/
app/static/avatars/
app/static/images/trophies/
## Devlopement files
## Development files
# Flask env
.env
.flaskenv
# virtualenv
requirements.txt
venv/
@ -13,6 +17,9 @@ venv/
# pipenv
Pipfile
Pipfile.lock
# Tests files
test.*
## Deployment files
@ -25,10 +32,12 @@ update.sh
# Config to set up some server specific config
local_config.py
## Wiki
wiki/
## Personal folder
exclude/

4
V5.py
View File

@ -1,6 +1,4 @@
from app import app, db
from app.models.users import User, Guest, Member, Group, GroupPrivilege
from app.models.topic import Topic
from app import app
@app.shell_context_processor

View File

@ -1,19 +1,10 @@
from flask import Flask, g
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect
from config import Config
import time
<<<<<<< HEAD
<<<<<<< HEAD
import slugify
=======
>>>>>>> e15005a... Ajout des stats sur la durée de chargement
=======
>>>>>>> e15005a427f95829bbbad8f0d625ab9cb0c30e69
app = Flask(__name__)
app.config.from_object(Config)
@ -25,59 +16,23 @@ if Config.SECRET_KEY == "a-random-secret-key":
db = SQLAlchemy(app)
migrate = Migrate(app, db)
mail = Mail(app)
@app.before_request
def request_time():
g.request_start_time = time.time()
g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time)
@app.before_request
def request_time():
g.request_start_time = time.time()
g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time)
csrf = CSRFProtect(app)
login = LoginManager(app)
login.login_view = 'login'
login.login_message = "Veuillez vous authentifier avant de continuer."
<<<<<<< HEAD
<<<<<<< HEAD
# Register converters (needed for routing)
from app.utils.converters import *
app.url_map.converters['forum'] = ForumConverter
app.url_map.converters['topicpage'] = TopicPageConverter
@app.before_request
def request_time():
g.request_start_time = time.time()
g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time)
# Register routes
from app import routes
# Register filters
from app.utils import filters
from app.processors.menu import menu_processor
from app.processors.utilities import utilities_processor
=======
>>>>>>> e15005a... Ajout des stats sur la durée de chargement
=======
>>>>>>> e15005a427f95829bbbad8f0d625ab9cb0c30e69
from app import models # IDK why this is here, but it works
from app.models.comment import Comment
from app.models.thread import Thread
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.notification import Notification
from app.routes import index, search, users, tools # To load routes at initialization
from app.routes.account import login, account, notification
from app.routes.admin import index, groups, account, trophies, forums
from app.routes.forum import index, topic
from app.utils import pluralize # To use pluralize into the templates
from app.utils import date
from app.utils import is_title
# Add slugify into the available functions in every template
app.jinja_env.globals.update(
slugify=slugify.slugify
)
# Register processors
from app import processors

View File

@ -85,7 +85,7 @@
/projets/outils:
name: Projets pour d'autres plateformes
prefix: toolprojetcs
prefix: toolprojects
descr: Tous les projets tournant sur ordinateur, téléphone, ou toute autre
plateforme que la calculatrice.
@ -103,3 +103,18 @@
name: Discussion
prefix: discussion
descr: Sujets hors-sujet et discussion libre.
# Limited-access board
# Prefixes "admin" and "assoc" are reserved for this and require special
# privileges to list, read and edit topics and messages.
/admin:
name: Administration
prefix: admin
descr: Discussions sur l'administration du site, accessible uniquement aux
membres de l'équipe.
/creativecalc:
name: CreativeCalc
prefix: assoc
descr: Forum privé de l'association CreativeCalc, réservé aux membres.

View File

@ -11,7 +11,7 @@
shoutbox-kick shoutbox-ban
unlimited-pms footer-statistics community-login
access-admin-panel edit-account delete-account edit-trophies
delete_notification
delete_notification no-upload-limits
-
name: Modérateur
css: "color: green;"
@ -21,7 +21,7 @@
move-public-content extract-posts
delete-notes delete-tests
shoutbox-kick shoutbox-ban
unlimited-pms
unlimited-pms no-upload-limits
-
name: Développeur
css: "color: #4169e1;"
@ -31,7 +31,7 @@
scheduled-posting
edit-static-content
unlimited-pms footer-statistics community-login
access-admin-panel
access-admin-panel no-upload-limits
-
name: Rédacteur
css: "color: blue;"
@ -41,6 +41,7 @@
upload-shared-files delete-shared-files
scheduled-posting
showcase-content edit-static-content
no-upload-limits
-
name: Responsable communauté
css: "color: DarkOrange;"

View File

@ -1,9 +1,8 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField
from wtforms.fields.html5 import DateField, EmailField
from wtforms.validators import DataRequired, InputRequired, Optional, Email, EqualTo
from wtforms.validators import InputRequired, Optional, Email, EqualTo
from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty
from app.models.trophies import Trophy
import app.utils.validators as vd
@ -12,15 +11,15 @@ class RegistrationForm(FlaskForm):
'Pseudonyme',
description='Ce nom est définitif !',
validators=[
DataRequired(),
vd.name_valid,
vd.name_available,
InputRequired(),
vd.name.valid,
vd.name.available,
],
)
email = EmailField(
'Adresse Email',
validators=[
DataRequired(),
InputRequired(),
Email(message="Adresse email invalide."),
vd.email,
],
@ -28,21 +27,21 @@ class RegistrationForm(FlaskForm):
password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
vd.password,
InputRequired(),
vd.password.is_strong,
],
)
password2 = PasswordField(
'Répéter le mot de passe',
validators=[
DataRequired(),
InputRequired(),
EqualTo('password', message="Les mots de passe doivent être identiques."),
],
)
guidelines = BooleanField(
"""J'accepte les <a href="#">CGU</a>""",
validators=[
DataRequired(),
InputRequired(),
],
)
newsletter = BooleanField(
@ -59,7 +58,8 @@ class UpdateAccountForm(FlaskForm):
'Avatar',
validators=[
Optional(),
vd.avatar,
vd.file.is_image,
vd.file.avatar_size,
],
)
email = EmailField(
@ -68,15 +68,15 @@ class UpdateAccountForm(FlaskForm):
Optional(),
Email(message="Addresse email invalide."),
vd.email,
vd.old_password,
vd.password.old_password,
],
)
password = PasswordField(
'Mot de passe',
'Nouveau mot de passe',
validators=[
Optional(),
vd.password,
vd.old_password,
vd.password.is_strong,
vd.password.old_password,
],
)
password2 = PasswordField(
@ -110,6 +110,14 @@ class UpdateAccountForm(FlaskForm):
Optional(),
]
)
title = SelectField(
'Titre',
coerce=int,
validators=[
Optional(),
vd.own_title,
]
)
newsletter = BooleanField(
'Inscription à la newsletter',
description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.',
@ -121,15 +129,15 @@ class DeleteAccountForm(FlaskForm):
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
InputRequired(),
],
description='Attention, cette opération est irréversible!'
)
old_password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
vd.old_password,
InputRequired(),
vd.password.old_password,
],
)
submit = SubmitField(
@ -153,7 +161,7 @@ class ResetPasswordForm(FlaskForm):
'Mot de passe',
validators=[
Optional(),
vd.password,
vd.password.is_strong,
],
)
password2 = PasswordField(
@ -171,14 +179,16 @@ class AdminUpdateAccountForm(FlaskForm):
'Pseudonyme',
validators=[
Optional(),
vd.name_valid,
vd.name.valid,
vd.name.available,
],
)
avatar = FileField(
'Avatar',
validators=[
Optional(),
vd.avatar,
vd.file.is_image,
vd.file.avatar_size,
],
)
email = EmailField(
@ -191,7 +201,7 @@ class AdminUpdateAccountForm(FlaskForm):
)
email_confirmed = BooleanField(
"Confirmer l'email",
description="Si décoché, l'utilisateur devra demander explicitement un email "\
description="Si décoché, l'utilisateur devra demander explicitement un email "
"de validation, ou faire valider son adresse email par un administrateur.",
)
password = PasswordField(
@ -199,7 +209,7 @@ class AdminUpdateAccountForm(FlaskForm):
description="L'ancien mot de passe ne pourra pas être récupéré !",
validators=[
Optional(),
vd.password,
vd.password.is_strong,
],
)
xp = DecimalField(
@ -226,6 +236,14 @@ class AdminUpdateAccountForm(FlaskForm):
Optional(),
],
)
title = SelectField(
'Titre',
coerce=int,
validators=[
Optional(),
# Admin can set any title to any member!
]
)
newsletter = BooleanField(
'Inscription à la newsletter',
description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.',
@ -253,7 +271,7 @@ class AdminDeleteAccountForm(FlaskForm):
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
InputRequired(),
],
description='Attention, cette opération est irréversible!',
)

View File

@ -1,23 +1,34 @@
from flask_wtf import FlaskForm
from wtforms import StringField, FormField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Length
from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField
from wtforms.validators import InputRequired, Length
import app.utils.validators as vd
class TopicCreationForm(FlaskForm):
title = StringField('Nom du sujet',
validators=[DataRequired(), Length(min=3, max=32)])
message = TextAreaField('Message principal', validators=[DataRequired()])
submit = SubmitField('Créer le sujet')
class AnonymousTopicCreationForm(TopicCreationForm):
pseudo = StringField('Pseudo',
validators=[DataRequired(), vd.name_valid, vd.name_available])
class CommentForm(FlaskForm):
message = TextAreaField('Commentaire', validators=[DataRequired()])
message = TextAreaField('Message', validators=[InputRequired()])
attachments = MultipleFileField('Pièces-jointes',
validators=[vd.file.optional, vd.file.count, vd.file.extension,
vd.file.size, vd.file.namelength])
submit = SubmitField('Commenter')
class AnonymousCommentForm(CommentForm):
pseudo = StringField('Pseudo',
validators=[DataRequired(), vd.name_valid, vd.name_available])
validators=[InputRequired(), vd.name.valid, vd.name.available])
class CommentEditForm(CommentForm):
submit = SubmitField('Modifier')
class AnonymousCommentEditForm(CommentEditForm, AnonymousCommentForm):
pass
class TopicCreationForm(CommentForm):
title = StringField('Nom du sujet',
validators=[InputRequired(), Length(min=3, max=128)])
submit = SubmitField('Créer le sujet')
class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm):
pass

View File

@ -1,19 +1,19 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired
from wtforms.validators import InputRequired
class LoginForm(FlaskForm):
username = StringField(
'Identifiant',
validators=[
DataRequired(),
InputRequired(),
],
)
password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
InputRequired(),
],
)
remember_me = BooleanField(

58
app/forms/poll.py Normal file
View File

@ -0,0 +1,58 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, SelectField, \
BooleanField
from wtforms.fields.html5 import DateTimeField
from wtforms.validators import InputRequired, Optional
from datetime import datetime, timedelta
class PollForm(FlaskForm):
title = StringField(
'Question',
validators=[
InputRequired(),
]
)
choices = TextAreaField(
'Choix (un par ligne)',
validators=[
InputRequired(),
# TODO: add a validator to check if there is at least one choice
]
)
type = SelectField(
'Type',
choices=[
('simplepoll', 'Réponse unique'),
('multiplepoll', 'Réponses multiples')
]
)
start = DateTimeField(
'Début',
default=datetime.now(),
validators=[
Optional()
]
)
end = DateTimeField(
'Fin',
default=datetime.now() + timedelta(days=1),
validators=[
Optional()
]
)
submit = SubmitField(
'Créer le sondage'
)
class DeletePollForm(FlaskForm):
delete = BooleanField(
'Confirmer la suppression',
validators=[
InputRequired(),
],
description='Attention, cette opération est irréversible!'
)
submit = SubmitField(
'Supprimer le sondage'
)

View File

@ -1,32 +1,13 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.fields.html5 import DateField
from wtforms.validators import DataRequired, Optional
from wtforms.validators import InputRequired, 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(),
],
)
q = StringField('Rechercher', validators=[InputRequired()])
class AdvancedSearchForm(SearchForm):
date = DateField('Date', validators=[Optional()])
submit = SubmitField('Affiner la recherche')

View File

@ -1,6 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, BooleanField
from wtforms.validators import DataRequired, Optional
from wtforms.validators import InputRequired, Optional
from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty
@ -8,7 +8,7 @@ class TrophyForm(FlaskForm):
name = StringField(
'Nom',
validators=[
DataRequired(),
InputRequired(),
],
)
icon = FileField(
@ -43,7 +43,7 @@ class DeleteTrophyForm(FlaskForm):
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
InputRequired(),
],
description='Attention, cette opération est irréversible!',
)

6
app/models/__init__.py Normal file
View File

@ -0,0 +1,6 @@
from app.models.comment import Comment
from app.models.thread import Thread
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.notification import Notification
from app.models.program import Program

50
app/models/attachment.py Normal file
View File

@ -0,0 +1,50 @@
from werkzeug.utils import secure_filename
from sqlalchemy.orm import backref
from app import db
from app.utils.filesize import filesize
from config import V5Config
import os
class Attachment(db.Model):
__tablename__ = 'attachment'
id = db.Column(db.Integer, primary_key=True)
# Original name of the file
name = db.Column(db.Unicode(64))
# The comment linked with
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False)
comment = db.relationship('Comment', backref=backref('attachments'))
# The size of the file
size = db.Column(db.Integer)
# Storage file path
@property
def path(self):
return os.path.join(V5Config.DATA_FOLDER, "attachments",
f"{self.id:05}", self.name)
@property
def url(self):
return f"/fichiers/{self.id:05}/{self.name}"
def __init__(self, file, comment):
self.name = secure_filename(file.filename)
self.size = filesize(file)
self.comment = comment
def set_file(self, file):
os.mkdir(os.path.dirname(self.path))
file.save(self.path)
def edit_file(self, file):
file.name = secure_filename(file.filename)
self.set_file(file)
def delete_file(self):
try:
os.delete(self.path)
except FileNotFoundError:
pass

View File

@ -2,6 +2,7 @@ from app import db
from app.models.post import Post
from sqlalchemy.orm import backref
class Comment(Post):
__tablename__ = 'comment'
__mapper_args__ = {'polymorphic_identity': __tablename__}
@ -14,11 +15,12 @@ class Comment(Post):
# Parent thread
thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'),
nullable=False)
nullable=False)
thread = db.relationship('Thread',
backref=backref('comments', lazy='dynamic'),
foreign_keys=thread_id)
def __init__(self, author, text, thread):
"""
Create a new Comment in a thread.
@ -39,5 +41,10 @@ class Comment(Post):
self.text = new_text
self.touch()
def delete(self):
"""Recursively delete post and all associated contents."""
# FIXME: Attached files?
db.session.delete(self)
def __repr__(self):
return f'<Comment: #{self.id}>'

View File

@ -16,12 +16,15 @@ class Forum(db.Model):
# Relationships
parent_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=True)
parent = db.relationship('Forum', backref='sub_forums', remote_side=id,
lazy=True, foreign_keys=parent_id)
parent = db.relationship('Forum', backref='sub_forums', remote_side=id,
lazy=True, foreign_keys=parent_id)
# Other fields populated automatically through relations:
# <topics> List of topics in this exact forum (of type Topic)
# Some configuration
TOPICS_PER_PAGE = 30
def __init__(self, url, name, prefix, descr="", parent=None):
self.url = url
self.name = name
@ -35,7 +38,15 @@ class Forum(db.Model):
def post_count(self):
"""Number of posts in every topic of the forum, without subforums."""
return sum(len(t.thread.comments) for t in self.topics)
# TODO: optimize this with real ORM
return sum(t.thread.comments.count() for t in self.topics)
def delete(self):
"""Recursively delete forum and all associated contents."""
for t in self.topics:
t.delete()
db.session.commit()
db.session.delete(self)
def __repr__(self):
return f'<Forum: {self.name}>'

123
app/models/poll.py Normal file
View File

@ -0,0 +1,123 @@
from app import db
from enum import Enum
from sqlalchemy.orm import backref
from datetime import datetime, timedelta
from collections import Counter
class Poll(db.Model):
"""Default class for polls"""
__tablename__ = 'poll'
# Names of templates
template = 'defaultpoll.html'
# Unique ID
id = db.Column(db.Integer, primary_key=True)
# Type
type = db.Column(db.String(20))
# Author
author_id = db.Column(db.Integer, db.ForeignKey('member.id'))
author = db.relationship('Member', backref=backref('polls'),
foreign_keys=author_id)
# Title/question
title = db.Column(db.UnicodeText)
# Start datetime
start = db.Column(db.DateTime, default=datetime.now())
# End datetime
end = db.Column(db.DateTime)
# Choices
# We want a size-variable list of strings, or a dictionnary with
# key/values, depending on the poll type.
# As the data is likely to be adapted to the poll type, the PickleType
# seems to be appropriate. Same applies for PollAnswer.
choices = db.Column(db.PickleType)
# Other fields populated automatically through relations:
# <answers> The list of answers (of type PollAnswer)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on':type
}
def __init__(self, author, title, choices, start=datetime.now(), end=datetime.now()):
self.author = author
self.title = title
self.choices = choices
self.start = start
self.end = end
def delete(self):
"""Deletes a poll and its answers"""
# TODO: move this out of class definition?
for answer in SpecialPrivilege.query.filter_by(poll_id=self.id).all():
db.session.delete(answer)
db.session.commit()
db.session.delete(self)
db.session.commit()
# Common properties and methods
@property
def started(self):
"""Returns whether the poll is open"""
return self.start <= datetime.now()
@property
def ended(self):
"""Returns whether the poll is closed"""
return self.end < datetime.now()
def has_voted(self, user):
"""Returns wheter the user has voted"""
# TODO: use ORM for this dirty request
return user in [a.author for a in self.answers]
def can_vote(self, user):
"""Returns true if the current user can vote.
More conditions may be added in the future"""
return user.is_authenticated
# Poll-specific methods. Must be overrided per-poll definition
def vote(self, user, data):
"""Return a PollAnswer object from specified user and data"""
return None
@property
def results(self):
"""Returns an easy-to-use object with answers of the poll."""
return None
class PollAnswer(db.Model):
"""An answer to a poll"""
__tablename__ = 'pollanswer'
# Unique ID
id = db.Column(db.Integer, primary_key=True)
# Poll
poll_id = db.Column(db.Integer, db.ForeignKey('poll.id'))
poll = db.relationship('Poll', backref=backref('answers'),
foreign_keys=poll_id)
# Author. Must be Member
author_id = db.Column(db.Integer, db.ForeignKey('member.id'))
author = db.relationship('Member', foreign_keys=author_id)
# Choice(s)
answer = db.Column(db.PickleType)
def __init__(self, poll, user, answer):
self.poll = poll
self.author = user
self.answer = answer

View File

@ -0,0 +1,49 @@
from app import db
from app.models.poll import Poll, PollAnswer
from collections import Counter
from itertools import chain
class MultiplePoll(Poll):
"""Poll with many answers allowed"""
__tablename__ = 'multiplepoll'
# Names of templates
template = 'multiplepoll.html'
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
def __init__(self, author, title, choices, **kwargs):
choices = [Choice(i, t) for i, t in enumerate(choices)]
super().__init__(author, title, choices, **kwargs)
# Mandatory methods
def vote(self, user, request):
answers_id = []
for c in self.choices:
if f"pollanswers-{c.id}" in request.form:
answers_id.append(c.id)
return PollAnswer(self, user, answers_id)
@property
def results(self):
values = {c: 0 for c in self.choices}
counter = Counter(values)
for a in self.answers:
counter.update([self.choice_from_id(id) for id in a.answer])
return counter
# Custom method
def choice_from_id(self, id):
for c in self.choices:
if c.id == id:
return c
return None
class Choice():
def __init__(self, id, title):
self.id = id
self.title = title

View File

@ -0,0 +1,49 @@
from app import db
from app.models.poll import Poll, PollAnswer
from collections import Counter
class SimplePoll(Poll):
"""Poll with only one answer allowed"""
__tablename__ = 'simplepoll'
# Names of templates
template = 'simplepoll.html'
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
def __init__(self, author, title, choices, **kwargs):
choices = [Choice(i, t) for i, t in enumerate(choices)]
super().__init__(author, title, choices, **kwargs)
# Mandatory methods
def vote(self, user, request):
try:
choice_id = int(request.form['pollanwsers'])
except (KeyError, ValueError):
return None
answer = PollAnswer(self, user, choice_id)
return answer
@property
def results(self):
values = {c: 0 for c in self.choices}
counter = Counter(values)
answers = [self.choice_from_id(a.answer) for a in self.answers]
counter.update(answers)
return counter
# Custom method
def choice_from_id(self, id):
for c in self.choices:
if c.id == id:
return c
return None
class Choice():
def __init__(self, id, title):
self.id = id
self.title = title

View File

@ -1,5 +1,4 @@
from app import db
from app.models.users import User
from datetime import datetime

View File

@ -1,8 +1,7 @@
# Planète Casio v5
# models.privs: Database models for groups and privilege management
# models.priv: 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
@ -19,7 +18,7 @@ class SpecialPrivilege(db.Model):
# 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))
priv = db.Column(db.String(64))
def __init__(self, member, priv):
self.mid = member.id
@ -85,7 +84,7 @@ class GroupPrivilege(db.Model):
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))
priv = db.Column(db.String(64))
def __init__(self, group, priv):
self.gid = group.id

44
app/models/program.py Normal file
View File

@ -0,0 +1,44 @@
from app import db
from app.models.post import Post
class Program(Post):
__tablename__ = 'program'
__mapper_args__ = {'polymorphic_identity': __tablename__}
# ID of underlying Post object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Program name
title = db.Column(db.Unicode(128))
# TODO: Category (games/utilities/lessons)
# TODO: Tags
# TODO: Compatible calculator models
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
thread = db.relationship('Thread', foreign_keys=thread_id,
back_populates='owner_program')
# TODO: Number of views, statistics, attached files, etc
def __init__(self, author, title, thread):
"""
Create a Program.
Arguments:
author -- post author (User, though only Members can post)
title -- program title (unicode string)
thread -- discussion thread attached to the topic
"""
Post.__init__(self, author)
self.title = title
self.thread = thread
@staticmethod
def from_topic(topic):
p = Program(topic.author, topic.title, topic.thread)
topic.promotion = p
def __repr__(self):
return f'<Program: #{self.id} "{self.title}">'

View File

@ -10,11 +10,18 @@ class Thread(db.Model):
# Top comment
top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
top_comment = db.relationship('Comment', foreign_keys=top_comment_id)
top_comment = db.relationship('Comment', foreign_keys=top_comment_id)
# Post owning the thread, set only by Topic, Program, etc. In general, you
# should use [owner_post] which groups them together.
owner_topic = db.relationship('Topic')
owner_program = db.relationship('Program')
# Other fields populated automatically through relations:
# <comments> The list of comments (of type Comment)
COMMENTS_PER_PAGE = 20
def __init__(self):
"""
Create a empty Thread. Normally threads are not meant to be empty, so
@ -37,5 +44,26 @@ class Thread(db.Model):
self.top_comment = top_comment
@property
def owner_post(self):
if self.owner_topic != []:
return self.owner_topic[0]
if self.owner_program != []:
return self.owner_program[0]
return None
def delete(self):
"""Recursively delete thread and all associated contents."""
# Remove reference to top comment
self.top_comment = None
db.session.add(self)
db.session.commit()
# Remove comments
for c in self.comments:
c.delete()
# Remove thread
db.session.commit()
db.session.delete(self)
def __repr__(self):
return f'<Thread: #{self.id}>'

View File

@ -1,24 +1,34 @@
from app import db
from app.models.post import Post
from config import V5Config
from sqlalchemy.orm import backref
class Topic(Post):
__tablename__ = 'topic'
__mapper_args__ = {'polymorphic_identity': __tablename__}
# ID of the underlying [Post] object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'inherit_condition': id == Post.id
}
# Post that the topic was promoted into. If this is not None, then the
# topic was published into a project and a redirection should be emitted
promotion_id = db.Column(db.Integer,db.ForeignKey('post.id'),nullable=True)
promotion = db.relationship('Post', foreign_keys=promotion_id)
# Topic title
title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN))
title = db.Column(db.Unicode(128))
# Parent forum
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False)
forum = db.relationship('Forum', backref='topics',foreign_keys=forum_id)
forum = db.relationship('Forum',
backref=backref('topics', lazy='dynamic'), foreign_keys=forum_id)
# Associated thread
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
thread = db.relationship('Thread', foreign_keys=thread_id)
thread = db.relationship('Thread', foreign_keys=thread_id,
back_populates='owner_topic')
# Number of views in the forum
views = db.Column(db.Integer)
@ -40,5 +50,10 @@ class Topic(Post):
self.thread = thread
self.forum = forum
def delete(self):
"""Recursively delete topic and all associated contents."""
self.thread.delete()
db.session.delete(self)
def __repr__(self):
return f'<Topic: #{self.id}>'

View File

@ -1,21 +1,19 @@
from datetime import date
from flask import flash
from flask_login import UserMixin
from sqlalchemy import func as SQLfunc
from os.path import isfile
from PIL import Image
from app import app, db
from app.models.privs import SpecialPrivilege, Group, GroupMember, \
from app.models.priv import SpecialPrivilege, Group, GroupMember, \
GroupPrivilege
from app.models.trophies import Trophy, TrophyMember
from app.models.trophy import Trophy, TrophyMember, Title
from app.models.notification import Notification
import app.utils.unicode_names as unicode_names
from app.utils.notify import notify
import app.utils.ldap as ldap
from app.utils.unicode_names import normalize
from config import V5Config
import werkzeug.security
import re
import math
import app
import os
@ -35,6 +33,10 @@ class User(UserMixin, db.Model):
# Other fields populated automatically through relations:
# <posts> relationship populated from the Post class.
# Minimum and maximum user name length
NAME_MINLEN = 3
NAME_MAXLEN = 32
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on': type
@ -54,7 +56,7 @@ class Guest(User):
id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
# Reusable username, cannot be chosen as the name of a member
# but will be distinguished at rendering time if a member take it later
name = db.Column(db.Unicode(64))
name = db.Column(db.Unicode(User.NAME_MAXLEN))
def __init__(self, name):
self.name = name
@ -73,9 +75,8 @@ class Member(User):
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(V5Config.USER_NAME_MAXLEN), index=True)
norm = db.Column(db.Unicode(V5Config.USER_NAME_MAXLEN), index=True,
unique=True)
name = db.Column(db.Unicode(User.NAME_MAXLEN), index=True)
norm = db.Column(db.Unicode(User.NAME_MAXLEN), index=True, unique=True)
email = db.Column(db.Unicode(120), index=True, unique=True)
email_confirmed = db.Column(db.Boolean)
password_hash = db.Column(db.String(255))
@ -101,6 +102,10 @@ class Member(User):
signature = db.Column(db.UnicodeText)
birthday = db.Column(db.Date)
# Displayed title, if set
title_id = db.Column(db.Integer, db.ForeignKey('title.id'), nullable=True)
title = db.relationship('Title', foreign_keys=title_id)
# Settings
newsletter = db.Column(db.Boolean, default=False)
@ -108,6 +113,7 @@ class Member(User):
trophies = db.relationship('Trophy', secondary=TrophyMember,
back_populates='owners')
topics = db.relationship('Topic')
programs = db.relationship('Program')
comments = db.relationship('Comment')
# Displayed title
@ -116,6 +122,7 @@ class Member(User):
# Other fields populated automatically through relations:
# <notifications> List of unseen notifications (of type Notification)
# <polls> Polls created by the member (of class Poll)
def __init__(self, name, email, password):
"""Register a new user."""
@ -150,7 +157,7 @@ class Member(User):
if SpecialPrivilege.query.filter_by(mid=self.id, priv=priv).first():
return True
return db.session.query(Group, GroupPrivilege).filter(
Group.id.in_([ g.id for g in self.groups ]),
Group.id.in_([g.id for g in self.groups]),
GroupPrivilege.gid==Group.id,
GroupPrivilege.priv==priv).first() is not None
@ -184,6 +191,9 @@ class Member(User):
# TODO: verify good type of those args, think about the password mgt
# Beware of LDAP injections
if "name" in data:
self.name = data["name"]
self.norm = normalize(data["name"])
if "email" in data:
self.email = data["email"]
if V5Config.USE_LDAP:
@ -200,6 +210,8 @@ class Member(User):
self.newsletter = data["newsletter"]
if "avatar" in data:
self.set_avatar(data["avatar"])
if "title" in data:
self.title = Title.query.get(data["title"])
# For admins only
if "email_confirmed" in data:
@ -209,7 +221,7 @@ class Member(User):
def set_avatar(self, avatar):
# Save old avatar filepath
old_avatar = V5Config.AVATARS_FOLDER + self.avatar
old_avatar = os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar)
# Resize & convert image
size = 128, 128
im = Image.open(avatar)
@ -221,7 +233,8 @@ class Member(User):
db.session.merge(self)
db.session.commit()
# Save the new avatar
im.save(V5Config.AVATARS_FOLDER + self.avatar, 'PNG')
im.save(os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar),
'PNG')
# If nothing has failed, remove old one (allow failure to regularize
# exceptional situations like missing avatar or folder migration)
try:
@ -366,8 +379,7 @@ class Member(User):
progress(levels, post_count)
if context in ["new-program", None]:
# TODO: Amount of programs by the user
program_count = 0
program_count = self.programs.count()
levels = {
5: "Programmeur du dimanche",
@ -454,7 +466,8 @@ class Member(User):
# TODO: Trophy "actif"
if context in ["on-profile-update", None]:
if isfile(V5Config.AVATARS_FOLDER + self.avatar):
if isfile(os.path.join(
V5Config.DATA_FOLDER, "avatars", self.avatar)):
self.add_trophy("Artiste")
else:
self.del_trophy("Artiste")

View File

@ -0,0 +1,5 @@
# Register processors here
from app.processors.menu import menu_processor
from app.processors.utilities import utilities_processor
from app.processors.stats import request_time

View File

@ -3,9 +3,7 @@ from app.forms.login import LoginForm
from app.forms.search import SearchForm
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
from app.models.users import Member
@app.context_processor
def menu_processor():

8
app/processors/stats.py Normal file
View File

@ -0,0 +1,8 @@
from flask import g
from time import time
from app import app
@app.before_request
def request_time():
g.request_start_time = time()
g.request_time = lambda: "%.5fs" % (time() - g.request_start_time)

View File

@ -1,6 +1,7 @@
from app import app
from flask import url_for
from config import V5Config
from slugify import slugify
@app.context_processor
def utilities_processor():
@ -8,6 +9,7 @@ def utilities_processor():
return dict(
len=len,
# enumerate=enumerate,
_url_for = lambda route, args, **other: url_for(route, **args, **other),
V5Config = V5Config,
_url_for=lambda route, args, **other: url_for(route, **args, **other),
V5Config=V5Config,
slugify=slugify,
)

16
app/routes/__init__.py Normal file
View File

@ -0,0 +1,16 @@
# Register routes here
from app.routes import index, search, users, tools, development
from app.routes.account import login, account, notification, polls
from app.routes.admin import index, groups, account, trophies, forums, \
attachments, config, members, polls
from app.routes.forum import index, topic
from app.routes.polls import vote, delete
from app.routes.posts import edit
from app.routes.programs import index
from app.routes.api import markdown
try:
from app.routes import test
except ImportError:
pass

View File

@ -3,7 +3,8 @@ from flask_login import login_required, current_user, logout_user
from app import app, db
from app.forms.account import UpdateAccountForm, RegistrationForm, \
DeleteAccountForm, AskResetPasswordForm, ResetPasswordForm
from app.models.users import Member
from app.models.user import Member
from app.models.trophy import Title
from app.utils.render import render
from app.utils.send_mail import send_validation_mail, send_reset_password_mail
from app.utils.priv_required import guest_only
@ -16,6 +17,9 @@ from config import V5Config
@login_required
def edit_account():
form = UpdateAccountForm()
titles = [(t.id, t.name) for t in current_user.trophies if isinstance(t, Title)]
titles.insert(0, (-1, "Membre"))
form.title.choices = titles
if form.submit.data:
if form.validate_on_submit():
current_user.update(
@ -25,6 +29,7 @@ def edit_account():
birthday=form.birthday.data,
signature=form.signature.data,
bio=form.biography.data,
title=form.title.data,
newsletter=form.newsletter.data
)
db.session.merge(current_user)
@ -35,7 +40,8 @@ def edit_account():
else:
flash('Erreur lors de la modification', 'error')
return render('account/account.html', form=form)
return render('account/account.html', scripts=["+scripts/entropy.js"],
form=form)
@app.route('/compte/reinitialiser', methods=['GET', 'POST'])
@guest_only
@ -74,7 +80,8 @@ def reset_password(token):
else:
flash('Erreur lors de la modification', 'error')
return render('account/reset_password.html', form=form)
return render('account/reset_password.html',
scripts=["+scripts/entropy.js"], form=form)
@app.route('/compte/supprimer', methods=['GET', 'POST'])
@ -113,7 +120,8 @@ def register():
send_validation_mail(member.name, member.email)
return redirect(url_for('validation') + "?email=" + form.email.data)
return render('account/register.html', title='Register', form=form)
return render('account/register.html', title='Register',
scripts=["+scripts/entropy.js"], form=form)
@app.route('/inscription/validation', methods=['GET'])
@ -122,7 +130,7 @@ def validation():
try:
mail = request.args['email']
except Exception as e:
print("Error: {e}")
print(f"Error: {e}")
abort(404)
if current_user.is_authenticated:
@ -136,6 +144,7 @@ def activate_account(token):
ts = URLSafeTimedSerializer(app.config["SECRET_KEY"])
email = ts.loads(token, salt="email-confirm-key", max_age=86400)
except Exception as e:
# TODO: add proper login
print(f"Error: {e}")
abort(404)

View File

@ -3,11 +3,12 @@ from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse, urljoin
from app import app
from app.forms.login import LoginForm
from app.models.users import Member
from app.models.privs import Group
from app.models.user import Member
from app.models.priv import Group
from app.utils.render import render
from app.utils.send_mail import send_validation_mail
from config import V5Config
from app.utils.check_csrf import check_csrf
import datetime
@app.route('/connexion', methods=['GET', 'POST'])
@ -46,7 +47,7 @@ def login():
# Login & update time-based trophies
login_user(member, remember=form.remember_me.data,
duration=V5Config.REMEMBER_COOKIE_DURATION)
duration=datetime.timedelta(days=7))
member.update_trophies("on-login")
# Redirect safely (https://huit.re/open-redirect)
@ -68,6 +69,7 @@ def login():
@app.route('/deconnexion')
@login_required
@check_csrf
def logout():
logout_user()
flash('Déconnexion réussie', 'info')

View File

@ -0,0 +1,32 @@
from app import app, db
from flask import abort, flash, redirect, request, url_for
from flask_login import current_user, login_required
from app.models.poll import Poll
from app.models.polls.simple import SimplePoll
from app.models.polls.multiple import MultiplePoll
from app.forms.poll import PollForm
from app.utils.render import render
@app.route("/compte/sondages", methods=['GET', 'POST'])
@login_required
def account_polls():
form = PollForm()
polls = (Poll.query.filter(Poll.author == current_user)
.order_by(Poll.end.desc()))
polls_types = {
'simplepoll': SimplePoll,
'multiplepoll': MultiplePoll,
}
if form.validate_on_submit():
choices = list(filter(None, form.choices.data.split('\n')))
p = polls_types[form.type.data](current_user, form.title.data, choices,
start=form.start.data, end=form.end.data)
db.session.add(p)
db.session.commit()
flash(f"Le sondage {p.id} a été créé", "info")
return render("account/polls.html", polls=polls, form=form)

View File

@ -2,9 +2,9 @@ from flask import flash, redirect, url_for, request
from flask_login import current_user
from wtforms import BooleanField
from app.utils.priv_required import priv_required
from app.models.users import Member
from app.models.trophies import Trophy
from app.models.privs import Group
from app.models.user import Member
from app.models.trophy import Trophy, Title
from app.models.priv import Group
from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \
AdminAccountEditTrophyForm, AdminAccountEditGroupForm
from app.utils.render import render
@ -35,6 +35,10 @@ def adm_edit_account(user_id):
setattr(GroupForm, "user_groups", [f'g{g.id}' for g in user.groups])
group_form = GroupForm(prefix="group")
titles = [(t.id, t.name) for t in Title.query.all()]
titles.insert(0, (-1, "Membre"))
form.title.choices = titles
if form.submit.data:
if form.validate_on_submit():
newname = form.username.data
@ -52,6 +56,7 @@ def adm_edit_account(user_id):
password=form.password.data or None,
birthday=form.birthday.data,
signature=form.signature.data,
title=form.title.data,
bio=form.biography.data,
newsletter=form.newsletter.data,
xp=form.xp.data or None,
@ -107,9 +112,10 @@ def adm_edit_account(user_id):
for g in user.groups:
groups_owned.add(f"g{g.id}")
return render('admin/edit_account.html', user=user, form=form,
trophy_form=trophy_form, trophies_owned=trophies_owned,
group_form=group_form, groups_owned=groups_owned)
return render('admin/edit_account.html', scripts=["+scripts/entropy.js"],
user=user, form=form, trophy_form=trophy_form,
trophies_owned=trophies_owned, group_form=group_form,
groups_owned=groups_owned)
@app.route('/admin/compte/<user_id>/supprimer', methods=['GET', 'POST'])

View File

@ -0,0 +1,13 @@
from app import app
from app.models.attachment import Attachment
from app.utils.priv_required import priv_required
from app.utils.render import render
# TODO: add pagination & moderation tools (deletion)
@app.route('/admin/fichiers', methods=['GET'])
@priv_required('access-admin-panel')
def adm_attachments():
attachments = Attachment.query.all()
return render('admin/attachments.html', attachments=attachments)

View File

@ -0,0 +1,13 @@
from app.utils.priv_required import priv_required
from app.utils.render import render
from app import app
from config import V5Config
@app.route('/admin/config', methods=['GET'])
@priv_required('access-admin-panel')
def adm_config():
config = {k: getattr(V5Config, k) for k in [
"DOMAIN", "DB_NAME", "USE_LDAP", "LDAP_ROOT", "LDAP_ENV",
"ENABLE_GUEST_POST", "ENABLE_EMAIL_CONFIRMATION", "SEND_MAILS"
]}
return render('admin/config.html', config=config)

View File

@ -1,8 +1,8 @@
from app.utils.priv_required import priv_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.models.user import Member, GroupMember, Group, GroupPrivilege
from app.models.priv import SpecialPrivilege
from app.utils.render import render
from app import app, db
import yaml
@ -12,7 +12,12 @@ import os
@app.route('/admin/groupes', methods=['GET', 'POST'])
@priv_required('access-admin-panel')
def adm_groups():
users = Member.query.all()
groups = Group.query.all()
# Users with either groups or special privileges
users_groups = Member.query.join(GroupMember)
users_special = Member.query \
.join(SpecialPrivilege, Member.id == SpecialPrivilege.mid)
users = users_groups.union(users_special)
users = sorted(users, key = lambda x: x.name)
groups = Group.query.all()
return render('admin/groups_privileges.html', users=users, groups=groups)

View File

@ -0,0 +1,14 @@
from app.utils.priv_required import priv_required
from app.models.user import Member, Group, GroupPrivilege
from app.models.priv import SpecialPrivilege
from app.utils.render import render
from app import app, db
@app.route('/admin/membres', methods=['GET', 'POST'])
@priv_required('access-admin-panel')
def adm_members():
users = Member.query.all()
users = sorted(users, key = lambda x: x.name)
return render('admin/members.html', users=users)

11
app/routes/admin/polls.py Normal file
View File

@ -0,0 +1,11 @@
from app import app
from app.utils.priv_required import priv_required
from app.utils.render import render
from app.models.poll import Poll
@app.route('/admin/sondages', methods=['GET'])
@priv_required('access-admin-panel')
def adm_polls():
polls = Poll.query.order_by(Poll.end.desc()).all()
return render('admin/polls.html', polls=polls)

View File

@ -1,7 +1,7 @@
from flask import request, flash, redirect, url_for
from app.utils.priv_required import priv_required
from app.models.trophies import Trophy, Title
from app.forms.trophies import TrophyForm, DeleteTrophyForm
from app.models.trophy import Trophy, Title
from app.forms.trophy import TrophyForm, DeleteTrophyForm
from app.utils.render import render
from app import app, db

View File

@ -0,0 +1,13 @@
from app import app
from app.utils.filters.markdown import md
from flask import request, abort
from werkzeug.exceptions import BadRequestKeyError
class API():
@app.route("/api/markdown", methods=["POST"])
def api_markdown():
try:
markdown = request.get_json()['text']
except BadRequestKeyError:
abort(400)
return str(md(markdown))

25
app/routes/development.py Normal file
View File

@ -0,0 +1,25 @@
from flask import send_file, redirect, url_for, abort
from werkzeug.utils import secure_filename
from app import app
from config import V5Config
import os
# These routes are used in development
# In production, those files should be served by the web server (nginx)
@app.route('/avatar/<filename>')
def avatar(filename):
filename = secure_filename(filename) # No h4ckers allowed
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
if os.path.isfile(filepath):
return send_file(filepath)
return redirect(url_for('static', filename='images/default_avatar.png'))
@app.route('/fichiers/<path>/<name>')
def attachment(path, name):
file = os.path.join(V5Config.DATA_FOLDER, "attachments",
secure_filename(path), secure_filename(name))
if os.path.isfile(file):
return send_file(file)
else:
abort(404)

View File

@ -9,7 +9,8 @@ from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
from app.models.users import Guest
from app.models.user import Guest
from app.models.attachment import Attachment
@app.route('/forum/')
@ -17,7 +18,8 @@ def forum_index():
return render('/forum/index.html')
@app.route('/forum/<forum:f>/', methods=['GET', 'POST'])
def forum_page(f):
@app.route('/forum/<forum:f>/p/<int:page>', methods=['GET', 'POST'])
def forum_page(f, page=1):
if current_user.is_authenticated:
form = TopicCreationForm()
else:
@ -34,17 +36,18 @@ def forum_page(f):
or ("/actus" not in f.url and not f.sub_forums)) and (
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
# First create the thread, then the comment, then the topic
th = Thread()
db.session.add(th)
db.session.commit()
# Manage author
if current_user.is_authenticated:
author = current_user
else:
author = Guest(form.pseudo.data)
db.session.add(author)
# First create the thread, then the comment, then the topic
th = Thread()
db.session.add(th)
db.session.commit()
c = Comment(author, form.message.data, th)
db.session.add(c)
db.session.commit()
@ -56,12 +59,28 @@ def forum_page(f):
db.session.add(t)
db.session.commit()
# Manage files
attachments = []
for file in form.attachments.data:
if file.filename != "":
a = Attachment(file, c)
attachments.append((a, file))
db.session.add(a)
db.session.commit()
for a, file in attachments:
a.set_file(file)
# Update member's xp and trophies
if current_user.is_authenticated:
current_user.add_xp(V5Config.XP_POINTS['topic'])
current_user.add_xp(2) # 2 points for a topic
current_user.update_trophies('new-post')
flash('Le sujet a bien été créé', 'ok')
return redirect(url_for('forum_topic', f=f, page=(t,1)))
return render('/forum/forum.html', f=f, form=form)
# Paginate topic pages
# TODO: order by last comment date
topics = f.topics.order_by(Topic.date_created.desc()).paginate(
page, Forum.TOPICS_PER_PAGE, True)
return render('/forum/forum.html', f=f, topics=topics, form=form)

View File

@ -1,15 +1,17 @@
from flask_login import current_user
from flask import request, redirect, url_for, flash, abort
from flask import redirect, url_for, flash, abort
from sqlalchemy import desc
from app import app, db
from config import V5Config
from app.utils.render import render
from app.forms.forum import CommentForm, AnonymousCommentForm
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
from app.models.users import Guest
from app.models.user import Guest
from app.models.attachment import Attachment
from datetime import datetime
@app.route('/forum/<forum:f>/<topicpage:page>', methods=['GET', 'POST'])
@ -26,25 +28,38 @@ def forum_topic(f, page):
form = AnonymousCommentForm()
if form.validate_on_submit() and \
(V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
(V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
# Manage author
if current_user.is_authenticated:
author = current_user
else:
author = Guest(form.pseudo.data)
db.session.add(author)
# Create comment
c = Comment(author, form.message.data, t.thread)
db.session.add(c)
db.session.commit()
# Manage files
attachments = []
for file in form.attachments.data:
if file.filename != "":
a = Attachment(file, c)
attachments.append((a, file))
db.session.add(a)
db.session.commit()
for a, file in attachments:
a.set_file(file)
# Update member's xp and trophies
if current_user.is_authenticated:
current_user.add_xp(V5Config.XP_POINTS['comment'])
current_user.add_xp(1) # 1 point for a comment
current_user.update_trophies('new-post')
flash('Message envoyé', 'ok')
# Redirect to empty the form
return redirect(url_for('forum_topic', f=f, page=(t,"fin"),
return redirect(url_for('forum_topic', f=f, page=(t, "fin"),
_anchor=c.id))
# Update views
@ -53,10 +68,15 @@ def forum_topic(f, page):
db.session.commit()
if page == -1:
page = (t.thread.comments.count() - 1) \
// V5Config.COMMENTS_PER_PAGE + 1
page = (t.thread.comments.count() - 1) // Thread.COMMENTS_PER_PAGE + 1
comments = t.thread.comments.paginate(page,
V5Config.COMMENTS_PER_PAGE, True)
Thread.COMMENTS_PER_PAGE, True)
return render('/forum/topic.html', t=t, form=form, comments=comments)
# Anti-necropost
last_com = t.thread.comments.order_by(desc(Comment.date_modified)).first()
inactive = datetime.now() - last_com.date_modified
outdated = inactive.days if inactive >= V5Config.NECROPOST_LIMIT else None
return render('/forum/topic.html', t=t, form=form, comments=comments,
outdated=outdated)

View File

@ -0,0 +1,31 @@
from app import app, db
from flask import abort, flash, redirect, request, url_for
from flask_login import current_user
from app.utils.render import render
from app.models.poll import Poll
from app.forms.poll import DeletePollForm
@app.route("/sondages/<int:poll_id>/supprimer", methods=['GET', 'POST'])
def poll_delete(poll_id):
poll = Poll.query.get(poll_id)
if poll is None:
abort(404)
if current_user != poll.author and \
not current_user.priv('delete-posts'):
abort(403)
form = DeletePollForm()
if form.validate_on_submit():
for a in poll.answers:
db.session.delete(a)
db.session.commit()
db.session.delete(poll)
db.session.commit()
flash('Le sondage a été supprimé', 'info')
return redirect(url_for('account_polls'))
return render('poll/delete.html', poll=poll, del_form=form)

41
app/routes/polls/vote.py Normal file
View File

@ -0,0 +1,41 @@
from app import app, db
from flask import abort, flash, redirect, request, url_for
from flask_login import current_user
from app.models.poll import Poll
@app.route("/sondages/<int:poll_id>/voter", methods=['POST'])
def poll_vote(poll_id):
poll = Poll.query.get(poll_id)
if poll is None:
abort(404)
if not current_user.is_authenticated:
flash("Seuls les membres connectés peuvent voter", 'error')
abort(401)
if not poll.can_vote(current_user):
flash("Vous n'avez pas le droit de voter", 'error')
abort(403)
if poll.has_voted(current_user):
flash("Vous avez déjà voté", 'error')
abort(403)
if not poll.started:
flash("Le sondage n'a pas débuté", 'error')
abort(403)
if poll.ended:
flash("Le sondage est terminé", 'error')
abort(403)
answer = poll.vote(current_user, request)
if answer is None:
abort(400)
db.session.add(answer)
db.session.commit()
flash('Le vote a été pris en compte', 'info')
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))

58
app/routes/posts/edit.py Normal file
View File

@ -0,0 +1,58 @@
from app import app, db
from app.models.post import Post
from app.utils.render import render
from app.utils.check_csrf import check_csrf
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm
from urllib.parse import urlparse
from flask import redirect, url_for, abort, request
from flask_login import login_required, current_user
@app.route('/post/<int:postid>', methods=['GET','POST'])
# TODO: Allow guest edit of posts
@login_required
def edit_post(postid):
# TODO: Maybe not safe
referrer = urlparse(request.args.get('r', default = '/', type = str)).path
print(referrer)
p = Post.query.filter_by(id=postid).first_or_404()
# TODO: Check whether privileged user has access to board
if p.author != current_user and not current_user.priv("edit-posts"):
abort(403)
if p.type == "comment":
form = CommentEditForm()
if form.validate_on_submit():
p.text = form.message.data
if form.submit.data:
db.session.add(p)
db.session.commit()
return redirect(referrer)
form.message.data = p.text
return render('forum/edit_comment.html', comment=p, form=form)
else:
abort(404)
@app.route('/post/supprimer/<int:postid>', methods=['GET','POST'])
@login_required
@check_csrf
def delete_post(postid):
p = Post.query.filter_by(id=postid).first_or_404()
# TODO: Check whether privileged user has access to board
if p.author != current_user and not current_user.priv("delete-posts"):
abort(403)
for a in p.attachments:
a.delete_file()
db.session.delete(a)
db.session.commit()
db.session.delete(p)
db.session.commit()
return redirect(request.referrer)

View File

@ -0,0 +1,8 @@
from app import app, db
from app.models.program import Program
from app.utils.render import render
@app.route('/programmes')
def program_index():
programs = Program.query.all()
return render('/programs/index.html')

View File

@ -1,9 +1,9 @@
from flask import redirect, url_for, send_from_directory
from flask import redirect, url_for, send_file
from werkzeug.utils import secure_filename
import os.path
from app import app
from app.models.users import Member
from app.models.trophies import Trophy
from app.models.user import Member
from app.models.trophy import Trophy
from app.utils import unicode_names
from app.utils.render import render
from config import V5Config
@ -21,10 +21,3 @@ def user(username):
def user_by_id(user_id):
member = Member.query.filter_by(id=user_id).first_or_404()
return redirect(url_for('user', username=member.name))
@app.route('/avatar/<filename>')
def avatar(filename):
filename = secure_filename(filename) # No h4ckers allowed
if os.path.isfile(V5Config.AVATARS_FOLDER + filename):
return send_from_directory(V5Config.AVATARS_FOLDER, filename)
return redirect(url_for('static', filename='images/default_avatar.png'))

View File

@ -3,15 +3,14 @@
*/
.flash {
position: fixed; left: 15%;
display: flex; align-items: center;
width: 70%; z-index: 10;
font-family: NotoSans; font-size: 14px; color: var(--text);
background: var(--background);
margin: 5px auto;
display: flex;
align-items: center;
width: 80%;
font-size: 14px;
border-bottom: 5px solid var(--info);
border-radius: 1px; box-shadow: var(--shadow);
transition: opacity .15s ease;
transition: top .2s ease;
border-radius: 1px;
box-shadow: var(--shadow);
}
.flash.info {
border-color: var(--info);
@ -26,18 +25,9 @@
border-color: var(--error);
}
.flash span {
flex-grow: 1; margin: 15px 10px 10px 0;
flex-grow: 1;
margin: 15px 10px 10px 0;
}
.flash svg {
margin: 15px 20px 10px 30px;
}
.flash input[type="button"] {
margin: 3px 30px 0 0; padding: 10px 15px;
border: none;
background: var(--btn-bg); color: var(--btn-text);
}
.flash input[type="button"]:hover,
.flash input[type="button"]:focus {
background: var(--btn-bg-active);
}

View File

@ -7,7 +7,7 @@
vertical-align: middle;
}
.form form > div:not(:last-child) {
.form form > div:not(:last-child):not(.editor-toolbar) {
margin-bottom: 16px;
}
@ -26,6 +26,7 @@
.form input[type='password'],
.form input[type='search'],
.form textarea,
.form select,
.trophies-panel > div {
display: block;
width: 100%; padding: 6px 8px;
@ -49,6 +50,34 @@
max-width: 100%;
resize: vertical;
}
.form select {
width: auto;
}
.form progress.entropy {
display: none; /* display with Js enabled */
width: 100%; margin-top: 5px;
background: var(--background);
border: var(--border);
}
.form progress.entropy.low::-moz-progress-bar {
background: var(--error);
}
.form progress.entropy.low::-webkit-progress-bar {
background: var(--error);
}
.form progress.entropy.medium::-moz-progress-bar {
background: var(--warn);
}
.form progress.entropy.medium::-webkit-progress-bar {
background: var(--warn);
}
.form progress.entropy.high::-moz-progress-bar {
background: var(--ok);
}
.form progress.entropy.high::-webkit-progress-bar {
background: var(--ok);
}
.form input[type="checkbox"],
.form input[type="radio"] {
@ -93,3 +122,37 @@
font-family: monospace;
height: 192px;
}
/* Interactive filter forms */
.form.filter > p:first-child {
font-size: 80%;
color: gray;
margin-bottom: 2px;
}
.form.filter {
margin-bottom: 16px;
}
.form.filter input {
font-family: monospace;
}
.form.filter .syntax-explanation {
font-size: 80%;
color: gray;
margin-top: 8px;
}
.form.filter .syntax-explanation ul {
font-size: inherit;
color: inherit;
padding-left: 16px;
line-height: 20px;
margin-top: 2px;
}
.form.filter .syntax-explanation li {
}
.form.filter .syntax-explanation code {
background: rgba(0,0,0,.05);
padding: 1px 2px;
border-radius: 2px;
}

View File

@ -0,0 +1,75 @@
pre { line-height: 125%; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos { padding: 0 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.codehilite .hll { background-color: #ffffcc }
.codehilite { background: #f8f8f8; }
.codehilite .c { color: #408080; font-style: italic } /* Comment */
.codehilite .err { border: 1px solid #FF0000 } /* Error */
.codehilite .k { color: #008000; font-weight: bold } /* Keyword */
.codehilite .o { color: #666666 } /* Operator */
.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */
.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */
.codehilite .gd { color: #A00000 } /* Generic.Deleted */
.codehilite .ge { font-style: italic } /* Generic.Emph */
.codehilite .gr { color: #FF0000 } /* Generic.Error */
.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.codehilite .gi { color: #00A000 } /* Generic.Inserted */
.codehilite .go { color: #888888 } /* Generic.Output */
.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.codehilite .gs { font-weight: bold } /* Generic.Strong */
.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.codehilite .gt { color: #0044DD } /* Generic.Traceback */
.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
.codehilite .kp { color: #008000 } /* Keyword.Pseudo */
.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.codehilite .kt { color: #B00040 } /* Keyword.Type */
.codehilite .m { color: #666666 } /* Literal.Number */
.codehilite .s { color: #BA2121 } /* Literal.String */
.codehilite .na { color: #7D9029 } /* Name.Attribute */
.codehilite .nb { color: #008000 } /* Name.Builtin */
.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
.codehilite .no { color: #880000 } /* Name.Constant */
.codehilite .nd { color: #AA22FF } /* Name.Decorator */
.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */
.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.codehilite .nf { color: #0000FF } /* Name.Function */
.codehilite .nl { color: #A0A000 } /* Name.Label */
.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
.codehilite .nv { color: #19177C } /* Name.Variable */
.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
.codehilite .mb { color: #666666 } /* Literal.Number.Bin */
.codehilite .mf { color: #666666 } /* Literal.Number.Float */
.codehilite .mh { color: #666666 } /* Literal.Number.Hex */
.codehilite .mi { color: #666666 } /* Literal.Number.Integer */
.codehilite .mo { color: #666666 } /* Literal.Number.Oct */
.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.codehilite .sx { color: #008000 } /* Literal.String.Other */
.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */
.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
.codehilite .ss { color: #19177C } /* Literal.String.Symbol */
.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
.codehilite .fm { color: #0000FF } /* Name.Function.Magic */
.codehilite .vc { color: #19177C } /* Name.Variable.Class */
.codehilite .vg { color: #19177C } /* Name.Variable.Global */
.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */

View File

@ -13,6 +13,10 @@
#menu a {
font-size: 13px;
}
section {
width: 90%;
}
}
@media all and (min-width: 1400px) {
@ -33,10 +37,6 @@
.home-pinned-content article:nth-child(5) {
display: none;
}
section {
width: 90%;
}
}
@media screen and (max-width: 849px) {

7
app/static/css/simplemde.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -70,13 +70,48 @@ table.topiclist tr > td:last-child {
table.thread {
width: 100%;
border-width: 1px 0;
}
table.thread.topcomment {
border: none;
}
table.thread td.author {
width: 20%;
width: 256px;
}
table.thread td {
vertical-align: top;
vertical-align: top;
}
table.thread td:nth-child(2) {
padding-top: 10px;
table.thread div.info {
float: right;
text-align: right;
opacity: 0.7;
padding-top: 8px;
margin-left: 16px;
}
@media screen and (max-width: 1199px) {
table.thread div.info {
float: none;
display: flex;
flex-direction: row;
margin-left: 0;
}
table.thread div.info > *:not(:last-child):after {
content: '·';
margin: 0 4px;
}
table.thread td.author {
/* Includes padding */
width: 136px;
}
}
/* Tables with filters */
table.filter-target th:after {
content: attr(data-filter);
display: block;
font-size: 80%;
font-family: monospace;
font-weight: normal;
}

View File

@ -105,5 +105,6 @@ footer {
--background: #e0e0e0;
--border: 1px solid #c0c0c0;
--background-xp: #f85555;
--background-xp-100: #d03333;
--border-xp: 1px solid #d03333;
}

View File

@ -0,0 +1,113 @@
/* Theme metadata
@NAME: FK's Dark Theme
@AUTHOR: Flammingkite
*/
/*
#22292c = gris bleuté, menu original
#1c2122 = gris foncé, intérieur du menu
*/
:root {
--background: #1c2124; /*22292c, 1c2124, 1E1E1E, 242424,*/
--text: #f2f2f2;
--links: #fe2d2d;
--ok: #149641;
--ok-text: #ffffff;
--ok-active: #0f7331;
--warn: #f59f25;
--warn-text: #ffffff;
--warn-active: #ea9720;
--error: #d23a2f;
--error-text: #ffffff;
--error-active: #b32a20;
--info: #2e7aec;
--info-text: #ffffff;
--info-active: #215ab0;
--hr-border: 1px solid #b0b0b0;
}
.form {
--background: #ffffff;
--text: #000000;
--border: 1px solid #c8c8c8;
--border-focused: #7cade0;
--shadow-focused: rgba(87, 143, 228, 0.5);
}
.editor button {
--background: #ffffff;
--text: #000000;
--border: 1px solid rgba(0, 0, 0, 0);
--border-focused: 1px solid rgba(0, 0, 0, .5);
}
#light-menu {
--background: #1c2124; /*1c2124, 22292c*/
--text: #ffffff;
--icons: #ffffff;
--shadow: 0 0 4px rgba(255, 255, 255, 0.15);
--logo-bg: #bf1c11;
--logo-shadow: 0 0 2px rgba(0, 0, 0, .7);
--logo-active: #d72411;
}
#menu {
--background: #1c2124;
--text: #ffffff;
--icons: #ffffff;
--shadow: 0 0 8px rgba(0, 0, 0, 0.3);
--input-bg: #22292c;
--input-text: #ffffff;
--input-border: 1px solid #474747;
}
header {
--background: #0d1215; /*5a5a5a*/
--text: #000000;
--border: 1px solid #d0d0d0;
}
footer {
--background: rgba(0, 0, 0, 1); /* #ffffff */
--text: #a0a0a0;
--border: #d0d0d0;
}
.flash {
--background: #ffffff;
--text: #212121;
--shadow: 0 1px 12px rgba(0, 0, 0, 0.3);
/* Uncomment to inherit :root values
--ok: #149641;
--warn: #f59f25;
--error: #d23a2f;
--info: #2e7aec; */
--btn-bg: rgba(0, 0, 0, 0);
--btn-text: #000000;
--btn-bg-active: rgba(0, 0, 0, .15);
}
.profile-xp {
--background: #e0e0e0;
--border: 1px solid #c0c0c0;
--background-xp: #f85555;
--border-xp: 1px solid #d03333;
}
table tr:nth-child(even) { --background: rgba(255, 255, 255, 0.15); }
table tr:nth-child(odd) { --background: #1c2124; } /* 22292c = background, 1c2124, 1e1e1e*/

View File

@ -1,81 +1,139 @@
/* Profile summaries */
.profile {
display: flex;
align-items: center;
display: flex;
align-items: center;
width: 265px;
}
.profile-avatar {
width: 128px;
height: 128px;
margin-right: 16px;
width: 128px;
height: 128px;
margin-right: 16px;
}
.profile-name {
font-weight: bold;
font-weight: bold;
}
.profile-title {
margin-bottom: 8px;
margin-bottom: 8px;
}
.profile-points {
font-size: 11px;
font-size: 11px;
}
.profile-points span {
color: gray;
color: gray;
}
.profile-points-small {
display: none;
display: none;
}
.profile-xp {
height: 10px;
min-width: 96px;
background: var(--background);
border: var(--border);
height: 10px;
min-width: 96px;
background: var(--background);
border: var(--border);
}
.profile-xp-100 {
background: var(--background-xp);
border: var(--border-xp);
}
.profile-xp div {
height: 10px;
background: var(--background-xp);
border: var(--border-xp);
margin: -1px;
height: 10px;
background: var(--background-xp);
border: var(--border-xp);
margin: -1px;
}
.profile-xp-100 div {
background: var(--background-xp-100);
}
.profile.guest {
flex-direction: column;
width: 100%; padding-top: 12px;
text-align: center;
flex-direction: column;
width: 100%;
padding-top: 12px;
text-align: center;
}
.profile.guest em {
display: block;
font-weight: bold; font-style: normal;
margin-bottom: 8px;
display: block;
font-weight: bold;
font-style: normal;
margin-bottom: 8px;
}
@media screen and (max-width: 1199px) {
table.thread .profile {
flex-direction: column;
width: 128px;
}
table.thread .profile-avatar {
order: 1;
margin-right: 0;
}
table.thread .profile-title,
table.thread .profile-points,
table.thread .profile-xp {
display: none;
}
table.thread .profile-points-small {
display: inline;
}
}
/* Trophies */
.trophies {
display: flex; flex-wrap: wrap; justify-content: space-between;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.trophy {
display: flex; align-items: center;
width: 260px;
margin: 5px; padding: 5px;
border: 1px solid #c5c5c5;
border-left: 5px solid var(--links);
border-radius: 2px;
display: flex;
align-items: center;
width: 260px;
margin: 5px;
padding: 5px;
border: 1px solid #c5c5c5;
border-left: 5px solid var(--links);
border-radius: 2px;
}
.trophy img {
height: 48px; margin-right: 8px;
height: 48px;
margin-right: 8px;
}
.trophy div > * {
display: block;
display: block;
}
.trophy em {
font-style: normal; font-weight: bold;
margin-bottom: 3px;
font-style: normal;
font-weight: bold;
margin-bottom: 3px;
}
.trophy span {
font-size: 80%;
font-size: 80%;
}
.trophy.disabled {
filter: grayscale(100%);
opacity: .5;
border-left: 1px solid #c5c5c5;
filter: grayscale(100%);
opacity: .5;
border-left: 1px solid #c5c5c5;
}
hr.signature {
opacity: 0.2;
}

View File

@ -0,0 +1,42 @@
function entropy(password) {
var chars = [
"abcdefghijklmnopqrstuvwxyz",
"ABCDFEGHIJKLMNOPQRSTUVWXYZ",
"0123456789",
" !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", // OWASP special chars
"áàâéèêíìîóòôúùûç"
];
used = new Set();
for(c in password) {
for(k in chars) {
if(chars[k].includes(password[c])) {
used.add(chars[k]);
}
}
}
return Math.log(Math.pow(Array.from(used).join("").length, password.length)) / Math.log(2);
}
function update_entropy(ev) {
var i = document.querySelector(".entropy").previousElementSibling;
var p = document.querySelector(".entropy");
var e = entropy(i.value);
p.classList.remove('low');
p.classList.remove('medium');
p.classList.remove('high');
if(e < 60) {
p.classList.add('low');
} else if(e < 100) {
p.classList.add('medium');
} else {
p.classList.add('high');
}
p.value = e;
}
document.querySelector(".entropy").previousElementSibling.addEventListener('input', update_entropy);
document.querySelector(".entropy").style.display = "block";

View File

@ -0,0 +1,143 @@
/* Tokens and patterns; keep arrays in order of token numbers */
const T = {
END:-2, ERR:-1, NAME:0, COMP:1, UNARY:2, AND:3, OR:4, LPAR:5, RPAR:6, STR:7
}
const patterns = [
/[a-z]+/, /=|!=|~=|!~=/, /!/, /&&/, /\|\|/, /\(/, /\)/, /"([^"]*)"/
]
function* lex(str) {
while(str = str.trim()) {
var t = T.ERR, best = undefined;
for(const i in patterns) {
const m = str.match(patterns[i]);
if(m === null || m.index != 0 || (best && m[0].length < best[0].length)) continue;
t = i;
best = m;
}
if(t == T.ERR) throw "LEXING ERROR";
yield [t, best[best.length-1]];
str = str.slice(best[0].length);
}
/* Finish with a continuous stream of T.END */
while(true) yield [T.END, undefined];
}
class Parser {
constructor(lex) {
this.lex = lex;
[this.la, this.value] = lex.next().value;
}
/* Expect a token of type t, returns the value */
expect(t) {
const v = this.value;
if(this.la != t) throw (`SYNTAX ERROR, EXPECTED ${t}, GOT ${this.la} ${this.value}`);
[this.la, this.value] = this.lex.next().value;
return v;
}
/* filter -> END | expr END */
filter() {
if(this.la == T.END) return true;
const e = this.expr();
this.expect(T.END);
return e;
}
/* expr -> term_and | term_and OR expr */
expr() {
const left = this.term_and();
if(this.la != T.OR) return left;
this.expect(T.OR);
return {
type: "Or",
left: left,
right: this.expr(),
};
}
/* term_and -> unary | unary AND term_and */
term_and() {
const left = this.unary();
if(this.la != T.AND) return left;
this.expect(T.AND);
return {
type: "And",
left: left,
right: this.term_and(),
};
}
/* unary -> UNARY* atom */
unary() {
if(this.la == T.UNARY) return {
type: "Unary",
op: this.expect(T.UNARY),
val: this.unary(),
};
return this.atom();
}
/* atom -> NAME COMP STR | LPAR expr RPAR */
atom() {
if(this.la == T.LPAR) {
this.expect(T.LPAR);
const e = this.expr();
this.expect(T.RPAR);
return e;
}
var e = {
type: "Atom",
field: this.expect(T.NAME),
op: this.expect(T.COMP),
};
const str = this.expect(T.STR);
/* Precompile regular expressions */
if(e.op == "~=" || e.op == "!~=")
e.regex = new RegExp(str, "i");
else
e.str = str;
return e;
}
}
function ev(e, row, fields) {
switch(e.type) {
case "Atom":
const val = row.children[fields[e.field]].textContent.trim();
if(e.op == "=") return val == e.str;
if(e.op == "!=") return val != e.str;
if(e.op == "~=") return e.regex.test(val);
if(e.op == "!~=") return !e.regex.test(val);
case "Unary":
if(e.op == "!") return !ev(e.val, row, fields);
case "And":
return ev(e.left, row, fields) && ev(e.right, row, fields);
case "Or":
return ev(e.left, row, fields) || ev(e.right, row, fields);
}
}
function filter_update(input) {
const t = document.querySelector(input.parentNode.dataset.target);
const th = t.querySelectorAll("tr:first-child > th");
/* Generate the names of fields from the header */
var fields = {};
for(var i = 0; i < th.length; i++) {
const name = th[i].dataset.filter;
if(name) fields[name] = i;
}
/* Parse the filter as an expression */
const parser = new Parser(lex(input.value));
const expr = parser.filter();
/* Evaluate the expression on each row of the table */
const rows = t.querySelectorAll("tr:not(:first-child)");
for(const row of rows) {
const ok = (expr === true) || ev(expr, row, fields);
row.style.display = ok ? "table-row" : "none";
}
}

View File

@ -1,7 +1,7 @@
function setCookie(name, value) {
var end = new Date();
end.setTime( end.getTime() + 3600 * 1000 );
var str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/";
var str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/; Secure; SameSite=lax";
document.cookie = str;
}
function getCookie(name) {
@ -11,42 +11,3 @@ function getCookie(name) {
if( end == -1 ) end = document.cookie.length;
return unescape( document.cookie.substring( debut+name.length+1, end ) );
}
/*
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(){
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);
}

15
app/static/scripts/simplemde.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,7 @@
<div>
{{ form.password.label }}
{{ form.password(placeholder='************') }}
<progress class="entropy" value="0" max="100"></progress>
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
@ -50,6 +51,13 @@
</div>
<h2>À propos</h2>
<div>
{{ form.title.label }}
{{ form.title }}
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.birthday.label }}
{{ form.birthday(value=current_user.birthday) }}

View File

@ -0,0 +1,61 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% block title %}
<h1>Gestion des sondages</h1>
{% endblock %}
{% block content %}
<section class="form">
<h1>Créer un sondage</h1>
<form action="" method="post">
<div>
{{ form.title.label }}<br>
{{ form.title(size=32) }}<br>
{% for error in form.title.errors %}
<span style="color: red;">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.choices.label }}
<textarea id="{{ form.choices.name }}" name="{{ form.choices.name }}"></textarea>
{% for error in form.choices.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.type.label }}<br>
{{ form.type }}<br>
{% for error in form.type.errors %}
<span style="color: red;">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.start.label }}
{{ form.start() }}
{% for error in form.start.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.end.label }}
{{ form.end() }}
{% for error in form.end.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
{{ form.hidden_tag() }}
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</section>
<section>
<h1>Mes sondages</h1>
<div>
{% for p in polls %}
{{ poll_widget.wpoll(p) }}
<a href="{{ url_for('poll_delete', poll_id=p.id) }}">Supprimer le sondage</a>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@ -24,6 +24,7 @@
<div>
{{ form.password.label }}
{{ form.password() }}
<progress class="entropy" value="0" max="100"></progress>
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

View File

@ -10,6 +10,7 @@
{{ form.password.label }}
<div class=desc>{{ form.password.description }}</div>
{{ form.password() }}
<progress class="entropy" value="0" max="100"></progress>
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

View File

@ -20,7 +20,7 @@
</div>
<div style="padding:30px;">
<div style="font-size:115%;font-style:italic;margin-bottom:15px;">
{{ member.signature }}
{{ member.signature|md }}
</div>
<div>
Membre depuis le {{ member.register_date|date('%Y-%m-%d') }}
@ -30,7 +30,7 @@
<h2>Présentation</h2>
<div>
{{ member.bio }}
{{ member.bio|md }}
</div>
<h2>Groupes</h2>
@ -67,14 +67,12 @@
<th>Titre</th>
<th>Forum</th>
<th>Création</th>
<th>Commentaires</th>
</tr>
{% for t in member.topics %}
<tr>
<td><a href="{{ url_for('forum_topic', f=t.forum, page=(t, 1)) }}">{{ t.title }}</a></td>
<td><a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a></td>
<td>Le {{ t.date_created|date }}</td>
<td>{{ t.thread.comments.count() }}</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,45 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
{% endblock %}
{% block content %}
<section>
<p>Cette page présente une vue d'ensemble des pièces-jointes postées sur le site.</p>
<h2>Pièces jointes</h2>
<table style="width:95%; margin: auto;">
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Taille</th></tr>
{% for a in attachments %}
<tr>
<td>{{ a.id }}</td>
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
<td><a href="{{ url_for('user', username=a.comment.author.name) }}">{{ a.comment.author.name }}</a></td>
<td>{{ a.size }}</td>
</tr>
{% endfor %}
</table>
<h2>Liste des groupes</h2>
<table style="width:90%; margin: auto;">
<tr><th>Groupe</th><th>Membres</th><th>Privilèges</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>
{{- ', ' if not loop.last }}
{% endfor %}
</td></tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Configuration du site</h1>
{% endblock %}
{% block content %}
<section>
<h2>Configuration du site</h2>
<table style='width: 90%; margin: auto'>
<tr><th>Nom</th><th>Valeur</th></tr>
{% for k in config %}
<tr><td>{{ k }}</td><td style="font-family:monospace;">{{ config[k] }}</td></tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -45,6 +45,7 @@
{{ form.password.label }}
<div class=desc>{{ form.password.description }}</div>
{{ form.password(placeholder='************') }}
<progress class="entropy" value="0" max="100"></progress>
{% for error in form.password.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
@ -60,6 +61,13 @@
</div>
<h2>À propos</h2>
<div>
{{ form.title.label }}
{{ form.title }}
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.birthday.label }}
{{ form.birthday(value=user.birthday) }}

View File

@ -10,6 +10,11 @@
{{ form.hidden_tag() }}
<h2>Éditer le trophée</h2>
<div>
<img src="{{ url_for('static', filename='images/trophies/'+slugify(trophy.name))+'.png' }}" style="vertical-align: middle; margin-right: 8px">
<b>{{ trophy.name }}</b>
</div>
<div>
{{ form.name.label }}
{{ form.name(value=trophy.name) }}

View File

@ -7,7 +7,7 @@
<td style='padding-left: {{ 6+24*level }}px'>
<a href='/forum{{ f.url }}'>{{ f.name }}</a>
</td>
<td>{{ f.topics | length }}</td>
<td>{{ f.topics.count() }}</td>
<td>{{ f.post_count() }}</td>
</tr>

View File

@ -8,9 +8,12 @@
<section>
<p>Pages générales du panneau d'administration :</p>
<ul>
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
<li><a href="{{ url_for('adm_members') }}">Liste des membres</a></li>
<li><a href="{{ url_for('adm_trophies') }}">Titres et trophées</a></li>
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
<li><a href="{{ url_for('adm_attachments') }}">Pièces-jointes</a></li>
<li><a href="{{ url_for('adm_config') }}">Configuration du site</a></li>
</ul>
</section>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Liste des membres</h1>
{% endblock %}
{% block content %}
<section>
<h2>Listes des membres</h2>
<div class="form filter" data-target="#members">
<p>Filtrer les entrées :</p>
<input type="text" onchange="filter_update(this)">
<div class="syntax-explanation">Syntaxe :
<ul><li>Comparaisons avec <code>=</code> ou <code>!=</code> : <code>name="DarkStorm"</code></li>
<li>Comparaison regex avec <code>~=</code> ou <code>!~=</code> (insensible à la casse) : <code>name~="^dark"</code></li>
<li>Combiner avec <code>!</code>, <code>&&</code>, <code>||</code> et parenthèses : <code>(name~="^dark" || name="Lephenixnoir") && (groups~="administrateur")</code></li>
</ul>
</div>
<noscript>
<p><i>Le filtre nécessite l'activation de Javascript.</i></p>
</noscript>
</div>
<table id="members" class="filter-target" style="width:90%; margin: auto;">
<tr>
<th data-filter="name">Pseudo</th>
<th data-filter="email">Email</th>
<th data-filter="registration">Inscrit le</th>
<th data-filter="groups">Groupes</th>
<th data-filter="privs">Privilèges spéciaux</th>
<th>Modifier</th>
</tr>
{% for user in users %}
<tr><td><a href="{{ url_for('user_by_id', user_id=user.id) }}" title="Page de profil publique de {{ user.name }}">{{ user.name }}</a></td>
<td style="color: {{ 'red' if not user.email_confirmed else 'inherit' }};">{{ user.email }}</td>
<td style="text-align: center">{{ user.register_date | date('%Y-%m-%d') }}</td>
<td>{% for g in user.groups %}
<span style="{{ g.css }}">{{ g.name }}</span>
{{ ', ' if not loop.last }}
{% endfor %}</td>
<td>{% for priv in user.special_privileges() %}
<code>{{ priv }}</code>
{{- ', ' if not loop.last }}
{% endfor %}</td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_account', user_id=user.id) }}">Modifier</a></td>
</tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% block title %}
<h1>Gestion des sondages</h1>
{% endblock %}
{% block content %}
<section>
<h1>Tous les sondages</h1>
<div>
{% for p in polls %}
{{ poll_widget.wpoll(p) }}
<span>Auteur : {{ p.author.name }}</span> |
<a href="{{ url_for('poll_delete', poll_id=p.id) }}">Supprimer le sondage</a>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@ -13,17 +13,17 @@
<h2>Titres et trophées</h2>
<table style="width:90%; margin: auto;">
<tr><th>ID</th><th>Icône</th><th>Nom</th><th>Titre</th>
<tr><td></td><th>Nom</th><th>Titre</th>
<th>Style</th><th>Modifier</th><th>Supprimer</th></tr>
{% for trophy in trophies %}
<tr><td>{{ trophy.id }}</td>
<td><img src="{{ url_for('static', filename='images/account-circle.svg') }}" alt="{{ trophy.name }}"></td>
<tr><td style="background: white; padding: 0; width: 64px">
<img src="{{ url_for('static', filename='images/trophies/'+slugify(trophy.name))+'.png' }}" alt="{{ trophy.name }}"></td>
<td style="{{ trophy.css }}">{{ trophy.name }}</td>
{% if trophy | is_title %}
<td style="color:green">Oui</td>
<td style="text-align: center; color:green">Oui</td>
{% else %}
<td style="color:red">Non</td>
<td style="text-align: center; color:red">Non</td>
{% endif %}
<td><code>{{ trophy.css }}</code></td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}">Modifier</a></td>

View File

@ -10,6 +10,7 @@
<div class=title>{% block title %}<h1>Planète Casio</h1>{% endblock %}</div>
{% include "base/header.html" %}
</header>
{% include "base/flash.html" %}
{% block content %}
{% endblock %}
@ -17,8 +18,6 @@
{% include "base/footer.html" %}
</div>
{% include "base/flash.html" %}
{% include "base/scripts.html" %}
</body>
</html>

View File

@ -1,7 +1,7 @@
{% 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)">
<div class="flash {{ category }}">
<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 %}
@ -11,8 +11,8 @@
<span>
{{ message }}
</span>
<input type="button" value="MASQUER"></input>
</div>
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -11,6 +11,11 @@
<path fill="#ffffff" d="M20,2A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H6L2,22V4C2,2.89 2.9,2 4,2H20M4,4V17.17L5.17,16H20V4H4M6,7H18V9H6V7M6,11H15V13H6V11Z"></path>
</svg>Notifications{{ " ({})".format(current_user.notifications|length) if current_user.notifications|length }}
</a>
<a href="{{ url_for('account_polls') }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M9 17H7V10H9M13 17H11V7H13M17 17H15V13H17M19.5 19.1H4.5V5H19.5M19.5 3H4.5C3.4 3 2.5 3.9 2.5 5V19C2.5 20.1 3.4 21 4.5 21H19.5C20.6 21 21.5 20.1 21.5 19V5C21.5 3.9 20.6 3 19.5 3Z" />
</svg>Sondages
</a>
<a href="#">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M2,2V11C2,12 3,13 4,13H6.2C6.6,15 7.9,16.7 11,17V19.1C8.8,19.3 8,20.4 8,21.7V22H16V21.7C16,20.4 15.2,19.3 13,19.1V17C16.1,16.7 17.4,15 17.8,13H20C21,13 22,12 22,11V2H18C17.1,2 16,3 16,4H8C8,3 6.9,2 6,2H2M4,4H6V6L6,11H4V4M18,4H20V11H18V6L18,4M8,6H16V11.5C16,13.43 15.42,15 12,15C8.59,15 8,13.43 8,11.5V6Z"></path>
@ -36,7 +41,7 @@
<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>Paramètres
</a>
<a href="{{ url_for('logout') }}">
<a href="{{ url_for('logout', csrf_token=csrf_token()) }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M17,17.25V14H10V10H17V6.75L22.25,12L17,17.25M13,2A2,2 0 0,1 15,4V8H13V4H4V20H13V16H15V20A2,2 0 0,1 13,22H4A2,2 0 0,1 2,20V4A2,2 0 0,1 4,2H13Z"></path>
</svg>Déconnexion

View File

@ -0,0 +1,40 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/user.html" as widget_user %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » Édition de commentaire</h1>
{% endblock %}
{% block content %}
<section>
<h1>Édition de commentaire</h1>
<h3>Commentaire actuel</h3>
<table class="thread">
<tr>
<td class="author">{{ widget_user.profile(comment.author) }}</td>
<td><div>{{ comment.text }}</div></td>
</tr>
</table>
<div class="form">
<h3>Nouveau commentaire</h3>
<form action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% if form.pseudo %}
{{ form.pseudo.label }}
{{ form.pseudo }}
{% for error in form.pseudo.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
{% endif %}
{{ widget_editor.text_editor(form.message, label=False, autofocus=True) }}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
</div>
</section>
{% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <h1>{{ f.name }}</h1>
@ -9,13 +10,16 @@
<section>
<p>{{ f.descr }}</p>
{% if f.topics %}
{% if topics.items %}
<h2>Sujets</h2>
{{ widget_pagination.paginate(topics, 'forum_page', None, {'f': f}) }}
<table class=topiclist>
<tr><th>Sujet</th><th>Auteur</th><th>Date de création</th>
<th>Commentaires</th><th>Vues</th></tr>
<th>Commentaires</th><th>Vues</th></tr>
{% for t in f.topics %}
{% for t in topics.items %}
<tr><td><a href='{{ url_for('forum_topic', f=t.forum, page=(t,1)) }}'>{{ t.title }}</a></td>
<td><a href='{{ url_for('user', username=t.author.name) }}'>{{ t.author.name }}</a></td>
<td>{{ t.date_created | date }}</td>
@ -23,8 +27,11 @@
<td>{{ t.views }} </td></tr>
{% endfor %}
</table>
{{ widget_pagination.paginate(topics, 'forum_page', None, {'f': f}) }}
{% elif not f.sub_forums %}
<p>Il n'y a aucun topic sur ce forum ! Animons-le vite !</p>
<p>Il n'y a aucun topic sur ce forum ! Animons-le vite !</p>
{% endif %}
{% if f.sub_forums %}
@ -34,7 +41,7 @@
{% for sf in f.sub_forums %}
<tr><td><a href='/forum{{ sf.url }}'>{{ sf.name }}</td>
<td>{{ sf.topics | length }}</td></tr>
<td>{{ sf.topics.count() }}</td></tr>
<tr><td>{{ sf.descr }}</td><td></td></tr>
{% endfor %}
@ -69,6 +76,11 @@
{{ widget_editor.text_editor(form.message) }}
{{ form.attachments }}
{% for error in form.attachments.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
</div>

View File

@ -15,25 +15,25 @@
.
</p>
{% if main_forum == None %}
{% if main_forum == None %}
<p>Il n'y a aucun forum.</p>
{% else %}
{% else %}
{% for l1 in main_forum.sub_forums %}
{% for l1 in main_forum.sub_forums %}
<table class=forumlist>
<tr><th>{{ l1.name }}</th><th>Nombre de sujets</th></tr>
<tr><th>{{ l1.name }}</th><th>Nombre de sujets</th></tr>
{% if l1.sub_forums == [] %}
<tr><td><a href='/forum{{ l1.url }}'>{{ l1.name }}</td>
<td>{{ l1.topics | length }}</td></tr>
<tr><td>{{ l1.descr }}</td><td></td></tr>
{% endif %}
{% if l1.sub_forums == [] %}
<tr><td><a href='/forum{{ l1.url }}'>{{ l1.name }}</a></td>
<td>{{ l1.topics.count() }}</td></tr>
<tr><td>{{ l1.descr }}</td><td></td></tr>
{% endif %}
{% for l2 in l1.sub_forums %}
<tr><td><a href='/forum{{ l2.url }}'>{{ l2.name }}</td>
<td>{{ l2.topics | length }}</td></tr>
<tr><td>{{ l2.descr }}</td><td></td></tr>
{% endfor %}
{% for l2 in l1.sub_forums %}
<tr><td><a href='/forum{{ l2.url }}'>{{ l2.name }}</td>
<td>{{ l2.topics.count() }}</td></tr>
<tr><td>{{ l2.descr }}</td><td></td></tr>
{% endfor %}
</table>
{% endfor %}

View File

@ -1,5 +1,6 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/thread.html" as widget_thread %}
{% import "widgets/user.html" as widget_user %}
{% import "widgets/pagination.html" as widget_pagination with context %}
@ -10,59 +11,44 @@
{% block content %}
<section>
<h1>{{ t.title }}</h1>
<table class="thread"><tr>
<td class="author">{{ widget_user.profile(t.author ) }}</td>
<td>{{ t.thread.top_comment.text }}</td>
</tr></table>
{{ widget_thread.thread([t.thread.top_comment], None) }}
{{ widget_pagination.paginate(comments, 'forum_topic', t, {'f': t.forum}) }}
<table class="thread">
{% for c in comments.items %}
<tr id="{{ c.id }}">
{% if c != t.thread.top_comment %}
<td class="author">{{ widget_user.profile(c.author) }}</td>
<td>
<div>{% if c.date_created != c.date_modified %}
Posté le {{ c.date_created|date }} (Modifié le {{ c.date_modified|date }})
{% else %}
Posté le {{ c.date_created|date }}
{% endif %}
| <a href="{{ url_for('forum_topic', f=t.forum, page=(t,comments.page), _anchor=c.id) }}">#</a>
| <a href="#">Modifier</a>
| <a href="#">Supprimer</a>
</div>
<!--<hr>-->
<p>{{ c.text }}</p>
{% elif loop.index0 != 0 %}
<div>Ce message est le top comment</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{{ widget_thread.thread(comments.items, t.thread.top_comment) }}
{{ widget_pagination.paginate(comments, 'forum_topic', t, {'f': t.forum}) }}
{% if outdated %}
<div class="bg-warn">
Ce topic est sans activité depuis {{ outdated }} jours, êtes-vous sûr de vouloir y poster ?
</div>
{% endif %}
{% if current_user.is_authenticated or V5Config.ENABLE_GUEST_POST %}
<div class=form>
<h3>Commenter le sujet</h3>
<h3>Commenter le sujet</h3>
<form action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ form.hidden_tag() }}
{% if form.pseudo %}
{{ form.pseudo.label }}
{{ form.pseudo }}
{% for error in form.pseudo.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
{% endif %}
{{ widget_editor.text_editor(form.message, label=False) }}
{{ widget_editor.text_editor(form.message, label=False) }}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
{{ form.attachments }}
{% for error in form.attachments.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
{% endif %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% block title %}
<h1>Supprimer un sondage</h1>
{% endblock %}
{% block content %}
<section>
{{ poll_widget.wpoll(poll) }}
<form action="{{ url_for('poll_delete', poll_id=poll.id) }}" method=post>
{{ del_form.hidden_tag() }}
<div>
{{ del_form.delete.label }}
{{ del_form.delete(checked=False) }}
<div style="font-size: 80%; color: gray">{{ del_form.delete.description }}</div>
{% for error in del_form.delete.errors %}
<span class=msgerror>{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-error") }}</div>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base/base.html" %}
{% block title %}
<h1>Programmes de Planète Casio</h1>
{% endblock %}
{% block content %}
<section>
<h2>Tous les programmes</h2>
<table class=programlist>
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Publié le</th></tr>
{% for p in programs %}
<tr><td>{{ p.id }}</td>
<td><a href='#'>{{ p.name }}</a></td>
<td>{{ p.author.name }}</td>
<td>{{ p.date_created }}</td></tr>
{% endfor %}
</table>
</section>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% macro attachments(comment) %}
{% if comment.attachments %}
<hr style="opacity:0.2;">
<summary>Pièces-jointes</summary>
<details>
<table>
<tr><th>Nom</th><th>Taille</th></tr>
{% for a in comment.attachments %}
<tr>
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
<td>{{ a.size }}</td>
</tr>
{% endfor %}
</table>
</details>
{% endif %}
{% endmacro %}

View File

@ -1,28 +1,68 @@
{% macro text_editor(field, label=True) %}
<div class="editor">
{{ field.label if label }}
<div class="buttons">
<button type="button" title="Gras" onclick="edit(this, 'bold')"><img src="{{ url_for('static', filename = 'icons/editor/format-bold.svg') }}" alt="Gras" /></button>
<button type="button" title="Italique" onclick="edit(this, 'italic')"><img src="{{ url_for('static', filename = 'icons/editor/format-italic.svg') }}" alt="Italique" /></button>
<button type="button" title="Souligné" onclick="edit(this, 'underline')"><img src="{{ url_for('static', filename = 'icons/editor/format-underline.svg') }}" alt="Souligné" /></button>
<button type="button" title="Barré" onclick="edit(this, 'strikethrough')"><img src="{{ url_for('static', filename = 'icons/editor/format-strikethrough.svg') }}" alt="Barré" /></button>
{% macro text_editor(field, label=True, autofocus=false) %}
{{ field.label if label }}
{{ field() }}
<script>
window.addEventListener("load", function(){
var simplemde = new SimpleMDE({
element: document.getElementById("{{field.name}}"),
autofocus: {{ "true" if autofocus else "false" }},
autosave: {
enabled: true,
uniqueId: "{{ request.path }}+{{ field.name }}",
delay: 1000,
},
hideIcons: ["guide", "side-by-side", "fullscreen", "heading"],
showIcons: ["code", "table", "horizontal-rule", "ordered-list",
"unordered-list", "heading-1", "heading-2", "heading-3",
"strikethrough"
],
insertTexts: {
image: ["![](https://", ")"],
link: ["[", "](https://)"],
},
previewRender: function(plainText, preview) { // Async method
data = {text: plainText};
fetch('{{ url_for("api_markdown") }}', {
method: "POST",
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
})
.then(response => response.text())
.then(data => preview.innerHTML = data);
return "Chargement…";
},
spellChecker: false, // Uses CDN jsdelivr.net
forceSync: true,
tabSize: 4,
shortcuts: {
toggleFullScreen: null,
},
status: false,
});
<button type="button" title="Titre 1" onclick="edit(this, 'h1')"><img src="{{ url_for('static', filename = 'icons/editor/format-header-1.svg') }}" alt="Titre 1" /></button>
<button type="button" title="Titre 2" onclick="edit(this, 'h2')"><img src="{{ url_for('static', filename = 'icons/editor/format-header-2.svg') }}" alt="Titre 2" /></button>
<button type="button" title="Titre 3" onclick="edit(this, 'h3')"><img src="{{ url_for('static', filename = 'icons/editor/format-header-3.svg') }}" alt="Titre 3" /></button>
// Ctrl+Enter submits form
ta = document.querySelector("div.CodeMirror");
ta.addEventListener('keydown', function(e) {
var keyCode = e.keyCode || e.which;
if (e.ctrlKey && keyCode == 13) {
var e = e.target;
while(! (e instanceof HTMLFormElement)) {
e = e.parentNode;
}
try {
e.submit();
} catch(exception) {
e.submit.click();
}
}
});
});
<button type="button" title="Liste à puces" onclick="edit(this, 'list-bulleted')"><img src="{{ url_for('static', filename = 'icons/editor/format-list-bulleted.svg') }}" alt="Liste à puces" /></button>
<button type="button" title="Liste numérotée" onclick="edit(this, 'list-numbered')"><img src="{{ url_for('static', filename = 'icons/editor/format-list-numbered.svg') }}" alt="Liste numérotée" /></button>
<button type="button" title="Citation" onclick="edit(this, 'quote')"><img src="{{ url_for('static', filename = 'icons/editor/format-quote-close.svg') }}" alt="Citation" /></button>
<button type="button" title="Code" onclick="edit(this, 'code')"><img src="{{ url_for('static', filename = 'icons/editor/code-braces.svg') }}" alt="Code" /></button>
<button type="button" title="Spoiler" onclick="edit(this, 'spoiler')"><img src="{{ url_for('static', filename = 'icons/editor/comment-alert-outline.svg') }}" alt="Spoiler" /></button>
<button type="button" title="Lien" onclick="edit(this, 'link')"><img src="{{ url_for('static', filename = 'icons/editor/link-variant.svg') }}" alt="Lien" /></button>
</div>
{{ field() }}
{% for error in field.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
</script>
{% for error in field.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
{% endmacro %}

View File

@ -1,21 +1,33 @@
{% macro paginate(objects, route, obj, route_args) %}
<div class="pagination">
{% if objects.has_prev %}
<a href="{{ _url_for(route, route_args, page=(obj,objects.prev_num)) }}">Page précédente</a> |
{% if obj %}
<a href="{{ _url_for(route, route_args, page=(obj,objects.prev_num)) }}">Page précédente</a> |
{% else %}
<a href="{{ _url_for(route, route_args, page=objects.prev_num) }}">Page précédente</a> |
{% endif %}
{% endif %}
{% for page in objects.iter_pages(1, 5, 6, 1) %}
{% if not page %}
{% elif page != objects.page %}
<a href="{{ _url_for(route, route_args, page=(obj,page)) }}">{{ page }}</a>
{% if obj %}
<a href="{{ _url_for(route, route_args, page=(obj,page)) }}">{{ page }}</a>
{% else %}
<a href="{{ _url_for(route, route_args, page=page) }}">{{ page }}</a>
{% endif %}
{% else %}
<strong>{{ page }}</strong>
{% endif %}
{% endfor %}
{% if objects.has_next %}
| <a href="{{ _url_for(route, route_args, page=(obj,objects.next_num)) }}">Page suivante</a>
{% if obj %}
| <a href="{{ _url_for(route, route_args, page=(obj,objects.next_num)) }}">Page suivante</a>
{% else %}
| <a href="{{ _url_for(route, route_args, page=objects.next_num) }}">Page suivante</a>
{% endif %}
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,40 @@
{% macro wpoll(poll) %}
{% import "widgets/polls/"+poll.template as poll_template with context %}
<div class="poll">
<h3>{{ poll.title }}</h3>
{# Poll has not begin #}
{% if not poll.started %}
<p><i>Le sondage ouvrira le {{ poll.start | date }}.</i></p>
{# Poll has ended: display results #}
{% elif poll.ended %}
{{ poll_template.results(poll) }}
<p><i>Ce sondage est terminé.</i></p>
{# Current user is a guest #}
{% elif not current_user.is_authenticated %}
<p><i>Seuls les membres peuvent voter.</i></p>
{# Current user cannot vote #}
{% elif not poll.can_vote(current_user) %}
<p><i>Vous n'avez pas le droit de voter dans ce sondage.</i></p>
{# Current user has already voted #}
{% elif poll.has_voted(current_user) %}
{{ poll_template.results(poll) }}
<p><i>Vous avez déjà voté.</i></p>
{# Current user can vote #}
{% else %}
<form class="poll" action="{{ url_for('poll_vote', poll_id=poll.id) }}" method="post" enctype="multipart/form-data">
{{ poll_template.choices(poll) }}
<input type="submit" value="Envoyer" />
<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}" />
</form>
{% endif %}
</div>
{% endmacro %}
{{ wpoll(poll) if poll }}

View File

@ -0,0 +1,7 @@
{% macro choices(p) %}
<div>Default choices</div>
{% endmacro %}
{% macro results(p) %}
<div>Default results.</div>
{% endmacro %}

View File

@ -0,0 +1,25 @@
{% macro choices(poll) %}
<fieldset>
{% for choice in poll.choices %}
<input type="checkbox" id="poll{{ poll.id }}-{{ choice.id }}" name="pollanswers-{{ choice.id }}" value="{{ choice.id }}" />
<label for="poll{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label><br/>
{% endfor %}
</fieldset>
{% endmacro %}
{% macro results(poll) %}
<table>
{% for choice, votes in poll.results.most_common() %}
<tr>
<td><label for="poll{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label></td>
<td>
<progress id="poll{{ poll.id }}-{{ choice.id }}" value="{{ votes }}" max="{{ len(poll.answers) }}"></progress>
</td>
<td>{{ votes }}</td>
</tr>
{% endfor %}
<tr>
<th>Participations</th><th></th><th>{{ len(poll.answers) }}</th>
</tr>
</table>
{% endmacro %}

View File

@ -0,0 +1,25 @@
{% macro choices(poll) %}
<fieldset>
{% for choice in poll.choices %}
<input type="radio" id="poll{{ poll.id }}-{{ choice.id }}" name="pollanwsers" value="{{ choice.id }}" />
<label for="poll{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label><br/>
{% endfor %}
</fieldset>
{% endmacro %}
{% macro results(poll) %}
<table>
{% for choice, votes in poll.results.most_common() %}
<tr>
<td><label for="poll{{ poll.id }}-{{ choice.id }}">{{ choice.title }}</label></td>
<td>
<progress id="poll{{ poll.id }}-{{ choice.id }}" value="{{ votes }}" max="{{ len(poll.answers) }}"></progress>
</td>
<td>{{ votes }}</td>
</tr>
{% endfor %}
<tr>
<th>Participations</th><th></th><th>{{ len(poll.answers) }}</th>
</tr>
</table>
{% endmacro %}

View File

@ -0,0 +1,36 @@
{% import "widgets/user.html" as widget_user %}
{% import "widgets/attachments.html" as widget_attachments %}
{% macro thread(comments, top_comment) %}
<table class="thread {{ 'topcomment' if top_comment == None else ''}} ">
{% for c in comments %}
<tr id="{{ c.id }}">
{% if c != top_comment %}
<td class="author">{{ widget_user.profile(c.author) }}</td>
<td>
<div class="info">
<div>Posté le {{ c.date_created|date }}</div>
{% if c.date_created != c.date_modified %}
<div>Modifié le {{ c.date_modified|date }}</div>
{% endif %}
<div><a href="{{ request.path }}#{{ c.id }}">Permalink</a></div>
<div><a href="{{ url_for('edit_post', postid=c.id, r=request.path) }}">Modifier</a></div>
<div><a href="{{ url_for('delete_post', postid=c.id, csrf_token=csrf_token()) }}" onclick="return confirm('Le message sera supprimé')">Supprimer</a></div>
</div>
{{ c.text|md }}
{{ widget_attachments.attachments(c) }}
{% if c.author.signature %}
<hr class="signature">
{{ c.author.signature|md }}
{% endif %}
</td>
{% elif loop.index0 != 0 %}
<div>Ce message est le top comment</div>
{% endif %}
</tr>
{% endfor %}
</table>
{% endmacro %}

View File

@ -4,10 +4,18 @@
<img class="profile-avatar" src="{{ url_for('avatar', filename=user.avatar) }}" alt="Avatar de {{ user.name }}">
<div>
<div class="profile-name"><a href="{{ url_for('user', username=user.name) }}">{{ user.name }}</a></div>
{% if user.title %}
<div class="profile-title" style="{{ user.title.css }}">{{ user.title.name }}</div>
{% else %}
<div class="profile-title">Membre</div>
{% endif %}
<div class="profile-points">Niveau {{ user.level[0] }} <span>({{ user.xp }})</span></div>
<div class="profile-points-small">N{{ user.level[0] }} <span>({{ user.xp }})</span></div>
<div class="profile-xp"><div style='width: {{ user.level[1] }}%;'></div></div>
<div class="profile-points-small">Niv. {{ user.level[0] }}</div>
{% if user.level[0] <= 100 %}
<div class="profile-xp"><div style='width: {{ user.level[0] }}%;'></div></div>
{% else %}
<div class="profile-xp profile-xp-100"><div style='width: {{ user.level[0] - 100 }}%;'></div></div>
{% endif %}
</div>
</div>
{% else %}

18
app/utils/check_csrf.py Normal file
View File

@ -0,0 +1,18 @@
from functools import wraps
from flask import request, abort
from flask_wtf import csrf
from wtforms.validators import ValidationError
from app import app
def check_csrf(func):
"""
Check csrf_token GET parameter
"""
@wraps(func)
def wrapped(*args, **kwargs):
try:
csrf.validate_csrf(request.args.get('csrf_token'))
except ValidationError:
abort(404)
return func(*args, **kwargs)
return wrapped

View File

@ -20,8 +20,7 @@ from werkzeug.routing import BaseConverter, ValidationError
from app.models.forum import Forum
from app.models.topic import Topic
from slugify import slugify
import re
import sys
class ForumConverter(BaseConverter):

View File

@ -1,9 +0,0 @@
from app import app
@app.template_filter('date')
def filter_date(date, format="%Y-%m-%d à %H:%M"):
"""
Print a date in a human-readable format.
"""
return date.strftime(format)

9
app/utils/filesize.py Normal file
View File

@ -0,0 +1,9 @@
import os
def filesize(file):
"""Return the filesize. Save in /tmp and delete it when done"""
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(0)
return size

View File

@ -0,0 +1,3 @@
# Register filters here
from app.utils.filters import date, is_title, markdown, pluralize

26
app/utils/filters/date.py Normal file
View File

@ -0,0 +1,26 @@
from app import app
from datetime import datetime
@app.template_filter('date')
def filter_date(date, format="%Y-%m-%d à %H:%M"):
"""
Print a date in a human-readable format.
"""
if format == "dynamic":
d = "1er" if date.day == 1 else int(date.day)
m = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet",
"Août", "Septembre", "Octobre", "Novembre","Décembre"] \
[date.month - 1]
# Omit current year in the dynamic format
if date.year == datetime.now().year:
format = f"{d} {m} à %H:%M"
else:
format = f"{d} {m} %Y à %H:%M"
return date.strftime(format)
@app.template_filter('dyndate')
def filter_dyndate(date):
return filter_date(date, format="dynamic")

Some files were not shown because too many files have changed in this diff Show More