splent-feature-comments 0.1.0__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.
- splent_feature_comments-0.1.0/MANIFEST.in +8 -0
- splent_feature_comments-0.1.0/PKG-INFO +5 -0
- splent_feature_comments-0.1.0/pyproject.toml +52 -0
- splent_feature_comments-0.1.0/setup.cfg +4 -0
- splent_feature_comments-0.1.0/src/splent_feature_comments.egg-info/PKG-INFO +5 -0
- splent_feature_comments-0.1.0/src/splent_feature_comments.egg-info/SOURCES.txt +26 -0
- splent_feature_comments-0.1.0/src/splent_feature_comments.egg-info/dependency_links.txt +1 -0
- splent_feature_comments-0.1.0/src/splent_feature_comments.egg-info/top_level.txt +1 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/__init__.py +19 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/assets/dist/splent_feature_comments.bundle.js +20 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/assets/dist/splent_feature_comments.bundle.js.map +1 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/commands.py +21 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/config.py +19 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/forms.py +6 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/hooks.py +38 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/migrations/alembic.ini +36 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/migrations/env.py +9 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/migrations/script.py.mako +24 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/migrations/versions/comments0001_initial.py +40 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/models.py +25 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/repositories.py +7 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/routes.py +78 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/seeders.py +10 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/services.py +25 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/signals.py +12 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/templates/comments/admin/list.html +83 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/templates/comments/index.html +10 -0
- splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/templates/comments/thread.html +93 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Compiled frontend bundles
|
|
2
|
+
recursive-include src/splent_io/splent_feature_comments/assets/dist *.js *.css *.map
|
|
3
|
+
|
|
4
|
+
# Jinja templates (feature views + hook fragments)
|
|
5
|
+
recursive-include src/splent_io/splent_feature_comments/templates *.html
|
|
6
|
+
|
|
7
|
+
# Alembic migration config and scripts
|
|
8
|
+
recursive-include src/splent_io/splent_feature_comments/migrations *.py *.ini *.mako
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.3.1", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "splent_feature_comments"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
package-dir = { "" = "src" }
|
|
17
|
+
include-package-data = true
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
exclude = ["*.tests", "*.tests.*"]
|
|
22
|
+
|
|
23
|
+
[tool.splent]
|
|
24
|
+
cli_version = "1.11.0"
|
|
25
|
+
|
|
26
|
+
# ── Feature Contract (auto-generated) ────────────────────────────────────────
|
|
27
|
+
# Do not edit manually — re-run `splent feature:contract --write` to refresh.
|
|
28
|
+
[tool.splent.contract]
|
|
29
|
+
description = "comments feature"
|
|
30
|
+
|
|
31
|
+
[tool.splent.contract.provides]
|
|
32
|
+
routes = ["/admin/comments", "/admin/comments/<int:comment_id>/approve", "/admin/comments/<int:comment_id>/delete", "/comments/<int:post_id>"]
|
|
33
|
+
blueprints = []
|
|
34
|
+
models = ["Comment"]
|
|
35
|
+
commands = ["hello"]
|
|
36
|
+
hooks = ["layout.authenticated_sidebar", "post.comments"]
|
|
37
|
+
services = ["CommentsService"]
|
|
38
|
+
signals = ["comment-created", "comment-submitting"]
|
|
39
|
+
translations = []
|
|
40
|
+
docker = []
|
|
41
|
+
|
|
42
|
+
[tool.splent.contract.requires]
|
|
43
|
+
features = []
|
|
44
|
+
env_vars = []
|
|
45
|
+
signals = []
|
|
46
|
+
|
|
47
|
+
[tool.splent.contract.extensible]
|
|
48
|
+
services = ["CommentsService"]
|
|
49
|
+
templates = ["comments/admin/list.html", "comments/index.html", "comments/thread.html"]
|
|
50
|
+
models = ["Comment"]
|
|
51
|
+
hooks = ["layout.authenticated_sidebar", "post.comments"]
|
|
52
|
+
routes = false
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/splent_feature_comments.egg-info/PKG-INFO
|
|
4
|
+
src/splent_feature_comments.egg-info/SOURCES.txt
|
|
5
|
+
src/splent_feature_comments.egg-info/dependency_links.txt
|
|
6
|
+
src/splent_feature_comments.egg-info/top_level.txt
|
|
7
|
+
src/splent_io/splent_feature_comments/__init__.py
|
|
8
|
+
src/splent_io/splent_feature_comments/commands.py
|
|
9
|
+
src/splent_io/splent_feature_comments/config.py
|
|
10
|
+
src/splent_io/splent_feature_comments/forms.py
|
|
11
|
+
src/splent_io/splent_feature_comments/hooks.py
|
|
12
|
+
src/splent_io/splent_feature_comments/models.py
|
|
13
|
+
src/splent_io/splent_feature_comments/repositories.py
|
|
14
|
+
src/splent_io/splent_feature_comments/routes.py
|
|
15
|
+
src/splent_io/splent_feature_comments/seeders.py
|
|
16
|
+
src/splent_io/splent_feature_comments/services.py
|
|
17
|
+
src/splent_io/splent_feature_comments/signals.py
|
|
18
|
+
src/splent_io/splent_feature_comments/assets/dist/splent_feature_comments.bundle.js
|
|
19
|
+
src/splent_io/splent_feature_comments/assets/dist/splent_feature_comments.bundle.js.map
|
|
20
|
+
src/splent_io/splent_feature_comments/migrations/alembic.ini
|
|
21
|
+
src/splent_io/splent_feature_comments/migrations/env.py
|
|
22
|
+
src/splent_io/splent_feature_comments/migrations/script.py.mako
|
|
23
|
+
src/splent_io/splent_feature_comments/migrations/versions/comments0001_initial.py
|
|
24
|
+
src/splent_io/splent_feature_comments/templates/comments/index.html
|
|
25
|
+
src/splent_io/splent_feature_comments/templates/comments/thread.html
|
|
26
|
+
src/splent_io/splent_feature_comments/templates/comments/admin/list.html
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
splent_io
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from splent_framework.blueprints.base_blueprint import create_blueprint
|
|
2
|
+
from splent_framework.services.service_locator import register_service
|
|
3
|
+
|
|
4
|
+
from splent_io.splent_feature_comments.services import CommentsService
|
|
5
|
+
|
|
6
|
+
comments_bp = create_blueprint(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_feature(app):
|
|
10
|
+
from splent_framework.assets.asset_registry import register_asset
|
|
11
|
+
|
|
12
|
+
register_service(app, "CommentsService", CommentsService)
|
|
13
|
+
register_asset(
|
|
14
|
+
"css", "comments.assets", order=100, subfolder="css", filename="comments.css"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def inject_context_vars(app):
|
|
19
|
+
return {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/******/ (() => { // webpackBootstrap
|
|
2
|
+
/*!*********************************************************************************************!*\
|
|
3
|
+
!*** ../splent_feature_comments/src/splent_io/splent_feature_comments/assets/js/scripts.js ***!
|
|
4
|
+
\*********************************************************************************************/
|
|
5
|
+
// Entry point for splent_feature_comments frontend assets.
|
|
6
|
+
// Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_comments.bundle.js
|
|
7
|
+
//
|
|
8
|
+
// To load the compiled bundle in the product layout, register it in hooks.py:
|
|
9
|
+
//
|
|
10
|
+
// from splent_framework.hooks.template_hooks import register_template_hook
|
|
11
|
+
// from flask import url_for
|
|
12
|
+
//
|
|
13
|
+
// def comments_scripts():
|
|
14
|
+
// return '<script src="' + url_for("comments.assets", subfolder="dist", filename="splent_feature_comments.bundle.js") + '"></script>'
|
|
15
|
+
//
|
|
16
|
+
// register_template_hook("layout.scripts", comments_scripts)
|
|
17
|
+
|
|
18
|
+
/******/ })()
|
|
19
|
+
;
|
|
20
|
+
//# sourceMappingURL=splent_feature_comments.bundle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"splent_feature_comments.bundle.js","mappings":";;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","sources":["webpack://diversolab_app/../splent_feature_comments/src/splent_io/splent_feature_comments/assets/js/scripts.js"],"sourcesContent":["// Entry point for splent_feature_comments frontend assets.\n// Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_comments.bundle.js\n//\n// To load the compiled bundle in the product layout, register it in hooks.py:\n//\n// from splent_framework.hooks.template_hooks import register_template_hook\n// from flask import url_for\n//\n// def comments_scripts():\n// return '<script src=\"' + url_for(\"comments.assets\", subfolder=\"dist\", filename=\"splent_feature_comments.bundle.js\") + '\"></script>'\n//\n// register_template_hook(\"layout.scripts\", comments_scripts)\n"],"names":[],"sourceRoot":""}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands contributed by splent_feature_comments.
|
|
3
|
+
|
|
4
|
+
These commands are auto-discovered by the framework and exposed in the
|
|
5
|
+
SPLENT CLI under the ``feature:comments`` group.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
splent feature:comments hello
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("hello")
|
|
16
|
+
def hello():
|
|
17
|
+
"""Example command — replace with your own."""
|
|
18
|
+
click.echo(" Hello from splent_feature_comments!")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
cli_commands = [hello]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
comments feature configuration.
|
|
3
|
+
|
|
4
|
+
Injects environment variables into Flask app.config.
|
|
5
|
+
Add your feature's env vars here so the framework can track them.
|
|
6
|
+
|
|
7
|
+
To regenerate from source code: splent feature:inject-config splent_feature_comments
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os # noqa: F401 — used when adding env vars below
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inject_config(app):
|
|
14
|
+
app.config.update(
|
|
15
|
+
{
|
|
16
|
+
# Add your feature's env vars here, e.g.:
|
|
17
|
+
# "MY_VAR": os.getenv("MY_VAR", "default_value"),
|
|
18
|
+
}
|
|
19
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from flask import render_template, request, url_for
|
|
2
|
+
|
|
3
|
+
from splent_framework.hooks.template_hooks import register_template_hook
|
|
4
|
+
from splent_framework.services.service_locator import service_proxy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def post_comments(post):
|
|
8
|
+
"""Rendered into the post detail through the 'post.comments' hook.
|
|
9
|
+
|
|
10
|
+
Receives the current post and renders the approved comments plus the
|
|
11
|
+
submission form (with a Turnstile widget if cloudflare is installed).
|
|
12
|
+
"""
|
|
13
|
+
comments = service_proxy("CommentsService").approved_for(post.id)
|
|
14
|
+
return render_template("comments/thread.html", post=post, comments=comments)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def comments_sidebar_link():
|
|
18
|
+
active = (
|
|
19
|
+
"active"
|
|
20
|
+
if request.endpoint and request.endpoint.startswith("comments.admin")
|
|
21
|
+
else ""
|
|
22
|
+
)
|
|
23
|
+
pending = 0
|
|
24
|
+
try:
|
|
25
|
+
pending = len(service_proxy("CommentsService").pending())
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
badge = f' <span class="badge bg-danger">{pending}</span>' if pending else ""
|
|
29
|
+
return (
|
|
30
|
+
f'<li class="sidebar-item {active}">'
|
|
31
|
+
f'<a class="sidebar-link" href="{url_for("comments.admin_index")}">'
|
|
32
|
+
'<i class="align-middle" data-feather="message-square"></i> '
|
|
33
|
+
f'<span class="align-middle">Comments</span>{badge}</a></li>'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
register_template_hook("post.comments", post_comments)
|
|
38
|
+
register_template_hook("layout.authenticated_sidebar", comments_sidebar_link)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
|
|
4
|
+
[loggers]
|
|
5
|
+
keys = root,sqlalchemy,alembic
|
|
6
|
+
|
|
7
|
+
[handlers]
|
|
8
|
+
keys = console
|
|
9
|
+
|
|
10
|
+
[formatters]
|
|
11
|
+
keys = generic
|
|
12
|
+
|
|
13
|
+
[logger_root]
|
|
14
|
+
level = WARN
|
|
15
|
+
handlers = console
|
|
16
|
+
qualname =
|
|
17
|
+
|
|
18
|
+
[logger_sqlalchemy]
|
|
19
|
+
level = WARN
|
|
20
|
+
handlers =
|
|
21
|
+
qualname = sqlalchemy.engine
|
|
22
|
+
|
|
23
|
+
[logger_alembic]
|
|
24
|
+
level = INFO
|
|
25
|
+
handlers =
|
|
26
|
+
qualname = alembic
|
|
27
|
+
|
|
28
|
+
[handler_console]
|
|
29
|
+
class = StreamHandler
|
|
30
|
+
args = (sys.stderr,)
|
|
31
|
+
level = NOTSET
|
|
32
|
+
formatter = generic
|
|
33
|
+
|
|
34
|
+
[formatter_generic]
|
|
35
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
36
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Alembic migration environment for splent_feature_comments."""
|
|
2
|
+
|
|
3
|
+
from splent_io.splent_feature_comments import models # noqa
|
|
4
|
+
from splent_framework.migrations.feature_env import run_feature_migrations
|
|
5
|
+
|
|
6
|
+
FEATURE_NAME = "splent_feature_comments"
|
|
7
|
+
FEATURE_TABLES = set()
|
|
8
|
+
|
|
9
|
+
run_feature_migrations(FEATURE_NAME, FEATURE_TABLES)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
${imports if imports else ""}
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = ${repr(up_revision)}
|
|
14
|
+
down_revision = ${repr(down_revision)}
|
|
15
|
+
branch_labels = ${repr(branch_labels)}
|
|
16
|
+
depends_on = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade():
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade():
|
|
24
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""comment table.
|
|
2
|
+
|
|
3
|
+
Revision ID: comments0001
|
|
4
|
+
Revises:
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "comments0001"
|
|
11
|
+
down_revision = None
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade():
|
|
17
|
+
op.create_table(
|
|
18
|
+
"comment",
|
|
19
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
20
|
+
sa.Column("post_id", sa.Integer(), nullable=False),
|
|
21
|
+
sa.Column("author_name", sa.String(length=128), nullable=False),
|
|
22
|
+
sa.Column("author_email", sa.String(length=255), nullable=True),
|
|
23
|
+
sa.Column("content", sa.Text(), nullable=False),
|
|
24
|
+
sa.Column("created_at", sa.DateTime(), nullable=True),
|
|
25
|
+
sa.Column("approved", sa.Boolean(), nullable=True),
|
|
26
|
+
sa.ForeignKeyConstraint(["post_id"], ["post.id"], ondelete="CASCADE"),
|
|
27
|
+
sa.PrimaryKeyConstraint("id"),
|
|
28
|
+
)
|
|
29
|
+
op.create_index(op.f("ix_comment_post_id"), "comment", ["post_id"], unique=False)
|
|
30
|
+
op.create_index(
|
|
31
|
+
op.f("ix_comment_created_at"), "comment", ["created_at"], unique=False
|
|
32
|
+
)
|
|
33
|
+
op.create_index(op.f("ix_comment_approved"), "comment", ["approved"], unique=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def downgrade():
|
|
37
|
+
op.drop_index(op.f("ix_comment_approved"), table_name="comment")
|
|
38
|
+
op.drop_index(op.f("ix_comment_created_at"), table_name="comment")
|
|
39
|
+
op.drop_index(op.f("ix_comment_post_id"), table_name="comment")
|
|
40
|
+
op.drop_table("comment")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from splent_framework.db import db
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Comment(db.Model):
|
|
7
|
+
"""A comment on a post. New comments start unapproved (moderation)."""
|
|
8
|
+
|
|
9
|
+
__tablename__ = "comment"
|
|
10
|
+
|
|
11
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
12
|
+
post_id = db.Column(
|
|
13
|
+
db.Integer,
|
|
14
|
+
db.ForeignKey("post.id", ondelete="CASCADE"),
|
|
15
|
+
nullable=False,
|
|
16
|
+
index=True,
|
|
17
|
+
)
|
|
18
|
+
author_name = db.Column(db.String(128), nullable=False)
|
|
19
|
+
author_email = db.Column(db.String(255), default="")
|
|
20
|
+
content = db.Column(db.Text, nullable=False)
|
|
21
|
+
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
|
22
|
+
approved = db.Column(db.Boolean, default=False, index=True)
|
|
23
|
+
|
|
24
|
+
def __repr__(self):
|
|
25
|
+
return f"Comment<{self.id} on post {self.post_id}>"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from flask import flash, redirect, render_template, request, url_for
|
|
2
|
+
from flask_login import login_required
|
|
3
|
+
|
|
4
|
+
from splent_io.splent_feature_comments import comments_bp
|
|
5
|
+
from splent_io.splent_feature_comments.models import Comment
|
|
6
|
+
from splent_io.splent_feature_comments.signals import (
|
|
7
|
+
comment_created,
|
|
8
|
+
comment_submitting,
|
|
9
|
+
)
|
|
10
|
+
from splent_framework.db import db
|
|
11
|
+
from splent_framework.services.service_locator import service_proxy
|
|
12
|
+
|
|
13
|
+
comments_service = service_proxy("CommentsService")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# =====================================================================
|
|
17
|
+
# PUBLIC — submit a comment (rendered into the post via the hook)
|
|
18
|
+
# =====================================================================
|
|
19
|
+
@comments_bp.route("/comments/<int:post_id>", methods=["POST"])
|
|
20
|
+
def create(post_id):
|
|
21
|
+
# Let any listener (a captcha provider, a spam filter…) veto the submission.
|
|
22
|
+
# comments knows nothing about captchas — they connect to this signal.
|
|
23
|
+
results = comment_submitting.send(
|
|
24
|
+
None, post_id=post_id, form=request.form, remoteip=request.remote_addr
|
|
25
|
+
)
|
|
26
|
+
if any(value is False for _, value in results):
|
|
27
|
+
flash("Spam check failed — please try again.", "danger")
|
|
28
|
+
return redirect(request.referrer or url_for("post.index"))
|
|
29
|
+
|
|
30
|
+
name = (request.form.get("author_name") or "").strip()
|
|
31
|
+
content = (request.form.get("content") or "").strip()
|
|
32
|
+
if not (name and content):
|
|
33
|
+
flash("Name and comment are required.", "danger")
|
|
34
|
+
return redirect(request.referrer or url_for("post.index"))
|
|
35
|
+
|
|
36
|
+
comment = Comment(
|
|
37
|
+
post_id=post_id,
|
|
38
|
+
author_name=name,
|
|
39
|
+
author_email=(request.form.get("author_email") or "").strip(),
|
|
40
|
+
content=content,
|
|
41
|
+
approved=False,
|
|
42
|
+
)
|
|
43
|
+
db.session.add(comment)
|
|
44
|
+
db.session.commit()
|
|
45
|
+
comment_created.send(None, comment=comment)
|
|
46
|
+
flash("Your comment was submitted and is awaiting moderation.", "success")
|
|
47
|
+
return redirect(request.referrer or url_for("post.index"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# =====================================================================
|
|
51
|
+
# ADMIN — moderation
|
|
52
|
+
# =====================================================================
|
|
53
|
+
@comments_bp.route("/admin/comments", methods=["GET"])
|
|
54
|
+
@login_required
|
|
55
|
+
def admin_index():
|
|
56
|
+
return render_template(
|
|
57
|
+
"comments/admin/list.html", comments=comments_service.all_comments()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@comments_bp.route("/admin/comments/<int:comment_id>/approve", methods=["POST"])
|
|
62
|
+
@login_required
|
|
63
|
+
def admin_approve(comment_id):
|
|
64
|
+
comment = Comment.query.get_or_404(comment_id)
|
|
65
|
+
comment.approved = True
|
|
66
|
+
db.session.commit()
|
|
67
|
+
flash("Comment approved.", "success")
|
|
68
|
+
return redirect(url_for("comments.admin_index"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@comments_bp.route("/admin/comments/<int:comment_id>/delete", methods=["POST"])
|
|
72
|
+
@login_required
|
|
73
|
+
def admin_delete(comment_id):
|
|
74
|
+
comment = Comment.query.get_or_404(comment_id)
|
|
75
|
+
db.session.delete(comment)
|
|
76
|
+
db.session.commit()
|
|
77
|
+
flash("Comment removed.", "success")
|
|
78
|
+
return redirect(url_for("comments.admin_index"))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from splent_io.splent_feature_comments.models import Comment
|
|
2
|
+
from splent_io.splent_feature_comments.repositories import CommentsRepository
|
|
3
|
+
from splent_framework.services.BaseService import BaseService
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommentsService(BaseService):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__(CommentsRepository())
|
|
9
|
+
|
|
10
|
+
def approved_for(self, post_id):
|
|
11
|
+
return (
|
|
12
|
+
Comment.query.filter_by(post_id=post_id, approved=True)
|
|
13
|
+
.order_by(Comment.created_at.asc())
|
|
14
|
+
.all()
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def pending(self):
|
|
18
|
+
return (
|
|
19
|
+
Comment.query.filter_by(approved=False)
|
|
20
|
+
.order_by(Comment.created_at.desc())
|
|
21
|
+
.all()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def all_comments(self):
|
|
25
|
+
return Comment.query.order_by(Comment.created_at.desc()).all()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Signals emitted by the comments feature.
|
|
2
|
+
|
|
3
|
+
comments stays decoupled from anti-spam: it emits ``comment-submitting`` and any
|
|
4
|
+
listener (a captcha provider, a spam filter…) may return False to veto the
|
|
5
|
+
submission. ``comment-created`` fires after a comment is stored, for things like
|
|
6
|
+
notifications or counters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from splent_framework.signals.signal_utils import define_signal
|
|
10
|
+
|
|
11
|
+
comment_submitting = define_signal("comment-submitting", "splent_feature_comments")
|
|
12
|
+
comment_created = define_signal("comment-created", "splent_feature_comments")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "base_template.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ _('Comments') }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="container section">
|
|
7
|
+
|
|
8
|
+
<header class="page-header">
|
|
9
|
+
<h1>{{ _('Comments') }}</h1>
|
|
10
|
+
<p class="text-muted">{{ _('Moderate comments submitted by visitors.') }}</p>
|
|
11
|
+
</header>
|
|
12
|
+
|
|
13
|
+
{# Flash messages #}
|
|
14
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
15
|
+
{% if messages %}
|
|
16
|
+
<div class="admin-flashes">
|
|
17
|
+
{% for category, message in messages %}
|
|
18
|
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
|
19
|
+
{% endfor %}
|
|
20
|
+
</div>
|
|
21
|
+
{% endif %}
|
|
22
|
+
{% endwith %}
|
|
23
|
+
|
|
24
|
+
<table class="table">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr>
|
|
27
|
+
<th>{{ _('Author') }}</th>
|
|
28
|
+
<th>{{ _('Comment') }}</th>
|
|
29
|
+
<th>{{ _('Date') }}</th>
|
|
30
|
+
<th>{{ _('Status') }}</th>
|
|
31
|
+
<th>{{ _('Actions') }}</th>
|
|
32
|
+
</tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
{% for c in comments %}
|
|
36
|
+
<tr>
|
|
37
|
+
<td>
|
|
38
|
+
<strong>{{ c.author_name }}</strong>
|
|
39
|
+
{% if c.author_email %}
|
|
40
|
+
<br><span class="text-muted">{{ c.author_email }}</span>
|
|
41
|
+
{% endif %}
|
|
42
|
+
</td>
|
|
43
|
+
<td>{{ c.content|truncate(100, true) }}</td>
|
|
44
|
+
<td>{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
45
|
+
<td>
|
|
46
|
+
{% if c.approved %}
|
|
47
|
+
<span class="badge bg-success">{{ _('approved') }}</span>
|
|
48
|
+
{% else %}
|
|
49
|
+
<span class="badge bg-warning text-dark">{{ _('pending') }}</span>
|
|
50
|
+
{% endif %}
|
|
51
|
+
</td>
|
|
52
|
+
<td class="comment-actions">
|
|
53
|
+
{% if not c.approved %}
|
|
54
|
+
<form method="POST"
|
|
55
|
+
action="{{ url_for('comments.admin_approve', comment_id=c.id) }}"
|
|
56
|
+
class="d-inline">
|
|
57
|
+
<button type="submit" class="btn btn-sm btn-success">
|
|
58
|
+
{{ _('Approve') }}
|
|
59
|
+
</button>
|
|
60
|
+
</form>
|
|
61
|
+
{% endif %}
|
|
62
|
+
<form method="POST"
|
|
63
|
+
action="{{ url_for('comments.admin_delete', comment_id=c.id) }}"
|
|
64
|
+
class="d-inline"
|
|
65
|
+
onsubmit="return confirm('{{ _('Delete this comment?') }}');">
|
|
66
|
+
<button type="submit" class="btn btn-sm btn-danger">
|
|
67
|
+
{{ _('Delete') }}
|
|
68
|
+
</button>
|
|
69
|
+
</form>
|
|
70
|
+
</td>
|
|
71
|
+
</tr>
|
|
72
|
+
{% else %}
|
|
73
|
+
<tr>
|
|
74
|
+
<td colspan="5" class="text-muted text-center">
|
|
75
|
+
{{ _('No comments yet.') }}
|
|
76
|
+
</td>
|
|
77
|
+
</tr>
|
|
78
|
+
{% endfor %}
|
|
79
|
+
</tbody>
|
|
80
|
+
</table>
|
|
81
|
+
|
|
82
|
+
</div>
|
|
83
|
+
{% endblock %}
|
splent_feature_comments-0.1.0/src/splent_io/splent_feature_comments/templates/comments/thread.html
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{# ------------------------------------------------------------------ #}
|
|
2
|
+
{# Comments thread partial. #}
|
|
3
|
+
{# Rendered INTO the post detail page (NOT a full page, no extends). #}
|
|
4
|
+
{# Context: `post`, `comments` (approved, oldest first). #}
|
|
5
|
+
{# ------------------------------------------------------------------ #}
|
|
6
|
+
<section class="section comments-thread">
|
|
7
|
+
<div class="container">
|
|
8
|
+
|
|
9
|
+
<h2 class="comments-thread__heading">
|
|
10
|
+
{{ _('Comments') }} ({{ comments|length }})
|
|
11
|
+
</h2>
|
|
12
|
+
|
|
13
|
+
{# Flash messages #}
|
|
14
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
15
|
+
{% if messages %}
|
|
16
|
+
<div class="comments-thread__flashes">
|
|
17
|
+
{% for category, message in messages %}
|
|
18
|
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
|
19
|
+
{% endfor %}
|
|
20
|
+
</div>
|
|
21
|
+
{% endif %}
|
|
22
|
+
{% endwith %}
|
|
23
|
+
|
|
24
|
+
{# Comment list #}
|
|
25
|
+
<ul class="comments-list">
|
|
26
|
+
{% for c in comments %}
|
|
27
|
+
<li class="comment">
|
|
28
|
+
<div class="comment__header">
|
|
29
|
+
<strong class="comment__author">{{ c.author_name }}</strong>
|
|
30
|
+
<time class="comment__date text-muted"
|
|
31
|
+
datetime="{{ c.created_at.isoformat() }}">
|
|
32
|
+
{{ c.created_at.strftime('%d %b %Y') }}
|
|
33
|
+
</time>
|
|
34
|
+
</div>
|
|
35
|
+
<p class="comment__content">{{ c.content }}</p>
|
|
36
|
+
</li>
|
|
37
|
+
{% else %}
|
|
38
|
+
<li class="comment comment--empty text-muted">
|
|
39
|
+
{{ _('No comments yet. Be the first!') }}
|
|
40
|
+
</li>
|
|
41
|
+
{% endfor %}
|
|
42
|
+
</ul>
|
|
43
|
+
|
|
44
|
+
{# Comment form — only when the post still accepts comments #}
|
|
45
|
+
{% if post.comment_status != 'closed' %}
|
|
46
|
+
<form class="comment-form"
|
|
47
|
+
method="POST"
|
|
48
|
+
action="{{ url_for('comments.create', post_id=post.id) }}">
|
|
49
|
+
|
|
50
|
+
<div class="form-group">
|
|
51
|
+
<label for="comment-author-name">{{ _('Name') }}</label>
|
|
52
|
+
<input type="text"
|
|
53
|
+
id="comment-author-name"
|
|
54
|
+
name="author_name"
|
|
55
|
+
class="form-control"
|
|
56
|
+
required>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="form-group">
|
|
60
|
+
<label for="comment-author-email">{{ _('Email (optional)') }}</label>
|
|
61
|
+
<input type="email"
|
|
62
|
+
id="comment-author-email"
|
|
63
|
+
name="author_email"
|
|
64
|
+
class="form-control">
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="form-group">
|
|
68
|
+
<label for="comment-content">{{ _('Comment') }}</label>
|
|
69
|
+
<textarea id="comment-content"
|
|
70
|
+
name="content"
|
|
71
|
+
class="form-control"
|
|
72
|
+
rows="4"
|
|
73
|
+
required></textarea>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{# Captcha widget (cloudflare / recaptcha) — empty when none installed #}
|
|
77
|
+
{% if captcha_widget is defined %}
|
|
78
|
+
{{ captcha_script() }}
|
|
79
|
+
{{ captcha_widget() }}
|
|
80
|
+
{% endif %}
|
|
81
|
+
|
|
82
|
+
<button type="submit" class="btn btn-primary">
|
|
83
|
+
{{ _('Post comment') }}
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
<p class="comment-form__note text-muted">
|
|
87
|
+
{{ _('Comments are moderated before appearing.') }}
|
|
88
|
+
</p>
|
|
89
|
+
</form>
|
|
90
|
+
{% endif %}
|
|
91
|
+
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|