Merge branch 'preprod' on master
This commit is contained in:
commit
41eaaa4c30
|
@ -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
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;"
|
||||
|
|
|
@ -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 !',
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'
|
||||
)
|
|
@ -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')
|
||||
|
|
|
@ -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 !',
|
||||
)
|
|
@ -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
|
|
@ -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
|
|
@ -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}>'
|
||||
|
|
|
@ -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}>'
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,5 +1,4 @@
|
|||
from app import db
|
||||
from app.models.users import User
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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}">'
|
|
@ -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}>'
|
||||
|
|
|
@ -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}>'
|
||||
|
|
|
@ -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")
|
|
@ -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
|
|
@ -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():
|
||||
|
|