Browse Source

Merge branch 'preprod' on master

master
Eldeberen 3 months ago
parent
commit
41eaaa4c30
Signed by: Darks GPG Key ID: 7515644268BE1433
122 changed files with 3064 additions and 690 deletions
  1. +10
    -1
      .gitignore
  2. +1
    -3
      V5.py
  3. +11
    -56
      app/__init__.py
  4. +16
    -1
      app/data/forums.yaml
  5. +4
    -3
      app/data/groups.yaml
  6. +42
    -24
      app/forms/account.py
  7. +26
    -15
      app/forms/forum.py
  8. +3
    -3
      app/forms/login.py
  9. +58
    -0
      app/forms/poll.py
  10. +6
    -25
      app/forms/search.py
  11. +3
    -3
      app/forms/trophy.py
  12. +6
    -0
      app/models/__init__.py
  13. +50
    -0
      app/models/attachment.py
  14. +8
    -1
      app/models/comment.py
  15. +14
    -3
      app/models/forum.py
  16. +123
    -0
      app/models/poll.py
  17. +49
    -0
      app/models/polls/multiple.py
  18. +49
    -0
      app/models/polls/simple.py
  19. +0
    -1
      app/models/post.py
  20. +3
    -4
      app/models/priv.py
  21. +44
    -0
      app/models/program.py
  22. +29
    -1
      app/models/thread.py
  23. +21
    -6
      app/models/topic.py
  24. +0
    -0
      app/models/trophy.py
  25. +28
    -15
      app/models/user.py
  26. +5
    -0
      app/processors/__init__.py
  27. +1
    -3
      app/processors/menu.py
  28. +8
    -0
      app/processors/stats.py
  29. +4
    -2
      app/processors/utilities.py
  30. +16
    -0
      app/routes/__init__.py
  31. +14
    -5
      app/routes/account/account.py
  32. +6
    -4
      app/routes/account/login.py
  33. +32
    -0
      app/routes/account/polls.py
  34. +12
    -6
      app/routes/admin/account.py
  35. +13
    -0
      app/routes/admin/attachments.py
  36. +13
    -0
      app/routes/admin/config.py
  37. +9
    -4
      app/routes/admin/groups.py
  38. +14
    -0
      app/routes/admin/members.py
  39. +11
    -0
      app/routes/admin/polls.py
  40. +2
    -2
      app/routes/admin/trophies.py
  41. +13
    -0
      app/routes/api/markdown.py
  42. +25
    -0
      app/routes/development.py
  43. +28
    -9
      app/routes/forum/index.py
  44. +31
    -11
      app/routes/forum/topic.py
  45. +31
    -0
      app/routes/polls/delete.py
  46. +41
    -0
      app/routes/polls/vote.py
  47. +58
    -0
      app/routes/posts/edit.py
  48. +8
    -0
      app/routes/programs/index.py
  49. +3
    -10
      app/routes/users.py
  50. +9
    -19
      app/static/css/flash.css
  51. +64
    -1
      app/static/css/form.css
  52. +75
    -0
      app/static/css/pygments.css
  53. +4
    -4
      app/static/css/responsive.css
  54. +7
    -0
      app/static/css/simplemde.min.css
  55. +39
    -4
      app/static/css/table.css
  56. +1
    -0
      app/static/css/theme.css
  57. +113
    -0
      app/static/css/themes/FK_dark_theme.css
  58. +97
    -39
      app/static/css/widgets.css
  59. +42
    -0
      app/static/scripts/entropy.js
  60. +143
    -0
      app/static/scripts/filter.js
  61. +1
    -40
      app/static/scripts/pc-utils.js
  62. +15
    -0
      app/static/scripts/simplemde.min.js
  63. +8
    -0
      app/templates/account/account.html
  64. +61
    -0
      app/templates/account/polls.html
  65. +1
    -0
      app/templates/account/register.html
  66. +1
    -0
      app/templates/account/reset_password.html
  67. +2
    -4
      app/templates/account/user.html
  68. +45
    -0
      app/templates/admin/attachments.html
  69. +18
    -0
      app/templates/admin/config.html
  70. +8
    -0
      app/templates/admin/edit_account.html
  71. +5
    -0
      app/templates/admin/edit_trophy.html
  72. +1
    -1
      app/templates/admin/forums.html
  73. +5
    -2
      app/templates/admin/index.html
  74. +52
    -0
      app/templates/admin/members.html
  75. +19
    -0
      app/templates/admin/polls.html
  76. +5
    -5
      app/templates/admin/trophies.html
  77. +1
    -2
      app/templates/base/base.html
  78. +2
    -2
      app/templates/base/flash.html
  79. +6
    -1
      app/templates/base/navbar/account.html
  80. +40
    -0
      app/templates/forum/edit_comment.html
  81. +17
    -5
      app/templates/forum/forum.html
  82. +16
    -16
      app/templates/forum/index.html
  83. +22
    -36
      app/templates/forum/topic.html
  84. +25
    -0
      app/templates/poll/delete.html
  85. +21
    -0
      app/templates/programs/index.html
  86. +17
    -0
      app/templates/widgets/attachments.html
  87. +65
    -25
      app/templates/widgets/editor.html
  88. +15
    -3
      app/templates/widgets/pagination.html
  89. +40
    -0
      app/templates/widgets/poll.html
  90. +7
    -0
      app/templates/widgets/polls/defaultpoll.html
  91. +25
    -0
      app/templates/widgets/polls/multiplepoll.html
  92. +25
    -0
      app/templates/widgets/polls/simplepoll.html
  93. +36
    -0
      app/templates/widgets/thread.html
  94. +10
    -2
      app/templates/widgets/user.html
  95. +18
    -0
      app/utils/check_csrf.py
  96. +1
    -2
      app/utils/converters.py
  97. +0
    -9
      app/utils/date.py
  98. +9
    -0
      app/utils/filesize.py
  99. +3
    -0
      app/utils/filters/__init__.py
  100. +26
    -0
      app/utils/filters/date.py

+ 10
- 1
.gitignore 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/

+ 1
- 3
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


+ 11
- 56
app/__init__.py 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)
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
# Register routes
from app import routes
from app.utils import pluralize # To use pluralize into the templates
from app.utils import date
from app.utils import is_title
# Register filters
from app.utils import filters
# Add slugify into the available functions in every template
app.jinja_env.globals.update(
slugify=slugify.slugify
)
# Register processors
from app import processors

+ 16
- 1
app/data/forums.yaml 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.

+ 4
- 3
app/data/groups.yaml 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;"


+ 42
- 24
app/forms/account.py 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 !',
)


+ 26
- 15
app/forms/forum.py 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

+ 3
- 3
app/forms/login.py 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
- 0
app/forms/poll.py 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'
)

+ 6
- 25
app/forms/search.py 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')

app/forms/trophies.py → app/forms/trophy.py View File


+ 6
- 0
app/models/__init__.py 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
- 0
app/models/attachment.py 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

+ 8
- 1
app/models/comment.py 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}>'

+ 14
- 3
app/models/forum.py 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
- 0
app/models/poll.py 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

+ 49
- 0
app/models/polls/multiple.py 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

+ 49
- 0
app/models/polls/simple.py 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

+ 0
- 1
app/models/post.py View File

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


app/models/privs.py → app/models/priv.py View File


+ 44
- 0
app/models/program.py 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}">'

+ 29
- 1
app/models/thread.py 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}>'

+ 21
- 6
app/models/topic.py 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}>'

app/models/trophies.py → app/models/trophy.py View File


app/models/users.py → app/models/user.py View File


+ 5
- 0
app/processors/__init__.py 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

+ 1
- 3
app/processors/menu.py 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
- 0
app/processors/stats.py 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)

+ 4
- 2
app/processors/utilities.py 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
- 0
app/routes/__init__.py 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

+ 14
- 5
app/routes/account/account.py 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)


+ 6
- 4
app/routes/account/login.py 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')


+ 32
- 0
app/routes/account/polls.py 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)

+ 12
- 6
app/routes/admin/account.py 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'])


+ 13
- 0
app/routes/admin/attachments.py 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)

+ 13
- 0
app/routes/admin/config.py 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)

+ 9
- 4
app/routes/admin/groups.py 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)

+ 14
- 0
app/routes/admin/members.py 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
- 0
app/routes/admin/polls.py 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)

+ 2
- 2
app/routes/admin/trophies.py 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