forum: restructure models and add topic creation

This changes fixes #25 by restructuring the forum models in a way
compatible with the polymorphic behavior of SQLAlchemy. Incidentally,
the new form turns out to be more appropriate for our use than the
polymorphic one originally used.

The migration for this task is non-trivial because the Thread class was
created with a foreign-key id which thus had no auto-increment or
associated sequence. The most reliable way of getting it back was to
recreate the table because SQLAlchemy ony performs automated sequence
introduction at table creation time. Four separate migration files
perform the whole change.

This commit also adds views and forms to create topics, and the
boilerplate for an advanced markup editor that can be used as a widget.
This commit is contained in:
Lephe 2019-09-08 14:34:51 +02:00
parent d1a8333cae
commit 8a0ba309e0
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
20 changed files with 417 additions and 86 deletions

View File

@ -74,10 +74,14 @@
/projects/games:
name: Projets de jeux
prefix: gameprojects
descr: Projets de jeux pour calculatrices, tous langages confondus et tous
modèles de calculatrices confondus.
/projects/apps:
name: Projets d'applications, utilitaires, outils pour calculatrice
prefix: appprojects
descr: Projets d'applications (hors jeux) pour calculatrice, tous langages et
modèles confondus.
/projects/tools:
name: Projets pour d'autres plateformes

15
app/forms/editor.py Normal file
View File

@ -0,0 +1,15 @@
from flask_wtf import FlaskForm
from wtforms import TextAreaField
class EditorForm(FlaskForm):
"""
A text editor with formatting buttons and help. A rendering macro is
defined in the template widgets/editor.html.
"""
# TODO: How to set DataRequired() dynamically?
contents = TextAreaField()
@property
def value(self):
return self.contents.data

9
app/forms/forum.py Normal file
View File

@ -0,0 +1,9 @@
from flask_wtf import FlaskForm
from wtforms import StringField, FormField, SubmitField
from wtforms.validators import DataRequired
from app.forms.editor import EditorForm
class TopicCreationForm(FlaskForm):
title = StringField('Nom du sujet', validators=[DataRequired()])
message = FormField(EditorForm, 'Premier post')
submit = SubmitField('Créer le sujet')

View File

@ -5,22 +5,36 @@ class Comment(Post):
__tablename__ = 'comment'
__mapper_args__ = {'polymorphic_identity': __tablename__}
# ID of the associated Post object
# ID of the underlying Post object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Standalone properties
# Comment contents
text = db.Column(db.UnicodeText)
# Relations
# Parent thread
thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'),
nullable=False)
thread = db.relationship('Thread', backref='comments',
foreign_keys=thread_id)
# attachement = db.relationship('Attachement', backref='comment')
def __init__(self, author, text, thread):
Post.__init__(author, text)
if isinstance(thread, Thread):
thread = thread.id
self.thread_id = thread
"""
Create a new Comment in a thread.
Arguments:
author -- comment poster (User)
text -- contents (unicode string)
thread -- parent discussion thread (Thread)
"""
Post.__init__(self, author)
self.thread = thread
def edit(self, new_text):
"""Edit a Comment's contents."""
self.text = new_text
self.touch()
def __repr__(self):
return f'<Comment: #{self.id}>'

View File

@ -1,47 +1,58 @@
from datetime import datetime
from app import db
from app.models.users import User
from datetime import datetime
class Post(db.Model):
""" Content a User can create and publish """
__tablename__ = 'post'
id = db.Column(db.Integer, primary_key=True)
"""Contents created and published by Users."""
__tablename__ = 'post'
# Unique Post ID for the whole site
id = db.Column(db.Integer, primary_key=True)
# Post type (polymorphic discriminator)
type = db.Column(db.String(20))
# Creation and edition date
date_created = db.Column(db.DateTime)
date_modified = db.Column(db.DateTime)
# Post author
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
author = db.relationship('User', backref="posts",foreign_keys=author_id)
# TODO: Post attachments?
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on': type
}
# Standalone properties
date_created = db.Column(db.DateTime)
date_modified = db.Column(db.DateTime)
def __init__(self, author):
"""
Create a new Post.
# Relationships
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
Arguments:
author -- post author (User)
"""
def __init__(self, author, text):
""" Create a Post """
self.text = text
if isinstance(author, User):
author = author.id
self.author_id = author
self.author = author
self.date_created = datetime.now()
self.date_modified = datetime.now()
def update(self, text):
""" Update a post. Check whether the request sender has the right to do
this! """
self.text = text
def touch(self):
"""Touch a Post when it is edited."""
self.date_modified = datetime.now()
def change_ownership(self, new_author):
""" Change ownership of a post. Check whether the request sender has the
right to do this! """
if isinstance(new_author, User):
new_author = new_author.id
self.author_id = new_author
"""
Change ownership of a Post. This is a privileged operation!
Arguments:
new_author -- new post author (User)
"""
self.author = new_author
def __repr__(self):
return f'<Post: #{self.id}>'

View File

@ -1,35 +1,40 @@
from app import db
from app.models.post import Post
from app.models.comment import Comment
from config import V5Config
class Thread(Post):
""" Some thread, such as a topic, program, tutorial """
class Thread(db.Model):
"""Some thread, such as a topic, program, tutorial."""
# Foreign Post object ID
__tablename__ = 'thread'
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Identify threads as a type of posts using the table name, and add a
# column to further discriminate types of threads
thread_type = db.Column(db.String(20))
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on': thread_type
}
# Unique ID
id = db.Column(db.Integer, primary_key=True)
# Properties
title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN))
# Also a relation [comments] populated from the Comment class.
# Relations
# Top comment
top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
top_comment = db.relationship('Comment', foreign_keys=top_comment_id)
def __init__(self, author, text, title):
""" Create a Thread """
Post.__init__(author, text)
self.title = title
# Also a relation [comments] populated from the Comment class.
def __init__(self):
"""
Create a empty Thread. Normally threads are not meant to be empty, so
you should create a Comment with this thread as parent, then assign it
as top comment with a call to set_top_comment().
"""
self.top_comment_id = None
def set_top_comment(self, top_comment):
"""
Changes the top comment of the thread. The old top comment will become
visible in the flow of posts?
Arguments:
top_comment -- new top comment, must belong to this thread
"""
if top_comment not in self.comments:
raise Exception("Cannot set foreign comment as top thread comment")
self.top_comment = top_comment
def __repr__(self):
return f'<Thread #{self.id}'
return f'<Thread: #{self.id}>'

View File

@ -1,22 +1,40 @@
from app import db
from app.models.thread import Thread
from app.models.post import Post
from config import V5Config
class Topic(Thread):
class Topic(Post):
__tablename__ = 'topic'
id = db.Column(db.Integer, db.ForeignKey('thread.id'), primary_key=True)
__mapper_args__ = {'polymorphic_identity': __tablename__}
# Relationships
# ID of the underlying [Post] object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Topic title
title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN))
# Parent forum
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False)
forum = db.relationship('Forum', backref='topics',foreign_keys=forum_id)
def __init__(self, author, text, title, forum):
""" Create a Topic """
Post.__init__(author, text, title)
if isinstance(forum, Forum):
forum = forum.id
self.forum_id = forum
# Associated thread
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
thread = db.relationship('Thread', foreign_keys=thread_id)
def __init__(self, forum, author, title, thread):
"""
Create a Topic.
Arguments:
forum -- parent forum or sub-forum (Forum)
author -- post author (User)
title -- topic title (unicode string)
thread -- discussion thread attached to the topic (Thread)
"""
Post.__init__(self, author)
self.title = title
self.thread = thread
self.forum = forum
def __repr__(self):
return f'<Topic #{self.id}'
return f'<Topic: #{self.id}>'

View File

@ -27,8 +27,7 @@ class User(UserMixin, db.Model):
# User type (polymorphic discriminator)
type = db.Column(db.String(30))
# TODO: add good relation
posts = db.relationship('Post', backref="author", lazy=True)
# Also a [posts] relationship populated from the Post class.
__mapper_args__ = {
'polymorphic_identity': __tablename__,

View File

@ -1,12 +1,42 @@
from app.utils.render import render
from app.models.forum import Forum
from app import app
from flask_login import current_user
from flask import request
@app.route('/forum')
from app.utils.render import render
from app.forms.forum import TopicCreationForm
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 import app, db
@app.route('/forum/')
def forum_index():
main_forum = Forum.query.filter_by(parent=None).first()
return render('/forum/index.html', main_forum=main_forum)
@app.route('/forum/<forum:f>')
@app.route('/forum/<forum:f>/', methods=['GET', 'POST'])
def forum_page(f):
return render('/forum/forum.html', f=f)
form = TopicCreationForm()
if form.validate_on_submit():
# TODO: Check user privileges for this specific forum!
# First create the thread, then the comment, then the topic
th = Thread()
db.session.add(th)
db.session.commit()
c = Comment(current_user, form.message.value, th)
th.set_top_comment(c)
t = Topic(f, current_user, form.title.data, th)
db.session.add(th)
db.session.add(c)
db.session.add(t)
db.session.commit()
return render('/forum/forum.html', f=f, form=form)
@app.route('/forum/<forum:f>/<topicslug:t>')
def forum_topic(f, t):
return render('/forum/topic.html', f=f, t=t)

View File

@ -86,3 +86,10 @@
.trophies-panel p label {
margin: 0;
}
/* Editor */
.editor textarea {
font-family: monospace;
height: 192px;
}

View File

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

View File

@ -5,12 +5,16 @@
</svg>
Actualités
</h2>
<a href="#">Casio</a>
<a href="#">Arduino</a>
<a href="#">Projets communautaires</a>
<a href="#">Divers</a>
<a href='/forum/news'>Toutes les nouveautés</a>
<hr />
<hr>
<a href='/forum/news/calc'>Nouveautés Casio</a>
<a href='/forum/news/projects'>Projets communutaires</a>
<a href='/forum/news/events'>Événements de Planète Casio</a>
<a href='/forum/news/other'>Autres nouveautés</a>
<hr>
<h3>Derniers articles</h3>
<ul>

View File

@ -1,4 +1,5 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <h1>{{ f.name }}</h1>
@ -7,5 +8,33 @@
{% block content %}
<section>
{{ f.descr }}
{% if f.topics %}
<ul>
{% for t in f.topics %}
<li>{{ t.title }}</li>
{% endfor %}
</ul>
{% endif %}
<div class=form>
<h2>Créer un nouveau sujet</h2>
<form action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div>
{{ form.title.label }}
{{ form.title() }}
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
{{ widget_editor.editor(form.message) }}
<div>{{ form.submit(class_='bg-green') }}</div>
</form>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% macro editor(form) %}
<div class=editor>
{{ form.hidden_tag() }}
{{ form.label }}
{{ form.contents() }}
{% for error in form.contents.errors %}
<span class=msgerror>{{ error }}</span>
{% endfor %}
</div>
{% endmacro %}

View File

@ -28,7 +28,6 @@ class ForumConverter(BaseConverter):
regex = r'[a-z/]+'
def to_python(self, url):
print(f"ForumConverter url from '{url}'", file=sys.stderr)
url = '/' + url
f = Forum.query.filter_by(url=url).first()
if f is None:
@ -36,7 +35,7 @@ class ForumConverter(BaseConverter):
return f
def to_url(self, forum):
return forum.url
return forum.url[1:]
class TopicSlugConverter(BaseConverter):

View File

@ -0,0 +1,42 @@
"""restructure forum models
Revision ID: 2a1165f6ad0a
Revises: a3fb8937ae16
Create Date: 2019-09-07 19:38:57.472404
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2a1165f6ad0a'
down_revision = 'a3fb8937ae16'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('thread_id_fkey', 'thread', type_='foreignkey')
op.drop_column('thread', 'title')
op.drop_column('thread', 'thread_type')
op.add_column('topic', sa.Column('thread_id', sa.Integer(), nullable=False))
op.add_column('topic', sa.Column('title', sa.Unicode(length=32), nullable=True))
op.drop_constraint('topic_id_fkey', 'topic', type_='foreignkey')
op.create_foreign_key(None, 'topic', 'post', ['id'], ['id'])
op.create_foreign_key(None, 'topic', 'thread', ['thread_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'topic', type_='foreignkey')
op.drop_constraint(None, 'topic', type_='foreignkey')
op.create_foreign_key('topic_id_fkey', 'topic', 'thread', ['id'], ['id'])
op.drop_column('topic', 'title')
op.drop_column('topic', 'thread_id')
op.add_column('thread', sa.Column('thread_type', sa.VARCHAR(length=20), autoincrement=False, nullable=True))
op.add_column('thread', sa.Column('title', sa.VARCHAR(length=32), autoincrement=False, nullable=True))
op.create_foreign_key('thread_id_fkey', 'thread', 'post', ['id'], ['id'])
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Remove Thread table
Revision ID: 2dbb614a7236
Revises: b890d9bb207b
Create Date: 2019-09-08 12:32:58.869143
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2dbb614a7236'
down_revision = 'b890d9bb207b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('thread')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('thread',
sa.Column('top_comment_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['top_comment_id'], ['comment.id'], name='thread_top_comment_id_fkey')
)
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""Recreate Thread table
Revision ID: 4e05b43b18b1
Revises: 2dbb614a7236
Create Date: 2019-09-08 12:33:30.647277
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e05b43b18b1'
down_revision = '2dbb614a7236'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('thread',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('top_comment_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['top_comment_id'], ['comment.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('thread')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Recreate Thread references
Revision ID: abd5f8de0106
Revises: 4e05b43b18b1
Create Date: 2019-09-08 12:34:12.574672
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'abd5f8de0106'
down_revision = '4e05b43b18b1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('comment', sa.Column('thread_id', sa.Integer(), nullable=False))
op.create_foreign_key(None, 'comment', 'thread', ['thread_id'], ['id'])
op.add_column('topic', sa.Column('thread_id', sa.Integer(), nullable=False))
op.create_foreign_key(None, 'topic', 'thread', ['thread_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', 'thread_id')
op.drop_constraint(None, 'comment', type_='foreignkey')
op.drop_column('comment', 'thread_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Remove Thread references
Revision ID: b890d9bb207b
Revises: 2a1165f6ad0a
Create Date: 2019-09-08 12:29:38.650491
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b890d9bb207b'
down_revision = '2a1165f6ad0a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('comment_thread_id_fkey', 'comment', type_='foreignkey')
op.drop_column('comment', 'thread_id')
op.drop_constraint('topic_thread_id_fkey', 'topic', type_='foreignkey')
op.drop_column('topic', 'thread_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('topic', sa.Column('thread_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.create_foreign_key('topic_thread_id_fkey', 'topic', 'thread', ['thread_id'], ['id'])
op.add_column('comment', sa.Column('thread_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.create_foreign_key('comment_thread_id_fkey', 'comment', 'thread', ['thread_id'], ['id'])
# ### end Alembic commands ###