flaskbb-plugin-like 1.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flaskbb_plugin_like-1.0.0.dev0.dist-info/METADATA +51 -0
- flaskbb_plugin_like-1.0.0.dev0.dist-info/RECORD +17 -0
- flaskbb_plugin_like-1.0.0.dev0.dist-info/WHEEL +4 -0
- flaskbb_plugin_like-1.0.0.dev0.dist-info/entry_points.txt +2 -0
- flaskbb_plugin_like-1.0.0.dev0.dist-info/licenses/LICENSE +33 -0
- like/__init__.py +115 -0
- like/forms.py +18 -0
- like/migrations/202607311200_b7d2e4f6a9c1_add_like_association.py +85 -0
- like/migrations/__init__.py +0 -0
- like/models.py +131 -0
- like/static/like.js +58 -0
- like/templates/like/_like_button.html +41 -0
- like/templates/like/_like_counts.html +4 -0
- like/templates/like/_scripts.html +1 -0
- like/templates/like/liked_posts.html +56 -0
- like/utils.py +90 -0
- like/views.py +150 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flaskbb-plugin-like
|
|
3
|
+
Version: 1.0.0.dev0
|
|
4
|
+
Summary: Like functionality for FlaskBB - let users like posts and see who liked them
|
|
5
|
+
Project-URL: Homepage, https://github.com/flaskbb/flaskbb-plugin-like
|
|
6
|
+
Project-URL: Repository, https://github.com/flaskbb/flaskbb-plugin-like
|
|
7
|
+
Project-URL: Issues, https://github.com/flaskbb/flaskbb-plugin-like/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/flaskbb/flaskbb-plugin-like/blob/master/CHANGELOG.md
|
|
9
|
+
Author-email: Peter Justin <peter.justin@outlook.com>
|
|
10
|
+
Maintainer-email: Peter Justin <peter.justin@outlook.com>
|
|
11
|
+
License-Expression: BSD-3-Clause
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: flaskbb,like,likes,plugin
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Flask
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Requires-Dist: flaskbb>=3.0.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
Like
|
|
30
|
+
====
|
|
31
|
+
|
|
32
|
+
Like functionality for FlaskBB. Lets users like posts and see who liked them.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Installation
|
|
36
|
+
------------
|
|
37
|
+
|
|
38
|
+
Install *flaskbb-plugin-like* with
|
|
39
|
+
``pip install flaskbb-plugin-like``
|
|
40
|
+
|
|
41
|
+
And run the migrations:
|
|
42
|
+
``flaskbb db upgrade``
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Credits
|
|
46
|
+
-------
|
|
47
|
+
Forked from [flaskbb-plugin-vanity](https://github.com/micha030201/flaskbb-plugin-vanity) by Михаил Лебедев.
|
|
48
|
+
|
|
49
|
+
License
|
|
50
|
+
-------
|
|
51
|
+
This project is licensed under the terms of the [BSD License](/LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
like/__init__.py,sha256=6gilrMXZEaZTS4sIGaBvY_sTBEFs9J4fiBmKdBqMh7Y,2774
|
|
2
|
+
like/forms.py,sha256=paftjPk2cq5RmDUnm8JQZKd3UpQ4Y9XqGQk31tN1efw,427
|
|
3
|
+
like/models.py,sha256=d4MEqJVq8iHK0bUtHH4wMvg3gn6Rf9SQ9XsB5MbP67E,4742
|
|
4
|
+
like/utils.py,sha256=X_TeGsvPxcnRzL4VlfTHsuE-Zm5guZeL8ISjpKeIw7g,3065
|
|
5
|
+
like/views.py,sha256=90i_pSA3LHW-VcvnIZc3AIZPUoWLQ7MdrN7kWF5yIOY,4549
|
|
6
|
+
like/migrations/202607311200_b7d2e4f6a9c1_add_like_association.py,sha256=8IMz4iE9ybDLrH5Ei_iT1Fy0L8QvCrA_j6jmfsRwcRk,2579
|
|
7
|
+
like/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
like/static/like.js,sha256=Xao0lqzuQM_YCvCQMIKEBEkeeG4J2Y6LI-iWn6Azw18,1772
|
|
9
|
+
like/templates/like/_like_button.html,sha256=MSgB5Y6vePLx0CzUz_Ub0LstG3iL8rylZYj8sJrsK4M,2015
|
|
10
|
+
like/templates/like/_like_counts.html,sha256=6yg_EqfmkL-_9T4iCahE0kqVS_s2XsuPbKeq0kO1Fpg,263
|
|
11
|
+
like/templates/like/_scripts.html,sha256=UyFZ5q0_DQ36ieUFTjXzbk7MYDhkCGmXnN7Jj3AxjQA,73
|
|
12
|
+
like/templates/like/liked_posts.html,sha256=omvEgW7vbamz6YzDNrnLX5W_Ik2cu4rsxUl1Dj4_7MU,1932
|
|
13
|
+
flaskbb_plugin_like-1.0.0.dev0.dist-info/METADATA,sha256=IW7NhJp606WZasI-FxLzLRg3XrMDd7QKBldGeO3pNxM,1778
|
|
14
|
+
flaskbb_plugin_like-1.0.0.dev0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
flaskbb_plugin_like-1.0.0.dev0.dist-info/entry_points.txt,sha256=iQnjmlVJFj3yQ8exgU0tHzgHy_Mvhqy9UGLrVsDcDmw,30
|
|
16
|
+
flaskbb_plugin_like-1.0.0.dev0.dist-info/licenses/LICENSE,sha256=0fSTfZMRcOWJ7ZMsVF4Lrshq8kb4hha4qviRKNL3Vc4,1575
|
|
17
|
+
flaskbb_plugin_like-1.0.0.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Copyright (c) 2018 Михаил Лебедев
|
|
2
|
+
Copyright (c) 2026 Peter Justin
|
|
3
|
+
|
|
4
|
+
Some rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms of the software as well
|
|
7
|
+
as documentation, with or without modification, are permitted provided
|
|
8
|
+
that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
* Redistributions of source code must retain the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
* Redistributions in binary form must reproduce the above
|
|
14
|
+
copyright notice, this list of conditions and the following
|
|
15
|
+
disclaimer in the documentation and/or other materials provided
|
|
16
|
+
with the distribution.
|
|
17
|
+
|
|
18
|
+
* The names of the contributors may not be used to endorse or
|
|
19
|
+
promote products derived from this software without specific
|
|
20
|
+
prior written permission.
|
|
21
|
+
|
|
22
|
+
THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
|
23
|
+
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
|
|
24
|
+
NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
25
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
|
26
|
+
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
27
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
28
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
29
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
30
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
31
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
32
|
+
SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
|
|
33
|
+
DAMAGE.
|
like/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
like
|
|
3
|
+
~~~~
|
|
4
|
+
|
|
5
|
+
Like functionality for FlaskBB. Lets users like posts and see who liked
|
|
6
|
+
them, via a button on the post menu.
|
|
7
|
+
|
|
8
|
+
:copyright: (c) 2026 by Peter Justin, (c) 2018 by Михаил Лебедев.
|
|
9
|
+
:license: BSD License, see LICENSE for more details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from flask import Flask
|
|
15
|
+
from flask_babelplus import gettext as _
|
|
16
|
+
from flask_login import current_user
|
|
17
|
+
from flaskbb.settings import BoolSetting, SettingGroup
|
|
18
|
+
from flaskbb.display.navigation import NavigationLink
|
|
19
|
+
from flaskbb.forum.models import Post
|
|
20
|
+
from flaskbb.user.models import User
|
|
21
|
+
from flaskbb.utils.helpers import real, render_template
|
|
22
|
+
from pluggy import HookimplMarker
|
|
23
|
+
|
|
24
|
+
from .forms import LikeActionForm
|
|
25
|
+
from .utils import (
|
|
26
|
+
DEFAULT_ALLOW_SELF_LIKE,
|
|
27
|
+
can_like_post,
|
|
28
|
+
has_liked,
|
|
29
|
+
likes_given_count,
|
|
30
|
+
likes_received_count,
|
|
31
|
+
)
|
|
32
|
+
from .views import like_bp
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.0"
|
|
35
|
+
|
|
36
|
+
hookimpl = HookimplMarker("flaskbb")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@hookimpl
|
|
40
|
+
def flaskbb_load_migrations():
|
|
41
|
+
return os.path.join(os.path.dirname(__file__), "migrations")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@hookimpl
|
|
45
|
+
def flaskbb_load_translations():
|
|
46
|
+
return os.path.join(os.path.dirname(__file__), "translations")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@hookimpl
|
|
50
|
+
def flaskbb_load_blueprints(app: Flask):
|
|
51
|
+
app.register_blueprint(
|
|
52
|
+
like_bp, url_prefix=app.config.get("PLUGIN_LIKE_URL_PREFIX", "/like")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SETTINGS = SettingGroup(
|
|
57
|
+
key="like",
|
|
58
|
+
name="Like Settings",
|
|
59
|
+
description="Settings for the like plugin.",
|
|
60
|
+
settings=(
|
|
61
|
+
BoolSetting(
|
|
62
|
+
key="ALLOW_SELF_LIKE",
|
|
63
|
+
value=DEFAULT_ALLOW_SELF_LIKE,
|
|
64
|
+
name="Allow liking your own posts",
|
|
65
|
+
description="If enabled, a user may like their own posts.",
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@hookimpl
|
|
72
|
+
def flaskbb_load_setting_groups():
|
|
73
|
+
return SETTINGS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@hookimpl
|
|
77
|
+
def flaskbb_tpl_post_menu_before(post: Post):
|
|
78
|
+
user = real(current_user)
|
|
79
|
+
can_like = user.is_authenticated and can_like_post(user, post)
|
|
80
|
+
already_liked = user.is_authenticated and has_liked(user, post)
|
|
81
|
+
return render_template(
|
|
82
|
+
"like/_like_button.html",
|
|
83
|
+
post=post,
|
|
84
|
+
can_like=can_like,
|
|
85
|
+
already_liked=already_liked,
|
|
86
|
+
form=LikeActionForm(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@hookimpl
|
|
91
|
+
def flaskbb_tpl_post_author_info_after(user: User | None, post: Post):
|
|
92
|
+
if user is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
return render_template(
|
|
96
|
+
"like/_like_counts.html",
|
|
97
|
+
user=user,
|
|
98
|
+
likes_given=likes_given_count(user),
|
|
99
|
+
likes_received=likes_received_count(user),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@hookimpl
|
|
104
|
+
def flaskbb_tpl_scripts():
|
|
105
|
+
return render_template("like/_scripts.html")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@hookimpl
|
|
109
|
+
def flaskbb_tpl_profile_sidebar_links(user: User):
|
|
110
|
+
return NavigationLink(
|
|
111
|
+
endpoint="like.liked_posts",
|
|
112
|
+
name=_("Liked posts"),
|
|
113
|
+
icon="fa fa-heart",
|
|
114
|
+
urlforkwargs={"username": user.username},
|
|
115
|
+
)
|
like/forms.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
like.forms
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Forms used to like/unlike a post. Their only real job is to carry the
|
|
6
|
+
CSRF token - which post to (un)like comes from the URL.
|
|
7
|
+
|
|
8
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
9
|
+
:license: BSD License, see LICENSE for more details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from flask_wtf import FlaskForm
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LikeActionForm(FlaskForm):
|
|
16
|
+
"""Used in _like_button.html to like or unlike a post.
|
|
17
|
+
It just contains the hidden CSRF field
|
|
18
|
+
"""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Add like_association and the user like counters
|
|
2
|
+
|
|
3
|
+
Revision ID: b7d2e4f6a9c1
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-31 12:00:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
from alembic import op
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = "b7d2e4f6a9c1"
|
|
14
|
+
down_revision = None
|
|
15
|
+
branch_labels = ("like",)
|
|
16
|
+
depends_on = "8ad96e49dc6" # flaskbb core init migration - creates posts/users
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade():
|
|
20
|
+
con = op.get_bind()
|
|
21
|
+
inspector = sa.inspect(con.engine)
|
|
22
|
+
|
|
23
|
+
if not inspector.has_table("like_association"):
|
|
24
|
+
op.create_table(
|
|
25
|
+
"like_association",
|
|
26
|
+
sa.Column("post_id", sa.Integer(), nullable=False),
|
|
27
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
28
|
+
sa.ForeignKeyConstraint(
|
|
29
|
+
["post_id"],
|
|
30
|
+
["posts.id"],
|
|
31
|
+
name=op.f("fk_like_association_post_id_posts"),
|
|
32
|
+
ondelete="CASCADE",
|
|
33
|
+
),
|
|
34
|
+
sa.ForeignKeyConstraint(
|
|
35
|
+
["user_id"],
|
|
36
|
+
["users.id"],
|
|
37
|
+
name=op.f("fk_like_association_user_id_users"),
|
|
38
|
+
ondelete="CASCADE",
|
|
39
|
+
),
|
|
40
|
+
sa.PrimaryKeyConstraint(
|
|
41
|
+
"post_id", "user_id", name=op.f("pk_like_association")
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# a fresh install that went through db.create_all() already has them
|
|
46
|
+
user_columns = {c["name"] for c in inspector.get_columns("users")}
|
|
47
|
+
with op.batch_alter_table("users", schema=None) as batch_op:
|
|
48
|
+
if "likes_given" not in user_columns:
|
|
49
|
+
batch_op.add_column(
|
|
50
|
+
sa.Column(
|
|
51
|
+
"likes_given", sa.Integer(), nullable=False, server_default="0"
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
if "likes_received" not in user_columns:
|
|
55
|
+
batch_op.add_column(
|
|
56
|
+
sa.Column(
|
|
57
|
+
"likes_received", sa.Integer(), nullable=False, server_default="0"
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
op.execute(
|
|
62
|
+
"""
|
|
63
|
+
UPDATE users SET likes_given = (
|
|
64
|
+
SELECT COUNT(*) FROM like_association
|
|
65
|
+
WHERE like_association.user_id = users.id
|
|
66
|
+
)
|
|
67
|
+
"""
|
|
68
|
+
)
|
|
69
|
+
op.execute(
|
|
70
|
+
"""
|
|
71
|
+
UPDATE users SET likes_received = (
|
|
72
|
+
SELECT COUNT(*) FROM like_association
|
|
73
|
+
JOIN posts ON posts.id = like_association.post_id
|
|
74
|
+
WHERE posts.user_id = users.id
|
|
75
|
+
)
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def downgrade():
|
|
81
|
+
op.drop_table("like_association")
|
|
82
|
+
|
|
83
|
+
with op.batch_alter_table("users", schema=None) as batch_op:
|
|
84
|
+
batch_op.drop_column("likes_received")
|
|
85
|
+
batch_op.drop_column("likes_given")
|
|
File without changes
|
like/models.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
like.models
|
|
3
|
+
~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
The model for a user's like on a post.
|
|
6
|
+
|
|
7
|
+
:copyright: (c) 2026 by Peter Justin, (c) 2018 by Михаил Лебедев.
|
|
8
|
+
:license: BSD License, see LICENSE for more details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
|
|
13
|
+
from flaskbb.extensions import db
|
|
14
|
+
from flaskbb.forum.models import Post
|
|
15
|
+
from flaskbb.user.models import User
|
|
16
|
+
from flaskbb.utils.database import BaseModel
|
|
17
|
+
from sqlalchemy import Connection, ForeignKey, Integer, Table, event, update
|
|
18
|
+
from sqlalchemy.ext.associationproxy import association_proxy
|
|
19
|
+
from sqlalchemy.orm import Mapped, Mapper, mapped_column, relationship
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PostLike(BaseModel):
|
|
23
|
+
__tablename__ = "like_association"
|
|
24
|
+
|
|
25
|
+
post_id: Mapped[int] = mapped_column(
|
|
26
|
+
ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True
|
|
27
|
+
)
|
|
28
|
+
user_id: Mapped[int] = mapped_column(
|
|
29
|
+
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Both sides load via `selectin`, never `joined` - see
|
|
33
|
+
# https://github.com/flaskbb/flaskbb/issues/503. `joined` folds into
|
|
34
|
+
# whatever query loads a Post/User, including flaskbb's own hand-built
|
|
35
|
+
# outerjoin in Topic.get_posts, and corrupts its column layout.
|
|
36
|
+
# `selectin` always runs as its own separate, batched query instead, so
|
|
37
|
+
# it can chain (post -> liked_by_users -> user) without ever touching a
|
|
38
|
+
# query this plugin doesn't own.
|
|
39
|
+
post: Mapped["Post"] = relationship(
|
|
40
|
+
"Post",
|
|
41
|
+
backref=db.backref(
|
|
42
|
+
"liked_by_users", lazy="selectin", cascade="all, delete-orphan"
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
user: Mapped["User"] = relationship(
|
|
46
|
+
"User",
|
|
47
|
+
lazy="selectin",
|
|
48
|
+
backref=db.backref("user_liked_posts", cascade="all, delete-orphan"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Post.likers = association_proxy(
|
|
53
|
+
"liked_by_users",
|
|
54
|
+
"user",
|
|
55
|
+
creator=lambda user: PostLike(user=user), # pyright: ignore
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Denormalized like counters on the core `users` table. Counting from
|
|
59
|
+
# like_association on every render costs a query per distinct post author
|
|
60
|
+
# per page; these are two columns that come along with the User row that
|
|
61
|
+
# flaskbb has already loaded.
|
|
62
|
+
#
|
|
63
|
+
# Assigning a mapped_column onto an already-mapped class goes through
|
|
64
|
+
# declarative's _add_attribute, which appends the column to the `users`
|
|
65
|
+
# Table and adds the property to User's mapper - the same monkey-patching
|
|
66
|
+
# this plugin already does with Post.likers above. like/migrations adds
|
|
67
|
+
# the actual DB columns.
|
|
68
|
+
User.likes_given = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
|
69
|
+
User.likes_received = mapped_column(
|
|
70
|
+
Integer, default=0, server_default="0", nullable=False
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if TYPE_CHECKING:
|
|
75
|
+
|
|
76
|
+
class LikeUser(User):
|
|
77
|
+
"""A ``User`` with the two counters added above. They are added at
|
|
78
|
+
import time, so a type checker cannot see them on ``User`` itself -
|
|
79
|
+
cast to this to read or write them.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
likes_given: int = 0
|
|
83
|
+
likes_received: int = 0
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
LikeUser = User
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _apply_delta(connection: Connection, like: PostLike, delta: int) -> None:
|
|
90
|
+
"""Two people liking the same post at once can't lose an increment. Runs
|
|
91
|
+
on the flush's connection, in the same transaction as the row itself.
|
|
92
|
+
|
|
93
|
+
The post's author is looked up as a subquery instead of via
|
|
94
|
+
``like.post.user_id``: emitting a lazy load from inside a flush event is
|
|
95
|
+
not allowed, and on the delete path the Post may already be gone from the
|
|
96
|
+
session. If the post has no author (guest post, or the author's row went
|
|
97
|
+
first), the subquery is NULL and the UPDATE matches nothing.
|
|
98
|
+
"""
|
|
99
|
+
users = cast(Table, User.__table__)
|
|
100
|
+
posts = cast(Table, Post.__table__)
|
|
101
|
+
author_id = (
|
|
102
|
+
db.select(posts.c.user_id).where(posts.c.id == like.post_id).scalar_subquery()
|
|
103
|
+
)
|
|
104
|
+
connection.execute(
|
|
105
|
+
update(users)
|
|
106
|
+
.where(users.c.id == like.user_id)
|
|
107
|
+
.values(likes_given=users.c.likes_given + delta)
|
|
108
|
+
)
|
|
109
|
+
connection.execute(
|
|
110
|
+
update(users)
|
|
111
|
+
.where(users.c.id == author_id)
|
|
112
|
+
.values(likes_received=users.c.likes_received + delta)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@event.listens_for(PostLike, "after_insert")
|
|
117
|
+
def _increment_like_counts(
|
|
118
|
+
mapper: Mapper[PostLike], connection: Connection, target: PostLike
|
|
119
|
+
) -> None:
|
|
120
|
+
_apply_delta(connection, target, 1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@event.listens_for(PostLike, "after_delete")
|
|
124
|
+
def _decrement_like_counts(
|
|
125
|
+
mapper: Mapper[PostLike], connection: Connection, target: PostLike
|
|
126
|
+
) -> None:
|
|
127
|
+
# Keeps the counters honest on paths this plugin never sees: deleting a
|
|
128
|
+
# post or a user cascades through the ORM relationships above, which
|
|
129
|
+
# deletes each PostLike individually and fires this. A raw SQL delete
|
|
130
|
+
# against like_association bypasses it - see recalculate_like_counts.
|
|
131
|
+
_apply_delta(connection, target, -1)
|
like/static/like.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function patchCounts(counts) {
|
|
5
|
+
for (const entry of counts) {
|
|
6
|
+
const blocks = document.querySelectorAll(
|
|
7
|
+
`.like-counts[data-user-id="${entry.user_id}"]`
|
|
8
|
+
);
|
|
9
|
+
for (const block of blocks) {
|
|
10
|
+
block.querySelector(".like-counts-given").textContent = entry.given;
|
|
11
|
+
block.querySelector(".like-counts-received").textContent =
|
|
12
|
+
entry.received;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Delegated, because the widget this fires on is replaced by the response
|
|
18
|
+
// of the request it just sent.
|
|
19
|
+
document.addEventListener("submit", async (event) => {
|
|
20
|
+
const form = event.target.closest("form[data-like-form]");
|
|
21
|
+
if (!form) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
|
|
26
|
+
const widget = form.closest(".like-widget");
|
|
27
|
+
const button = form.querySelector("button[type=submit]");
|
|
28
|
+
button.disabled = true;
|
|
29
|
+
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(form.action, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
35
|
+
// Carries the form's CSRF token, so the view's LikeActionForm
|
|
36
|
+
// validates exactly like it does for a plain post.
|
|
37
|
+
body: new FormData(form),
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Offline or the request never landed - nothing changed server side,
|
|
41
|
+
// so let the user try again.
|
|
42
|
+
button.disabled = false;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
// A stale token, or the post was liked/unliked from somewhere else in
|
|
48
|
+
// the meantime. Submit for real so the user lands on the same page the
|
|
49
|
+
// no-JS flow would have given them.
|
|
50
|
+
form.submit();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
widget.outerHTML = data.widget;
|
|
56
|
+
patchCounts(data.counts);
|
|
57
|
+
});
|
|
58
|
+
})();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{% set likes = post.likers | length %}
|
|
2
|
+
<div class="like-widget d-inline" data-post-id="{{ post.id }}">
|
|
3
|
+
<a href="#" data-bs-toggle="modal" data-bs-target="#likers-post-{{ post.id }}">
|
|
4
|
+
<small>{{ _("%(count)s like%(plural)s", count=likes, plural="" if likes == 1 else "s") }}</small>
|
|
5
|
+
</a>
|
|
6
|
+
<div class="modal fade" id="likers-post-{{ post.id }}" tabindex="-1" aria-hidden="true">
|
|
7
|
+
<div class="modal-dialog" role="document">
|
|
8
|
+
<div class="modal-content">
|
|
9
|
+
<div class="modal-header">
|
|
10
|
+
<h5 class="modal-title">{{ _("Users who liked this post") }}</h5>
|
|
11
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="modal-body">
|
|
14
|
+
{% for liker in post.likers %}
|
|
15
|
+
<div><a href="{{ liker.url }}">{{ liker.username }}</a></div>
|
|
16
|
+
{% else %}
|
|
17
|
+
<div>{{ _("No likes yet") }}</div>
|
|
18
|
+
{% endfor %}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
{% if can_like %}
|
|
25
|
+
{# No data-bs-toggle="tooltip" here: aurora instantiates its tooltips once
|
|
26
|
+
on page load and doesn't expose bootstrap to plugin scripts, so a button
|
|
27
|
+
swapped in by like.js would end up without one. A plain title attribute
|
|
28
|
+
survives the swap. #}
|
|
29
|
+
<form class="d-inline"
|
|
30
|
+
data-like-form
|
|
31
|
+
method="post"
|
|
32
|
+
action="{{ url_for('like.unlike_post' if already_liked else 'like.like_post', post_id=post.id) }}">
|
|
33
|
+
{{ form.hidden_tag() }}
|
|
34
|
+
{% if already_liked %}
|
|
35
|
+
<button type="submit" class="btn btn-icon fas fa-heart text-red" title="{{ _('Withdraw like') }}" aria-label="{{ _('Withdraw like') }}"></button>
|
|
36
|
+
{% else %}
|
|
37
|
+
<button type="submit" class="btn btn-icon far fa-heart text-red" title="{{ _('Like') }}" aria-label="{{ _('Like') }}"></button>
|
|
38
|
+
{% endif %}
|
|
39
|
+
</form>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<script src="{{ url_for('like.static', filename='like.js') }}"></script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{% extends theme("user/profile_layout.html") %}
|
|
2
|
+
{%- from theme("_macros/pagination.html") import render_pagination %}
|
|
3
|
+
|
|
4
|
+
{% block breadcrumb %}
|
|
5
|
+
<ol class="breadcrumb flaskbb-breadcrumb bg-light">
|
|
6
|
+
<li class="breadcrumb-item"><a href="{{ url_for('forum.index') }}">{% trans %}Forum{% endtrans %}</a></li>
|
|
7
|
+
<li class="breadcrumb-item"><a href="{{ user.url }}">{{ user.username }}</a></li>
|
|
8
|
+
<li class="breadcrumb-item active">{{ _("Liked posts") }}</li>
|
|
9
|
+
</ol>
|
|
10
|
+
{% endblock %}
|
|
11
|
+
|
|
12
|
+
{% block profile_content %}
|
|
13
|
+
<div class="col-12 profile-content">
|
|
14
|
+
|
|
15
|
+
{% for post in posts.items %}
|
|
16
|
+
<div class="card page mb-3">
|
|
17
|
+
<div class="card-header page-header topic-header fw-normal">
|
|
18
|
+
{% trans trimmed
|
|
19
|
+
post_url=post.url,
|
|
20
|
+
topic_title=post.topic.title,
|
|
21
|
+
forum_url=post.topic.forum.url,
|
|
22
|
+
forum_title=post.topic.forum.title
|
|
23
|
+
%}
|
|
24
|
+
<strong><a href="{{ post_url }}">{{ topic_title }}</a></strong>
|
|
25
|
+
in forum <a href="{{ forum_url }}">{{ forum_title }}</a>
|
|
26
|
+
{% endtrans %}
|
|
27
|
+
</div>
|
|
28
|
+
<div class="card-body page-body topic-content">
|
|
29
|
+
<div class="col-12 ps-3">
|
|
30
|
+
<div class="topic-created">
|
|
31
|
+
{{ post.date_created|format_datetime }}
|
|
32
|
+
</div>
|
|
33
|
+
<div class="topic-content">
|
|
34
|
+
{{ post.content|markup }}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
{% else %}
|
|
40
|
+
<div class="row">
|
|
41
|
+
<div class="col-12">
|
|
42
|
+
<div class="alert-message alert-message-info" role="alert">
|
|
43
|
+
{{ _("No posts liked yet") }}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
{% endfor %}
|
|
48
|
+
|
|
49
|
+
{% if posts.items %}
|
|
50
|
+
<div class="col-12 controls-col">
|
|
51
|
+
{{ render_pagination(posts, url_for('like.liked_posts', username=user.username)) }}
|
|
52
|
+
</div>
|
|
53
|
+
{% endif %}
|
|
54
|
+
|
|
55
|
+
</div>
|
|
56
|
+
{% endblock %}
|
like/utils.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
like.utils
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Helpers for liking/unliking a post and for counting a user's likes.
|
|
6
|
+
|
|
7
|
+
:copyright: (c) 2026 by Peter Justin, (c) 2018 by Михаил Лебедев.
|
|
8
|
+
:license: BSD License, see LICENSE for more details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from flaskbb.settings import flaskbb_config
|
|
14
|
+
from flaskbb.extensions import db
|
|
15
|
+
from flaskbb.forum.models import Post
|
|
16
|
+
from flaskbb.user.models import User
|
|
17
|
+
from sqlalchemy import func
|
|
18
|
+
|
|
19
|
+
from .models import LikeUser, PostLike
|
|
20
|
+
|
|
21
|
+
DEFAULT_ALLOW_SELF_LIKE = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def allow_self_like() -> bool:
|
|
25
|
+
# Falls back to the setting's own default when the plugin hasn't been
|
|
26
|
+
# installed yet (admin panel > Plugins > Install seeds it into the
|
|
27
|
+
# settings table).
|
|
28
|
+
value = flaskbb_config["LIKE_ALLOW_SELF_LIKE"]
|
|
29
|
+
return bool(value) if value is not None else DEFAULT_ALLOW_SELF_LIKE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def can_like_post(user: User, post: Post) -> bool:
|
|
33
|
+
"""Mirrors the old vanity plugin's ``Post.allowed_to_like`` - a post
|
|
34
|
+
can be liked if its topic and forum aren't locked, the post still has
|
|
35
|
+
a topic (it might have been orphaned by a delete), and the user shares
|
|
36
|
+
a group with the post's forum. Liking your own post is only allowed
|
|
37
|
+
when the ``LIKE_ALLOW_SELF_LIKE`` setting is on.
|
|
38
|
+
"""
|
|
39
|
+
topic = post.topic
|
|
40
|
+
if topic is None or topic.locked or topic.forum.locked:
|
|
41
|
+
return False
|
|
42
|
+
if not allow_self_like() and post.user == user:
|
|
43
|
+
return False
|
|
44
|
+
forum_group_ids = {group.id for group in topic.forum.groups}
|
|
45
|
+
user_group_ids = {group.id for group in user.groups}
|
|
46
|
+
return bool(forum_group_ids & user_group_ids)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def like_post(user: User, post: Post) -> None:
|
|
50
|
+
PostLike(post_id=post.id, user_id=user.id).save()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unlike_post(user: User, post: Post) -> None:
|
|
54
|
+
like = PostLike.get(PostLike.post_id == post.id, PostLike.user_id == user.id)
|
|
55
|
+
if like is not None:
|
|
56
|
+
like.delete()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_liked(user: User, post: Post) -> bool:
|
|
60
|
+
like = PostLike.get(PostLike.post_id == post.id, PostLike.user_id == user.id)
|
|
61
|
+
return like is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def likes_given_count(user: User) -> int:
|
|
65
|
+
return cast(LikeUser, user).likes_given
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def likes_received_count(user: User) -> int:
|
|
69
|
+
return cast(LikeUser, user).likes_received
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def recalculate_like_counts(user: User) -> User:
|
|
73
|
+
"""Recomputes both counters from like_association. The counters are
|
|
74
|
+
maintained by the events in like/models.py, which cover every ORM path;
|
|
75
|
+
anything that deletes likes with raw SQL (or a stale DB from before the
|
|
76
|
+
counters existed) needs this to resync.
|
|
77
|
+
"""
|
|
78
|
+
# PostLike has no `id` - its primary key is (post_id, user_id) - so
|
|
79
|
+
# CRUDMixin.count()'s default `column=cls.id` doesn't exist here.
|
|
80
|
+
counts = cast(LikeUser, user)
|
|
81
|
+
counts.likes_given = PostLike.count(
|
|
82
|
+
PostLike.user_id == user.id, column=PostLike.post_id
|
|
83
|
+
)
|
|
84
|
+
counts.likes_received = db.session.execute(
|
|
85
|
+
db.select(func.count(PostLike.post_id))
|
|
86
|
+
.join(Post, PostLike.post_id == Post.id)
|
|
87
|
+
.where(Post.user_id == user.id)
|
|
88
|
+
).scalar_one()
|
|
89
|
+
user.save()
|
|
90
|
+
return user
|
like/views.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
like.views
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
The views for liking/unliking a post and for a user's liked-posts page.
|
|
6
|
+
|
|
7
|
+
:copyright: (c) 2026 by Peter Justin, (c) 2018 by Михаил Лебедев.
|
|
8
|
+
:license: BSD License, see LICENSE for more details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from flask import Blueprint, abort, flash, jsonify, redirect, request
|
|
12
|
+
from flask.views import MethodView
|
|
13
|
+
from flask_babelplus import gettext as _
|
|
14
|
+
from flask_login import current_user, login_required
|
|
15
|
+
from flaskbb.settings import flaskbb_config
|
|
16
|
+
from flaskbb.extensions import db
|
|
17
|
+
from flaskbb.forum.models import Forum, Post, Topic
|
|
18
|
+
from flaskbb.user.models import Group, User
|
|
19
|
+
from flaskbb.utils.helpers import real, register_view, render_template
|
|
20
|
+
|
|
21
|
+
from .forms import LikeActionForm
|
|
22
|
+
from .models import PostLike
|
|
23
|
+
from .utils import (
|
|
24
|
+
can_like_post,
|
|
25
|
+
has_liked,
|
|
26
|
+
like_post,
|
|
27
|
+
likes_given_count,
|
|
28
|
+
likes_received_count,
|
|
29
|
+
unlike_post,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
like_bp = Blueprint(
|
|
33
|
+
"like", __name__, template_folder="templates", static_folder="static"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_ajax() -> bool:
|
|
38
|
+
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _counts_for(user: User) -> dict[str, int]:
|
|
42
|
+
return {
|
|
43
|
+
"user_id": user.id,
|
|
44
|
+
"given": likes_given_count(user),
|
|
45
|
+
"received": likes_received_count(user),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _like_state(post: Post, user: User):
|
|
50
|
+
"""The re-rendered like widget plus the counters the (un)like changed.
|
|
51
|
+
|
|
52
|
+
Both counters are read after ``like_post``/``unlike_post`` committed, so
|
|
53
|
+
the values the after_insert/after_delete events wrote are already visible.
|
|
54
|
+
They are sent as numbers rather than markup because they live in the post
|
|
55
|
+
author sidebar - a different part of the page from the widget, and
|
|
56
|
+
possibly in several places at once if the same user has more than one post
|
|
57
|
+
on it.
|
|
58
|
+
"""
|
|
59
|
+
counts = [_counts_for(user)]
|
|
60
|
+
if post.user is not None and post.user != user:
|
|
61
|
+
counts.append(_counts_for(post.user))
|
|
62
|
+
return jsonify(
|
|
63
|
+
widget=render_template(
|
|
64
|
+
"like/_like_button.html",
|
|
65
|
+
post=post,
|
|
66
|
+
can_like=can_like_post(user, post),
|
|
67
|
+
already_liked=has_liked(user, post),
|
|
68
|
+
form=LikeActionForm(),
|
|
69
|
+
),
|
|
70
|
+
counts=counts,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LikePost(MethodView):
|
|
75
|
+
decorators = [login_required]
|
|
76
|
+
|
|
77
|
+
def post(self, post_id: int):
|
|
78
|
+
post = Post.get_or_404(Post.id == post_id)
|
|
79
|
+
user = real(current_user)
|
|
80
|
+
|
|
81
|
+
form = LikeActionForm()
|
|
82
|
+
if not form.validate_on_submit():
|
|
83
|
+
flash(_("Could not verify the like request, please try again."), "danger")
|
|
84
|
+
return redirect(post.url)
|
|
85
|
+
|
|
86
|
+
if has_liked(user, post) or not can_like_post(user, post):
|
|
87
|
+
abort(403)
|
|
88
|
+
|
|
89
|
+
like_post(user, post)
|
|
90
|
+
if _is_ajax():
|
|
91
|
+
return _like_state(post, user)
|
|
92
|
+
return redirect(post.url)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UnlikePost(MethodView):
|
|
96
|
+
decorators = [login_required]
|
|
97
|
+
|
|
98
|
+
def post(self, post_id: int):
|
|
99
|
+
post = Post.get_or_404(Post.id == post_id)
|
|
100
|
+
user = real(current_user)
|
|
101
|
+
|
|
102
|
+
form = LikeActionForm()
|
|
103
|
+
if not form.validate_on_submit():
|
|
104
|
+
flash(_("Could not verify the like request, please try again."), "danger")
|
|
105
|
+
return redirect(post.url)
|
|
106
|
+
|
|
107
|
+
if not has_liked(user, post):
|
|
108
|
+
abort(403)
|
|
109
|
+
|
|
110
|
+
unlike_post(user, post)
|
|
111
|
+
if _is_ajax():
|
|
112
|
+
return _like_state(post, user)
|
|
113
|
+
return redirect(post.url)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class LikedPosts(MethodView):
|
|
117
|
+
def get(self, username: str):
|
|
118
|
+
page = request.args.get("page", 1, type=int)
|
|
119
|
+
user = User.get_by_or_404(username=username)
|
|
120
|
+
viewer = real(current_user)
|
|
121
|
+
group_ids = [g.id for g in viewer.groups]
|
|
122
|
+
|
|
123
|
+
stmt = (
|
|
124
|
+
db.select(Post)
|
|
125
|
+
.join(PostLike, PostLike.post_id == Post.id)
|
|
126
|
+
.join(Topic, Post.topic_id == Topic.id)
|
|
127
|
+
.join(Forum, Topic.forum_id == Forum.id)
|
|
128
|
+
.where(
|
|
129
|
+
PostLike.user_id == user.id,
|
|
130
|
+
Forum.groups.any(Group.id.in_(group_ids)),
|
|
131
|
+
)
|
|
132
|
+
.order_by(Post.id.desc())
|
|
133
|
+
)
|
|
134
|
+
posts = db.paginate(stmt, page=page, per_page=flaskbb_config["POSTS_PER_PAGE"])
|
|
135
|
+
return render_template("like/liked_posts.html", user=user, posts=posts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
register_view(
|
|
139
|
+
like_bp, routes=["/<int:post_id>/like"], view_func=LikePost.as_view("like_post")
|
|
140
|
+
)
|
|
141
|
+
register_view(
|
|
142
|
+
like_bp,
|
|
143
|
+
routes=["/<int:post_id>/unlike"],
|
|
144
|
+
view_func=UnlikePost.as_view("unlike_post"),
|
|
145
|
+
)
|
|
146
|
+
register_view(
|
|
147
|
+
like_bp,
|
|
148
|
+
routes=["/<username>/liked-posts"],
|
|
149
|
+
view_func=LikedPosts.as_view("liked_posts"),
|
|
150
|
+
)
|