From 8a0ba309e0d40d18da340fbf69d8f03e5ab105ba Mon Sep 17 00:00:00 2001 From: Lephe Date: Sun, 8 Sep 2019 14:34:51 +0200 Subject: [PATCH] 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. --- app/data/forums.yaml | 4 ++ app/forms/editor.py | 15 +++++ app/forms/forum.py | 9 +++ app/models/comment.py | 32 +++++++--- app/models/post.py | 61 +++++++++++-------- app/models/thread.py | 53 ++++++++-------- app/models/topic.py | 42 +++++++++---- app/models/users.py | 3 +- app/routes/forum/index.py | 42 +++++++++++-- app/static/css/form.css | 7 +++ app/templates/admin/forums.html | 4 +- app/templates/base/navbar/news.html | 14 +++-- app/templates/forum/forum.html | 29 +++++++++ app/templates/widgets/editor.html | 10 +++ app/utils/converters.py | 3 +- .../2a1165f6ad0a_restructure_forum_models.py | 42 +++++++++++++ .../2dbb614a7236_remove_thread_table.py | 32 ++++++++++ .../4e05b43b18b1_recreate_thread_table.py | 33 ++++++++++ ...abd5f8de0106_recreate_thread_references.py | 34 +++++++++++ .../b890d9bb207b_remove_thread_references.py | 34 +++++++++++ 20 files changed, 417 insertions(+), 86 deletions(-) create mode 100644 app/forms/editor.py create mode 100644 app/forms/forum.py create mode 100644 app/templates/widgets/editor.html create mode 100644 migrations/versions/2a1165f6ad0a_restructure_forum_models.py create mode 100644 migrations/versions/2dbb614a7236_remove_thread_table.py create mode 100644 migrations/versions/4e05b43b18b1_recreate_thread_table.py create mode 100644 migrations/versions/abd5f8de0106_recreate_thread_references.py create mode 100644 migrations/versions/b890d9bb207b_remove_thread_references.py diff --git a/app/data/forums.yaml b/app/data/forums.yaml index 18d0a64..a0f6b1d 100644 --- a/app/data/forums.yaml +++ b/app/data/forums.yaml @@ -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 diff --git a/app/forms/editor.py b/app/forms/editor.py new file mode 100644 index 0000000..8e7eb78 --- /dev/null +++ b/app/forms/editor.py @@ -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 diff --git a/app/forms/forum.py b/app/forms/forum.py new file mode 100644 index 0000000..c5d2a80 --- /dev/null +++ b/app/forms/forum.py @@ -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') diff --git a/app/models/comment.py b/app/models/comment.py index 4b7fbd1..0889a21 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -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'' diff --git a/app/models/post.py b/app/models/post.py index 0a033c0..eef1887 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -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'' diff --git a/app/models/thread.py b/app/models/thread.py index 4f6bc4c..ab3eb74 100644 --- a/app/models/thread.py +++ b/app/models/thread.py @@ -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'' diff --git a/app/models/topic.py b/app/models/topic.py index 8e1f0fd..16302f4 100644 --- a/app/models/topic.py +++ b/app/models/topic.py @@ -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'' diff --git a/app/models/users.py b/app/models/users.py index 21d65c2..55516dd 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -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__, diff --git a/app/routes/forum/index.py b/app/routes/forum/index.py index 72ee06c..1834164 100644 --- a/app/routes/forum/index.py +++ b/app/routes/forum/index.py @@ -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/') +@app.route('/forum//', 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//') +def forum_topic(f, t): + return render('/forum/topic.html', f=f, t=t) diff --git a/app/static/css/form.css b/app/static/css/form.css index 9747677..ddd0fd4 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -86,3 +86,10 @@ .trophies-panel p label { margin: 0; } + +/* Editor */ + +.editor textarea { + font-family: monospace; + height: 192px; +} diff --git a/app/templates/admin/forums.html b/app/templates/admin/forums.html index c8bdf7d..3d8848a 100644 --- a/app/templates/admin/forums.html +++ b/app/templates/admin/forums.html @@ -4,7 +4,9 @@ {% macro forumtree(f, level) %} {{ f.url }} - {{ f.name }} + + {{ f.name }} + {{ f.topics | length }} {{ f.post_count() }} diff --git a/app/templates/base/navbar/news.html b/app/templates/base/navbar/news.html index b81a7cc..1bff05e 100644 --- a/app/templates/base/navbar/news.html +++ b/app/templates/base/navbar/news.html @@ -5,12 +5,16 @@ Actualités - Casio - Arduino - Projets communautaires - Divers + Toutes les nouveautés -
+
+ + Nouveautés Casio + Projets communutaires + Événements de Planète Casio + Autres nouveautés + +

Derniers articles

    diff --git a/app/templates/forum/forum.html b/app/templates/forum/forum.html index 6f675d7..e70c299 100644 --- a/app/templates/forum/forum.html +++ b/app/templates/forum/forum.html @@ -1,4 +1,5 @@ {% extends "base/base.html" %} +{% import "widgets/editor.html" as widget_editor %} {% block title %} Forum de Planète Casio »

    {{ f.name }}

    @@ -7,5 +8,33 @@ {% block content %}
    {{ f.descr }} + + {% if f.topics %} +
      + {% for t in f.topics %} +
    • {{ t.title }}
    • + {% endfor %} +
    + {% endif %} +
    + +

    Créer un nouveau sujet

    + +
    + {{ form.hidden_tag() }} + +
    + {{ form.title.label }} + {{ form.title() }} + {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
    + + {{ widget_editor.editor(form.message) }} + +
    {{ form.submit(class_='bg-green') }}
    +
    +
    {% endblock %} diff --git a/app/templates/widgets/editor.html b/app/templates/widgets/editor.html new file mode 100644 index 0000000..2d4d911 --- /dev/null +++ b/app/templates/widgets/editor.html @@ -0,0 +1,10 @@ +{% macro editor(form) %} +
    + {{ form.hidden_tag() }} + {{ form.label }} + {{ form.contents() }} + {% for error in form.contents.errors %} + {{ error }} + {% endfor %} +
    +{% endmacro %} diff --git a/app/utils/converters.py b/app/utils/converters.py index 7ebf4b0..001d2b4 100644 --- a/app/utils/converters.py +++ b/app/utils/converters.py @@ -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): diff --git a/migrations/versions/2a1165f6ad0a_restructure_forum_models.py b/migrations/versions/2a1165f6ad0a_restructure_forum_models.py new file mode 100644 index 0000000..88f12d5 --- /dev/null +++ b/migrations/versions/2a1165f6ad0a_restructure_forum_models.py @@ -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 ### diff --git a/migrations/versions/2dbb614a7236_remove_thread_table.py b/migrations/versions/2dbb614a7236_remove_thread_table.py new file mode 100644 index 0000000..e22c0dd --- /dev/null +++ b/migrations/versions/2dbb614a7236_remove_thread_table.py @@ -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 ### diff --git a/migrations/versions/4e05b43b18b1_recreate_thread_table.py b/migrations/versions/4e05b43b18b1_recreate_thread_table.py new file mode 100644 index 0000000..9179829 --- /dev/null +++ b/migrations/versions/4e05b43b18b1_recreate_thread_table.py @@ -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 ### diff --git a/migrations/versions/abd5f8de0106_recreate_thread_references.py b/migrations/versions/abd5f8de0106_recreate_thread_references.py new file mode 100644 index 0000000..a01e686 --- /dev/null +++ b/migrations/versions/abd5f8de0106_recreate_thread_references.py @@ -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 ### diff --git a/migrations/versions/b890d9bb207b_remove_thread_references.py b/migrations/versions/b890d9bb207b_remove_thread_references.py new file mode 100644 index 0000000..24fa5a2 --- /dev/null +++ b/migrations/versions/b890d9bb207b_remove_thread_references.py @@ -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 ###