Browse Source

Chapter 13: Blog post comments (13a)

T. Meissner 2 months ago
parent
commit
889522d9e0

+ 5
- 0
app/main/forms.py View File

@@ -54,3 +54,8 @@ class EditProfileAdminForm(FlaskForm):
54 54
 class PostForm(FlaskForm):
55 55
     body = PageDownField("What's on your mind?", validators=[DataRequired()])
56 56
     submit = SubmitField('Submit')
57
+
58
+
59
+class CommentForm(FlaskForm):
60
+    body = StringField('', validators=[DataRequired()])
61
+    submit = SubmitField('Submit')

+ 22
- 4
app/main/views.py View File

@@ -2,9 +2,9 @@ from flask import render_template, redirect, url_for, flash, request, \
2 2
     current_app, abort, make_response
3 3
 from flask_login import login_required, current_user
4 4
 from . import main
5
-from .forms import EditProfileForm, EditProfileAdminForm, PostForm
5
+from .forms import EditProfileForm, EditProfileAdminForm, PostForm, CommentForm
6 6
 from .. import db
7
-from ..models import User, Role, Permission, Post
7
+from ..models import User, Role, Permission, Post, Comment
8 8
 from ..decorators import admin_required, permission_required
9 9
 
10 10
 
@@ -91,10 +91,28 @@ def edit_profile_admin(id):
91 91
     return render_template('edit_profile.html', form=form, user=user)
92 92
 
93 93
 
94
-@main.route('/post/<int:id>')
94
+@main.route('/post/<int:id>', methods=['GET', 'POST'])
95 95
 def post(id):
96 96
     post = Post.query.get_or_404(id)
97
-    return render_template('post.html', posts=[post])
97
+    form = CommentForm()
98
+    if form.validate_on_submit():
99
+        comment = Comment(body=form.body.data,
100
+                          post=post,
101
+                          author=current_user._get_current_object())
102
+        db.session.add(comment)
103
+        db.session.commit()
104
+        flash('Your comment has been published.')
105
+        return redirect(url_for('.post', id=post.id, page=-1))
106
+    page = request.args.get('page', 1, type=int)
107
+    if page == -1:
108
+        page = (post.comments.count() - 1) // \
109
+               current_app.config['FLASKY_COMMENTS_PER_PAGE'] + 1
110
+    pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(
111
+        page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'],
112
+        error_out=False)
113
+    comments = pagination.items
114
+    return render_template('post.html', posts=[post], form=form,
115
+                           comments=comments, pagination=pagination)
98 116
 
99 117
 
100 118
 @main.route('/edit/<int:id>', methods=['GET', 'POST'])

+ 24
- 0
app/models.py View File

@@ -105,6 +105,7 @@ class User(UserMixin, db.Model):
105 105
                                 backref=db.backref('followed', lazy='joined'),
106 106
                                 lazy='dynamic',
107 107
                                 cascade='all, delete-orphan')
108
+    comments = db.relationship('Comment', backref='author', lazy='dynamic')
108 109
 
109 110
     def __init__(self, **kwargs):
110 111
         super(User, self).__init__(**kwargs)
@@ -251,6 +252,7 @@ class Post(db.Model):
251 252
     body_html = db.Column(db.Text)
252 253
     timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
253 254
     author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
255
+    comments = db.relationship('Comment', backref='post', lazy='dynamic')
254 256
 
255 257
     @staticmethod
256 258
     def on_changed_body(target, value, oldvalue, initiator):
@@ -265,6 +267,28 @@ class Post(db.Model):
265 267
 db.event.listen(Post.body, 'set', Post.on_changed_body)
266 268
 
267 269
 
270
+class Comment(db.Model):
271
+    __tablename__ = 'comments'
272
+    id = db.Column(db.Integer, primary_key=True)
273
+    body = db.Column(db.Text)
274
+    body_html = db.Column(db.Text)
275
+    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
276
+    disabled = db.Column(db.Boolean)
277
+    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
278
+    post_id = db.Column(db.Integer, db.ForeignKey('posts.id'))
279
+
280
+    @staticmethod
281
+    def on_changed_body(target, value, oldvalue, initiator):
282
+        allowed_tags = ['a', 'abbr', 'acronym', 'b', 'code', 'em', 'i',
283
+                        'strong']
284
+        md = markdown(value, output_format='html')
285
+        clean_md = bleach.clean(md, tags=allowed_tags, strip=True)
286
+        target.body_html = bleach.linkify(clean_md)
287
+
288
+
289
+db.event.listen(Comment.body, 'set', Comment.on_changed_body)
290
+
291
+
268 292
 class AnonymousUser(AnonymousUserMixin):
269 293
     def can(self, perm):
270 294
         return False

+ 32
- 0
app/static/styles.css View File

@@ -41,6 +41,38 @@ div.post-content {
41 41
 div.post-footer {
42 42
     text-align: right;
43 43
 }
44
+ul.comments {
45
+    list-style-type: none;
46
+    padding: 0px;
47
+    margin: 16px 0px 0px 0px;
48
+}
49
+ul.comments li.comment {
50
+    margin-left: 32px;
51
+    padding: 8px;
52
+    border-bottom: 1px solid #e0e0e0;
53
+}
54
+ul.comments li.comment:nth-child(1) {
55
+    border-top: 1px solid #e0e0e0;
56
+}
57
+ul.comments li.comment:hover {
58
+    background-color: #f0f0f0;
59
+}
60
+div.comment-date {
61
+    float: right;
62
+}
63
+div.comment-author {
64
+    font-weight: bold;
65
+}
66
+div.comment-thumbnail {
67
+    position: absolute;
68
+}
69
+div.comment-content {
70
+    margin-left: 48px;
71
+    min-height: 48px;
72
+}
73
+div.comment-form {
74
+    margin: 16px 0px 16px 32px;
75
+}
44 76
 div.pagination {
45 77
     width: 100%;
46 78
     text-align: center;

+ 22
- 0
app/templates/_comments.html View File

@@ -0,0 +1,22 @@
1
+<ul class="comments">
2
+    {% for comment in comments %}
3
+    <li class="comment">
4
+        <div class="comment-thumbnail">
5
+            <a href="{{ url_for('.user', username=comment.author.username) }}">
6
+                <img class="img-rounded profile-thumbnail" src="{{ comment.author.gravatar(size=40) }}">
7
+            </a>
8
+        </div>
9
+        <div class="comment-content">
10
+            <div class="comment-date">{{ moment(comment.timestamp).fromNow() }}</div>
11
+            <div class="comment-author"><a href="{{ url_for('.user', username=comment.author.username) }}">{{ comment.author.username }}</a></div>
12
+            <div class="comemnt-body">
13
+            {% if comment.body_html %}
14
+                {{ comment.body_html | safe }}
15
+            {% else %}
16
+                {{ comment.body }}
17
+            {% endif %}
18
+            </div>
19
+        </div>
20
+    </li>
21
+    {% endfor %}
22
+</ul>

+ 5
- 5
app/templates/_macros.html View File

@@ -1,7 +1,7 @@
1
-{% macro pagination_widget(pagination, endpoint) %}
1
+{% macro pagination_widget(pagination, endpoint, fragment='') %}
2 2
 <ul class="pagination">
3 3
     <li{% if not pagination.has_prev %} class="disabled"{% endif %}>
4
-        <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}">
4
+        <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}">
5 5
             &laquo;
6 6
         </a>
7 7
     </li>
@@ -9,11 +9,11 @@
9 9
         {% if p %}
10 10
             {% if p == pagination.page %}
11 11
             <li class="active">
12
-                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
12
+                <a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a>
13 13
             </li>
14 14
             {% else %}
15 15
             <li>
16
-                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
16
+                <a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a>
17 17
             </li>
18 18
             {% endif %}
19 19
         {% else %}
@@ -21,7 +21,7 @@
21 21
         {% endif %}
22 22
     {% endfor %}
23 23
     <li{% if not pagination.has_next %} class="disabled"{% endif %}>
24
-        <a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}">
24
+        <a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}">
25 25
             &raquo;
26 26
         </a>
27 27
     </li>

+ 3
- 0
app/templates/_posts.html View File

@@ -29,6 +29,9 @@
29 29
                 <a href="{{ url_for('.post', id=post.id) }}">
30 30
                     <span class="label label-default">Permalink</span>
31 31
                 </a>
32
+                <a href="{{ url_for('.post', id=post.id) }}#comments">
33
+                    <span class="label label-primary">{{ post.comments.count() }} Comments</span>
34
+                </a>
32 35
             </div>
33 36
         </div>
34 37
     </li>

+ 13
- 0
app/templates/post.html View File

@@ -1,8 +1,21 @@
1 1
 {% extends "base.html" %}
2
+{% import "bootstrap/wtf.html" as wtf %}
2 3
 {% import "_macros.html" as macros %}
3 4
 
4 5
 {% block title %}Flasky{% endblock %}
5 6
 
6 7
 {% block page_content %}
7 8
 {% include '_posts.html' %}
9
+<h4 id="comments">Comments</h4>
10
+{% if current_user.can(Permission.COMMENT) %}
11
+<div class="comment-form">
12
+    {{ wtf.quick_form(form) }}  
13
+</div>
14
+{% endif %}
15
+{% include '_comments.html' %}
16
+{% if pagination %}
17
+ <div class="pagination">
18
+     {{ macros.pagination_widget(pagination, '.post', fragment='#comments', id=posts[0].id) }}
19
+ </div>
20
+ {% endif %}
8 21
 {% endblock %}

+ 4
- 1
app/templates/user.html View File

@@ -27,7 +27,10 @@
27 27
             <p>
28 28
                 Member since {{ moment(user.member_since).format('L') }}.
29 29
                 Last seen {{ moment(user.last_seen).fromNow() }}.
30
-                <p>{{ user.posts.count() }} blog post{% if user.posts.count() > 1 %}s{% endif %}.</p>
30
+                <p>
31
+                    {{ user.posts.count() }} blog post{% if user.posts.count() != 1 %}s{% endif %}.
32
+                    {{ user.comments.count() }} comment{% if user.comments.count() != 1 %}s{% endif %}.
33
+                </p>
31 34
             </p>
32 35
             <p>
33 36
             {% if current_user.can(Permission.FOLLOW) and user != current_user %}

+ 1
- 0
config.py View File

@@ -16,6 +16,7 @@ class Config:
16 16
     SQLALCHEMY_TRACK_MODIFICATIONS = False
17 17
     FLASKY_POSTS_PER_PAGE = 20
18 18
     FLASKY_FOLLOWERS_PER_PAGE = 50
19
+    FLASKY_COMMENTS_PER_PAGE = 30
19 20
 
20 21
     @staticmethod
21 22
     def init_app(app):

+ 2
- 2
flasky.py View File

@@ -1,7 +1,7 @@
1 1
 import os
2 2
 from flask_migrate import Migrate
3 3
 from app import create_app, db
4
-from app.models import User, Role, Permission, Post, Follow
4
+from app.models import User, Role, Permission, Post, Follow, Comment
5 5
 
6 6
 
7 7
 app = create_app(os.getenv('FLASK_CONFIG') or 'default')
@@ -11,7 +11,7 @@ migrate = Migrate(app, db)
11 11
 @app.shell_context_processor
12 12
 def make_shell_context():
13 13
     return dict(db=db, User=User, Follow=Follow, Role=Role,
14
-                Permission=Permission, Post=Post)
14
+                Permission=Permission, Post=Post, Comment=Comment)
15 15
 
16 16
 
17 17
 @app.cli.command()

+ 41
- 0
migrations/versions/643e9b785c28_.py View File

@@ -0,0 +1,41 @@
1
+"""empty message
2
+
3
+Revision ID: 643e9b785c28
4
+Revises: d1ab608c102a
5
+Create Date: 2018-11-24 23:31:37.923248
6
+
7
+"""
8
+from alembic import op
9
+import sqlalchemy as sa
10
+
11
+
12
+# revision identifiers, used by Alembic.
13
+revision = '643e9b785c28'
14
+down_revision = 'd1ab608c102a'
15
+branch_labels = None
16
+depends_on = None
17
+
18
+
19
+def upgrade():
20
+    # ### commands auto generated by Alembic - please adjust! ###
21
+    op.create_table('comments',
22
+    sa.Column('id', sa.Integer(), nullable=False),
23
+    sa.Column('body', sa.Text(), nullable=True),
24
+    sa.Column('body_html', sa.Text(), nullable=True),
25
+    sa.Column('timestamp', sa.DateTime(), nullable=True),
26
+    sa.Column('disabled', sa.Boolean(), nullable=True),
27
+    sa.Column('author_id', sa.Integer(), nullable=True),
28
+    sa.Column('post_id', sa.Integer(), nullable=True),
29
+    sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
30
+    sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ),
31
+    sa.PrimaryKeyConstraint('id')
32
+    )
33
+    op.create_index(op.f('ix_comments_timestamp'), 'comments', ['timestamp'], unique=False)
34
+    # ### end Alembic commands ###
35
+
36
+
37
+def downgrade():
38
+    # ### commands auto generated by Alembic - please adjust! ###
39
+    op.drop_index(op.f('ix_comments_timestamp'), table_name='comments')
40
+    op.drop_table('comments')
41
+    # ### end Alembic commands ###