Merge branch 'dev' into preprod
This commit is contained in:
commit
9d9aee6565
|
@ -28,8 +28,8 @@ app.url_map.converters['topicpage'] = TopicPageConverter
|
|||
# Register routes
|
||||
from app import routes
|
||||
|
||||
# Register utils
|
||||
from app import utils
|
||||
# Register filters
|
||||
from app.utils import filters
|
||||
|
||||
# Register processors
|
||||
from app import processors
|
||||
|
|
|
@ -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,23 +1,32 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, FormField, SubmitField, TextAreaField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
from wtforms import StringField, FormField, SubmitField, TextAreaField, \
|
||||
MultipleFileField
|
||||
from wtforms.validators import DataRequired, Length, Optional
|
||||
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=[DataRequired()])
|
||||
attachments = MultipleFileField('Pièces-jointes',
|
||||
validators=[vd.file.optional, vd.file.count, vd.file.extension,
|
||||
vd.file.size])
|
||||
submit = SubmitField('Commenter')
|
||||
preview = SubmitField('Prévisualiser')
|
||||
|
||||
class AnonymousCommentForm(CommentForm):
|
||||
pseudo = StringField('Pseudo',
|
||||
validators=[DataRequired(), 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=[DataRequired(), Length(min=3, max=32)])
|
||||
submit = SubmitField('Créer le sujet')
|
||||
|
||||
class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm):
|
||||
pass
|
||||
|
|
|
@ -3,3 +3,4 @@ from app.models.thread import Thread
|
|||
from app.models.forum import Forum
|
||||
from app.models.topic import Topic
|
||||
from app.models.notification import Notification
|
||||
from app.models.program import Program
|
||||
|
|
50
app/models/attachment.py
Normal file
50
app/models/attachment.py
Normal 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
|
43
app/models/program.py
Normal file
43
app/models/program.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
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)
|
||||
|
||||
# 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}">'
|
|
@ -3,13 +3,22 @@ from app.models.post import Post
|
|||
|
||||
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__,
|
||||
'primary_key': id,
|
||||
'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(32))
|
||||
title = db.Column(db.Unicode(128))
|
||||
|
||||
# Parent forum
|
||||
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# Register routes here
|
||||
|
||||
from app.routes import index, search, users, tools
|
||||
from app.routes import index, search, users, tools, development
|
||||
from app.routes.account import login, account, notification
|
||||
from app.routes.admin import index, groups, account, trophies, forums
|
||||
from app.routes.admin import index, groups, account, trophies, forums, attachments
|
||||
from app.routes.forum import index, topic
|
||||
from app.routes.programs import index
|
||||
from app.routes.posts import edit
|
||||
|
|
|
@ -130,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:
|
||||
|
|
13
app/routes/admin/attachments.py
Normal file
13
app/routes/admin/attachments.py
Normal 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)
|
25
app/routes/development.py
Normal file
25
app/routes/development.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from flask import send_file, redirect, url_for, abort
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import app
|
||||
from config import V5Config
|
||||
import os
|
||||
|
||||
# These routes are used in development
|
||||
# In production, those files should be served by the web server (nginx)
|
||||
|
||||
@app.route('/avatar/<filename>')
|
||||
def avatar(filename):
|
||||
filename = secure_filename(filename) # No h4ckers allowed
|
||||
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
|
||||
if os.path.isfile(filepath):
|
||||
return send_file(filepath)
|
||||
return redirect(url_for('static', filename='images/default_avatar.png'))
|
||||
|
||||
@app.route('/fichiers/<path>/<name>')
|
||||
def attachment(path, name):
|
||||
file = os.path.join(V5Config.DATA_FOLDER, "attachments",
|
||||
secure_filename(path), secure_filename(name))
|
||||
if os.path.isfile(file):
|
||||
return send_file(file)
|
||||
else:
|
||||
abort(404)
|
|
@ -10,6 +10,7 @@ from app.models.topic import Topic
|
|||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
from app.models.users import Guest
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
|
||||
@app.route('/forum/')
|
||||
|
@ -34,17 +35,18 @@ def forum_page(f):
|
|||
or ("/actus" not in f.url and not f.sub_forums)) and (
|
||||
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
|
||||
|
||||
# First create the thread, then the comment, then the topic
|
||||
th = Thread()
|
||||
db.session.add(th)
|
||||
db.session.commit()
|
||||
|
||||
# Manage author
|
||||
if current_user.is_authenticated:
|
||||
author = current_user
|
||||
else:
|
||||
author = Guest(form.pseudo.data)
|
||||
db.session.add(author)
|
||||
|
||||
# First create the thread, then the comment, then the topic
|
||||
th = Thread()
|
||||
db.session.add(th)
|
||||
db.session.commit()
|
||||
|
||||
c = Comment(author, form.message.data, th)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
@ -56,6 +58,17 @@ def forum_page(f):
|
|||
db.session.add(t)
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
current_user.add_xp(2) # 2 points for a topic
|
||||
|
|
|
@ -10,6 +10,7 @@ from app.models.topic import Topic
|
|||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
from app.models.users import Guest
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
|
||||
@app.route('/forum/<forum:f>/<topicpage:page>', methods=['GET', 'POST'])
|
||||
|
@ -27,16 +28,29 @@ def forum_topic(f, page):
|
|||
|
||||
if form.validate_on_submit() and \
|
||||
(V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
|
||||
# Manage author
|
||||
if current_user.is_authenticated:
|
||||
author = current_user
|
||||
else:
|
||||
author = Guest(form.pseudo.data)
|
||||
db.session.add(author)
|
||||
|
||||
# Create comment
|
||||
c = Comment(author, form.message.data, t.thread)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
current_user.add_xp(1) # 1 point for a comment
|
||||
|
|
34
app/routes/posts/edit.py
Normal file
34
app/routes/posts/edit.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from app import app, db
|
||||
from app.models.post import Post
|
||||
from app.utils.render import render
|
||||
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm
|
||||
from flask import redirect, url_for, abort
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
@app.route('/post/<int:postid>', methods=['GET','POST'])
|
||||
# TODO: Allow guest edit of posts
|
||||
@login_required
|
||||
def edit_post(postid):
|
||||
p = Post.query.filter_by(id=postid).first_or_404()
|
||||
|
||||
# TODO: Check whether privileged user has access to board
|
||||
if p.author != current_user and not current_user.priv("edit-posts"):
|
||||
abort(403)
|
||||
|
||||
if p.type == "comment":
|
||||
form = CommentEditForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
p.text = form.message.data
|
||||
|
||||
if form.submit.data:
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
|
||||
# TODO: Proper redirection
|
||||
return redirect(url_for('index'))
|
||||
|
||||
form.message.data = p.text
|
||||
return render('forum/edit_comment.html', comment=p, form=form)
|
||||
else:
|
||||
abort(404)
|
8
app/routes/programs/index.py
Normal file
8
app/routes/programs/index.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from app import app, db
|
||||
from app.models.program import Program
|
||||
from app.utils.render import render
|
||||
|
||||
@app.route('/programmes')
|
||||
def program_index():
|
||||
programs = Program.query.all()
|
||||
return render('/programs/index.html')
|
|
@ -21,11 +21,3 @@ def user(username):
|
|||
def user_by_id(user_id):
|
||||
member = Member.query.filter_by(id=user_id).first_or_404()
|
||||
return redirect(url_for('user', username=member.name))
|
||||
|
||||
@app.route('/avatar/<filename>')
|
||||
def avatar(filename):
|
||||
filename = secure_filename(filename) # No h4ckers allowed
|
||||
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
|
||||
if os.path.isfile(filepath):
|
||||
return send_file(filepath)
|
||||
return redirect(url_for('static', filename='images/default_avatar.png'))
|
||||
|
|
|
@ -105,5 +105,6 @@ footer {
|
|||
--background: #e0e0e0;
|
||||
--border: 1px solid #c0c0c0;
|
||||
--background-xp: #f85555;
|
||||
--background-xp-100: #d03333;
|
||||
--border-xp: 1px solid #d03333;
|
||||
}
|
||||
|
|
|
@ -31,12 +31,19 @@
|
|||
background: var(--background);
|
||||
border: var(--border);
|
||||
}
|
||||
.profile-xp-100 {
|
||||
background: var(--background-xp);
|
||||
border: var(--border-xp);
|
||||
}
|
||||
.profile-xp div {
|
||||
height: 10px;
|
||||
background: var(--background-xp);
|
||||
border: var(--border-xp);
|
||||
margin: -1px;
|
||||
}
|
||||
.profile-xp-100 div {
|
||||
background: var(--background-xp-100);
|
||||
}
|
||||
|
||||
.profile.guest {
|
||||
flex-direction: column;
|
||||
|
|
45
app/templates/admin/attachments.html
Normal file
45
app/templates/admin/attachments.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<p>Cette page présente une vue d'ensemble des pièces-jointes postées sur le site.</p>
|
||||
|
||||
<h2>Pièces jointes</h2>
|
||||
|
||||
<table style="width:95%; margin: auto;">
|
||||
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Taille</th></tr>
|
||||
|
||||
{% for a in attachments %}
|
||||
<tr>
|
||||
<td>{{ a.id }}</td>
|
||||
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
|
||||
<td><a href="{{ url_for('user', username=a.comment.author.name) }}">{{ a.comment.author.name }}</a></td>
|
||||
<td>{{ a.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>Liste des groupes</h2>
|
||||
|
||||
<table style="width:90%; margin: auto;">
|
||||
<tr><th>Groupe</th><th>Membres</th><th>Privilèges</th></tr>
|
||||
|
||||
{% for group in groups %}
|
||||
<tr><td><span style="{{ group.css }}">{{ group.name }}</span></td><td>
|
||||
{% for user in group.members %}
|
||||
{{ user.name }}
|
||||
{% endfor %}
|
||||
</td><td>
|
||||
{% for priv in group.privs() %}
|
||||
<code>{{ priv }}</code>
|
||||
{{- ', ' if not loop.last }}
|
||||
{% endfor %}
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -8,9 +8,10 @@
|
|||
<section>
|
||||
<p>Pages générales du panneau d'administration :</p>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
|
||||
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
|
||||
<li><a href="{{ url_for('adm_trophies') }}">Titres et trophées</a></li>
|
||||
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
|
||||
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
|
||||
<li><a href="{{ url_for('adm_attachments') }}">Pièces-jointes</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
41
app/templates/forum/edit_comment.html
Normal file
41
app/templates/forum/edit_comment.html
Normal file
|
@ -0,0 +1,41 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/editor.html" as widget_editor %}
|
||||
{% import "widgets/user.html" as widget_user %}
|
||||
|
||||
{% block title %}
|
||||
<a href='/forum'>Forum de Planète Casio</a> » Édition de commentaire</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<h1>Édition de commentaire</h1>
|
||||
|
||||
<h3>Commentaire actuel</h3>
|
||||
<table class="thread">
|
||||
<tr>
|
||||
<td class="author">{{ widget_user.profile(comment.author) }}</td>
|
||||
<td><div>{{ comment.text }}</div></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="form">
|
||||
<h3>Nouveau commentaire</h3>
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{% if form.pseudo %}
|
||||
{{ form.pseudo.label }}
|
||||
{{ form.pseudo }}
|
||||
{% for error in form.pseudo.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{{ widget_editor.text_editor(form.message, label=False) }}
|
||||
|
||||
<div>{{ form.preview(class_='bg-ok') }}</div>
|
||||
<div>{{ form.submit(class_='bg-ok') }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -69,6 +69,11 @@
|
|||
|
||||
{{ widget_editor.text_editor(form.message) }}
|
||||
|
||||
{{ form.attachments }}
|
||||
{% for error in form.attachments.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
||||
<div>{{ form.submit(class_='bg-ok') }}</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -15,25 +15,25 @@
|
|||
.
|
||||
</p>
|
||||
|
||||
{% if main_forum == None %}
|
||||
{% if main_forum == None %}
|
||||
<p>Il n'y a aucun forum.</p>
|
||||
{% else %}
|
||||
{% else %}
|
||||
|
||||
{% for l1 in main_forum.sub_forums %}
|
||||
{% for l1 in main_forum.sub_forums %}
|
||||
<table class=forumlist>
|
||||
<tr><th>{{ l1.name }}</th><th>Nombre de sujets</th></tr>
|
||||
<tr><th>{{ l1.name }}</th><th>Nombre de sujets</th></tr>
|
||||
|
||||
{% if l1.sub_forums == [] %}
|
||||
<tr><td><a href='/forum{{ l1.url }}'>{{ l1.name }}</td>
|
||||
<td>{{ l1.topics | length }}</td></tr>
|
||||
<tr><td>{{ l1.descr }}</td><td></td></tr>
|
||||
{% endif %}
|
||||
{% if l1.sub_forums == [] %}
|
||||
<tr><td><a href='/forum{{ l1.url }}'>{{ l1.name }}</a></td>
|
||||
<td>{{ l1.topics | length }}</td></tr>
|
||||
<tr><td>{{ l1.descr }}</td><td></td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% for l2 in l1.sub_forums %}
|
||||
<tr><td><a href='/forum{{ l2.url }}'>{{ l2.name }}</td>
|
||||
<td>{{ l2.topics | length }}</td></tr>
|
||||
<tr><td>{{ l2.descr }}</td><td></td></tr>
|
||||
{% endfor %}
|
||||
{% for l2 in l1.sub_forums %}
|
||||
<tr><td><a href='/forum{{ l2.url }}'>{{ l2.name }}</td>
|
||||
<td>{{ l2.topics | length }}</td></tr>
|
||||
<tr><td>{{ l2.descr }}</td><td></td></tr>
|
||||
{% endfor %}
|
||||
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{% import "widgets/editor.html" as widget_editor %}
|
||||
{% import "widgets/user.html" as widget_user %}
|
||||
{% import "widgets/pagination.html" as widget_pagination with context %}
|
||||
{% import "widgets/attachments.html" as widget_attachments %}
|
||||
|
||||
{% block title %}
|
||||
<a href='/forum'>Forum de Planète Casio</a> » <a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a> » <h1>{{ t.title }}</h1>
|
||||
|
@ -26,18 +27,19 @@
|
|||
<div>{% if c.date_created != c.date_modified %}
|
||||
Posté le {{ c.date_created|date }} (Modifié le {{ c.date_modified|date }})
|
||||
{% else %}
|
||||
Posté le {{ c.date_created|date }}
|
||||
Posté le {{ c.date_created|dyndate }}
|
||||
{% endif %}
|
||||
| <a href="{{ url_for('forum_topic', f=t.forum, page=(t,comments.page), _anchor=c.id) }}">#</a>
|
||||
| <a href="#">Modifier</a>
|
||||
| <a href="{{ url_for('edit_post', postid=c.id) }}">Modifier</a>
|
||||
| <a href="#">Supprimer</a>
|
||||
</div>
|
||||
<!--<hr>-->
|
||||
<p>{{ c.text }}</p>
|
||||
{{ widget_attachments.attachments(c) }}
|
||||
{% elif loop.index0 != 0 %}
|
||||
<div>Ce message est le top comment</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
@ -60,6 +62,11 @@
|
|||
|
||||
{{ widget_editor.text_editor(form.message, label=False) }}
|
||||
|
||||
{{ form.attachments }}
|
||||
{% for error in form.attachments.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
||||
<div>{{ form.submit(class_='bg-ok') }}</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
21
app/templates/programs/index.html
Normal file
21
app/templates/programs/index.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Programmes de Planète Casio</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Tous les programmes</h2>
|
||||
|
||||
<table class=programlist>
|
||||
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Publié le</th></tr>
|
||||
{% for p in programs %}
|
||||
<tr><td>{{ p.id }}</td>
|
||||
<td><a href='#'>{{ p.name }}</a></td>
|
||||
<td>{{ p.author.name }}</td>
|
||||
<td>{{ p.date_created }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
16
app/templates/widgets/attachments.html
Normal file
16
app/templates/widgets/attachments.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% macro attachments(comment) %}
|
||||
{% if comment.attachments %}
|
||||
<summary>Pièces-jointes</summary>
|
||||
<details>
|
||||
<table>
|
||||
<tr><th>Nom</th><th>Taille</th></tr>
|
||||
{% for a in comment.attachments %}
|
||||
<tr>
|
||||
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
|
||||
<td>{{ a.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
|
@ -11,7 +11,11 @@
|
|||
{% endif %}
|
||||
<div class="profile-points">Niveau {{ user.level[0] }} <span>({{ user.xp }})</span></div>
|
||||
<div class="profile-points-small">N{{ user.level[0] }} <span>({{ user.xp }})</span></div>
|
||||
<div class="profile-xp"><div style='width: {{ user.level[1] }}%;'></div></div>
|
||||
{% if user.level[0] <= 100 %}
|
||||
<div class="profile-xp"><div style='width: {{ user.level[0] }}%;'></div></div>
|
||||
{% else %}
|
||||
<div class="profile-xp profile-xp-100"><div style='width: {{ user.level[0] - 100 }}%;'></div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
# Register utils here
|
||||
|
||||
from app.utils import pluralize
|
||||
from app.utils import date
|
||||
from app.utils import is_title
|
|
@ -1,9 +0,0 @@
|
|||
from app import app
|
||||
|
||||
@app.template_filter('date')
|
||||
def filter_date(date, format="%Y-%m-%d à %H:%M"):
|
||||
"""
|
||||
Print a date in a human-readable format.
|
||||
"""
|
||||
|
||||
return date.strftime(format)
|
9
app/utils/filesize.py
Normal file
9
app/utils/filesize.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
import os
|
||||
from random import getrandbits
|
||||
|
||||
def filesize(file):
|
||||
"""Return the filesize. Save in /tmp and delete it when done"""
|
||||
file.seek(0, os.SEEK_END)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
return size
|
3
app/utils/filters/__init__.py
Normal file
3
app/utils/filters/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Register filters here
|
||||
|
||||
from app.utils.filters import date, is_title, pluralize
|
26
app/utils/filters/date.py
Normal file
26
app/utils/filters/date.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from app import app
|
||||
from datetime import datetime
|
||||
|
||||
@app.template_filter('date')
|
||||
def filter_date(date, format="%Y-%m-%d à %H:%M"):
|
||||
"""
|
||||
Print a date in a human-readable format.
|
||||
"""
|
||||
|
||||
if format == "dynamic":
|
||||
d = "1er" if date.day == 1 else int(date.day)
|
||||
m = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet",
|
||||
"Août", "Septembre", "Octobre", "Novembre","Décembre"] \
|
||||
[date.month - 1]
|
||||
|
||||
# Omit current year in the dynamic format
|
||||
if date.year == datetime.now().year:
|
||||
format = f"{d} {m} à %H:%M"
|
||||
else:
|
||||
format = f"{d} {m} %Y à %H:%M"
|
||||
|
||||
return date.strftime(format)
|
||||
|
||||
@app.template_filter('dyndate')
|
||||
def filter_dyndate(date):
|
||||
return filter_date(date, format="dynamic")
|
|
@ -8,8 +8,12 @@ from app.utils.unicode_names import normalize
|
|||
from math import log
|
||||
from werkzeug.exceptions import NotFound
|
||||
import app.utils.ldap as ldap
|
||||
|
||||
from config import V5Config
|
||||
|
||||
from app.utils.validators.file import *
|
||||
|
||||
# TODO: clean this shit into split files
|
||||
|
||||
def name_valid(form, name):
|
||||
valid = valid_name(name.data)
|
||||
|
@ -35,7 +39,6 @@ def name_valid(form, name):
|
|||
err = ' '.join(msg.get(code, default) for code in valid)
|
||||
raise ValidationError(err)
|
||||
|
||||
|
||||
def name_available(form, name):
|
||||
# If the name is invalid, name_valid() will return a meaningful message
|
||||
try:
|
||||
|
@ -53,14 +56,11 @@ def name_available(form, name):
|
|||
if member is not None:
|
||||
raise ValidationError("Ce nom d'utilisateur est indisponible.")
|
||||
|
||||
|
||||
|
||||
def email(form, email):
|
||||
member = Member.query.filter_by(email=email.data).first()
|
||||
if member is not None:
|
||||
raise ValidationError('Adresse email déjà utilisée.')
|
||||
|
||||
|
||||
def password(form, password):
|
||||
# To avoid errors in forms where password is optionnal
|
||||
if len(password.data) == 0:
|
||||
|
@ -87,14 +87,12 @@ def password(form, password):
|
|||
if entropy(password.data) < 60:
|
||||
raise ValidationError("Mot de passe pas assez complexe")
|
||||
|
||||
|
||||
def avatar(form, avatar):
|
||||
try:
|
||||
Image.open(avatar.data)
|
||||
except IOError:
|
||||
raise ValidationError("Avatar invalide")
|
||||
|
||||
|
||||
def old_password(form, field):
|
||||
if field.data:
|
||||
if not form.old_password.data:
|
||||
|
@ -102,7 +100,6 @@ def old_password(form, field):
|
|||
if not current_user.check_password(form.old_password.data):
|
||||
raise ValidationError('Mot de passe actuel erroné.')
|
||||
|
||||
|
||||
def id_exists(object):
|
||||
"""Check if an id exists in a table"""
|
||||
def _id_exists(form, id):
|
||||
|
@ -115,7 +112,6 @@ def id_exists(object):
|
|||
raise ValidationError('L\'id n\'existe pas dans la BDD')
|
||||
return _id_exists
|
||||
|
||||
|
||||
def css(form, css):
|
||||
"""Check if input is valid and sane CSS"""
|
||||
pass
|
51
app/utils/validators/file.py
Normal file
51
app/utils/validators/file.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
from flask_login import current_user
|
||||
from wtforms.validators import ValidationError, StopValidation
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.utils.filesize import filesize
|
||||
import re
|
||||
|
||||
def optional(form, files):
|
||||
if(len(files.data) == 0 or files.data[0].filename == ""):
|
||||
raise StopValidation()
|
||||
|
||||
def count(form, files):
|
||||
if current_user.is_authenticated:
|
||||
if current_user.priv("no-upload-limits"):
|
||||
return
|
||||
if len(files.data) > 100: # 100 files for a authenticated user
|
||||
raise ValidationError("100 fichiers maximum autorisés")
|
||||
else:
|
||||
if len(files.data) > 3:
|
||||
raise ValidationError("3 fichiers maximum autorisés")
|
||||
|
||||
def extension(form, files):
|
||||
valid_extensions = [
|
||||
"g[123][a-z]|cpa|c1a|fxi|cat|mcs|xcp|fls", # Casio files
|
||||
"png|jpg|jpeg|bmp|tiff|gif|xcf", # Images
|
||||
"[ch](pp|\+\+|xx)?|s|py|bide|lua|lc", # Source code
|
||||
"txt|md|tex|pdf|odt|ods|docx|xlsx", # Office files
|
||||
"zip|7z|tar|bz2?|t?gz|xz|zst", # Archives
|
||||
]
|
||||
r = re.compile("|".join(valid_extensions), re.IGNORECASE)
|
||||
errors = []
|
||||
|
||||
for f in files.data:
|
||||
name = secure_filename(f.filename)
|
||||
ext = name.split(".")[-1]
|
||||
if not r.fullmatch(ext):
|
||||
errors.append("." + ext)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(f"Extension(s) invalide(s) ({', '.join(errors)})")
|
||||
|
||||
def size(form, files):
|
||||
"""There is no global limit to file sizes"""
|
||||
size = sum([filesize(f) for f in files.data])
|
||||
if current_user.is_authenticated:
|
||||
if current_user.priv("no-upload-limits"):
|
||||
return
|
||||
if size > 5e6: # 5 Mo per comment for an authenticated user
|
||||
raise ValidationError("Fichiers trop lourds (max 5 Mo)")
|
||||
else:
|
||||
if size > 500e3: # 500 ko per comment for a guest
|
||||
raise ValidationError("Fichiers trop lourds (max 500 ko)")
|
|
@ -0,0 +1,35 @@
|
|||
"""add a base model for programs
|
||||
|
||||
Revision ID: c5561fa6af4e
|
||||
Revises: c7779a558510
|
||||
Create Date: 2020-08-01 14:52:52.878440
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c5561fa6af4e'
|
||||
down_revision = 'c7779a558510'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('program',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.Unicode(length=128), nullable=True),
|
||||
sa.Column('thread_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['id'], ['post.id'], ),
|
||||
sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('program')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,30 @@
|
|||
"""add promotion information to topics
|
||||
|
||||
Revision ID: c7779a558510
|
||||
Revises: 001d2eaf0413
|
||||
Create Date: 2020-08-01 11:27:23.298821
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c7779a558510'
|
||||
down_revision = '001d2eaf0413'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('topic', sa.Column('promotion_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'topic', 'post', ['promotion_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'topic', type_='foreignkey')
|
||||
op.drop_column('topic', 'promotion_id')
|
||||
# ### end Alembic commands ###
|
35
migrations/versions/cd4868f312c5_added_attachments.py
Normal file
35
migrations/versions/cd4868f312c5_added_attachments.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""Added attachments
|
||||
|
||||
Revision ID: cd4868f312c5
|
||||
Revises: 001d2eaf0413
|
||||
Create Date: 2020-08-01 19:22:12.405038
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'cd4868f312c5'
|
||||
down_revision = 'c5561fa6af4e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('attachment',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.Unicode(length=64), nullable=True),
|
||||
sa.Column('comment_id', sa.Integer(), nullable=False),
|
||||
sa.Column('size', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('attachment')
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in a new issue