flaskbb-plugin-like 1.0.0.dev0__tar.gz
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/.gitignore +43 -0
- flaskbb_plugin_like-1.0.0.dev0/LICENSE +33 -0
- flaskbb_plugin_like-1.0.0.dev0/PKG-INFO +51 -0
- flaskbb_plugin_like-1.0.0.dev0/README.md +23 -0
- flaskbb_plugin_like-1.0.0.dev0/like/__init__.py +115 -0
- flaskbb_plugin_like-1.0.0.dev0/like/forms.py +18 -0
- flaskbb_plugin_like-1.0.0.dev0/like/migrations/202607311200_b7d2e4f6a9c1_add_like_association.py +85 -0
- flaskbb_plugin_like-1.0.0.dev0/like/migrations/__init__.py +0 -0
- flaskbb_plugin_like-1.0.0.dev0/like/models.py +131 -0
- flaskbb_plugin_like-1.0.0.dev0/like/static/like.js +58 -0
- flaskbb_plugin_like-1.0.0.dev0/like/templates/like/_like_button.html +41 -0
- flaskbb_plugin_like-1.0.0.dev0/like/templates/like/_like_counts.html +4 -0
- flaskbb_plugin_like-1.0.0.dev0/like/templates/like/_scripts.html +1 -0
- flaskbb_plugin_like-1.0.0.dev0/like/templates/like/liked_posts.html +56 -0
- flaskbb_plugin_like-1.0.0.dev0/like/utils.py +90 -0
- flaskbb_plugin_like-1.0.0.dev0/like/views.py +150 -0
- flaskbb_plugin_like-1.0.0.dev0/pyproject.toml +90 -0
- flaskbb_plugin_like-1.0.0.dev0/tests/conftest.py +46 -0
- flaskbb_plugin_like-1.0.0.dev0/tests/test_like_models.py +53 -0
- flaskbb_plugin_like-1.0.0.dev0/tests/test_like_utils.py +166 -0
- flaskbb_plugin_like-1.0.0.dev0/tests/test_like_views.py +158 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
.Python
|
|
8
|
+
build/
|
|
9
|
+
develop-eggs/
|
|
10
|
+
dist/
|
|
11
|
+
downloads/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
lib/
|
|
15
|
+
lib64/
|
|
16
|
+
parts/
|
|
17
|
+
sdist/
|
|
18
|
+
var/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Unit test / coverage reports
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.coverage
|
|
27
|
+
.coverage.*
|
|
28
|
+
.cache
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.ruff_cache/
|
|
31
|
+
nosetests.xml
|
|
32
|
+
coverage.xml
|
|
33
|
+
*,cover
|
|
34
|
+
.hypothesis/
|
|
35
|
+
|
|
36
|
+
# Translations
|
|
37
|
+
*.mo
|
|
38
|
+
*.pot
|
|
39
|
+
|
|
40
|
+
*.log
|
|
41
|
+
|
|
42
|
+
.vscode
|
|
43
|
+
.venv/
|
|
@@ -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.
|
|
@@ -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,23 @@
|
|
|
1
|
+
Like
|
|
2
|
+
====
|
|
3
|
+
|
|
4
|
+
Like functionality for FlaskBB. Lets users like posts and see who liked them.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Installation
|
|
8
|
+
------------
|
|
9
|
+
|
|
10
|
+
Install *flaskbb-plugin-like* with
|
|
11
|
+
``pip install flaskbb-plugin-like``
|
|
12
|
+
|
|
13
|
+
And run the migrations:
|
|
14
|
+
``flaskbb db upgrade``
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Credits
|
|
18
|
+
-------
|
|
19
|
+
Forked from [flaskbb-plugin-vanity](https://github.com/micha030201/flaskbb-plugin-vanity) by Михаил Лебедев.
|
|
20
|
+
|
|
21
|
+
License
|
|
22
|
+
-------
|
|
23
|
+
This project is licensed under the terms of the [BSD License](/LICENSE).
|
|
@@ -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
|
+
)
|
|
@@ -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
|
+
"""
|
flaskbb_plugin_like-1.0.0.dev0/like/migrations/202607311200_b7d2e4f6a9c1_add_like_association.py
ADDED
|
@@ -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
|
|
@@ -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)
|
|
@@ -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 %}
|