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