Browse Source

Merge branch 'preprod' on master

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

11
.gitignore

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

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

67
app/__init__.py

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

17
app/data/forums.yaml

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

7
app/data/groups.yaml

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

66
app/forms/account.py

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

41
app/forms/forum.py

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

6
app/forms/login.py

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

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

31
app/forms/search.py

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

6
app/forms/trophies.py → app/forms/trophy.py

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

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

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

9
app/models/comment.py

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

17
app/models/forum.py

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

@ -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
app/models/polls/multiple.py

@ -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
app/models/polls/simple.py

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

1
app/models/post.py

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

7
app/models/privs.py → app/models/priv.py

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

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

30
app/models/thread.py

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

27
app/models/topic.py

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

0
app/models/trophies.py → app/models/trophy.py

43
app/models/users.py → app/models/user.py

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

5
app/processors/__init__.py

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

4
app/processors/menu.py

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

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

6
app/processors/utilities.py

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

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

19
app/routes/account/account.py

@ -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"],<