Add comment & review system for products & content
This commit is contained in:
parent
6e0bad873a
commit
c15d5e2425
18 changed files with 1057 additions and 1 deletions
|
|
@ -24,6 +24,7 @@ from .inventory import *
|
|||
from .stripe_user_shop import *
|
||||
|
||||
from .shop_search_request import *
|
||||
from .comment import *
|
||||
|
||||
# run configure_mappers after defining all of the models
|
||||
# to ensure all relationships can be setup.
|
||||
|
|
|
|||
300
make_post_sell/models/comment.py
Normal file
300
make_post_sell/models/comment.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
from collections import (
|
||||
OrderedDict,
|
||||
deque,
|
||||
)
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func
|
||||
)
|
||||
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
||||
import uuid
|
||||
|
||||
from slugify import slugify
|
||||
|
||||
from make_post_sell.lib.time_funcs import (
|
||||
timestamp_to_datetime,
|
||||
timestamp_to_ago_string,
|
||||
)
|
||||
|
||||
from make_post_sell.lib.render import markdown_to_html
|
||||
|
||||
from .meta import Base, RBase
|
||||
from .meta import UUIDType
|
||||
from .meta import now_timestamp, foreign_key, get_object_by_id
|
||||
|
||||
import logging
|
||||
|
||||
try:
|
||||
unicode("")
|
||||
except:
|
||||
from six import u as unicode
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Comment(RBase, Base):
|
||||
"""
|
||||
Comment class acts like a linked list to support nested comments:
|
||||
|
||||
Product -> Comments -> Replies -> Sub-replies
|
||||
|
||||
The `parent_id` column is only used by child comments.
|
||||
|
||||
The class allows 3 layer nesting like StackOverflow or unlimited
|
||||
nesting like reddit. The frontend code determines the particular
|
||||
nesting strategy.
|
||||
|
||||
For more information checkout this section of the SQLAlchemy Docs:
|
||||
|
||||
http://docs.sqlalchemy.org/en/latest/orm/relationships.html#adjacency-list-relationships
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
root_id = Column(UUIDType, index=True, default=None)
|
||||
parent_id = Column(UUIDType, foreign_key("Comment", "id"), default=None)
|
||||
product_id = Column(UUIDType, foreign_key("Product", "id"), default=None)
|
||||
user_id = Column(UUIDType, foreign_key("User", "id"), default=None)
|
||||
|
||||
title = Column(Unicode(256), default=None)
|
||||
data = Column(UnicodeText, default=None)
|
||||
data_html = Column(UnicodeText, default=None)
|
||||
|
||||
# the depth of this comment in the thread's graph / tree.
|
||||
graph_depth = Column(Integer, nullable=False, default=-1)
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
disabled_timestamp = Column(BigInteger)
|
||||
disabled = Column(Boolean, default=False)
|
||||
verified = Column(Boolean, default=False)
|
||||
|
||||
# a locked thread (root comment) prevents new commenting.
|
||||
locked = Column(Boolean, default=False)
|
||||
# by default comments are approved. unless shop requires approval.
|
||||
approved = Column(Boolean, default=True)
|
||||
|
||||
ip_address = Column(Unicode(45), default=None)
|
||||
|
||||
# lazy='joined' performs a left join to reduce queries, it's magic.
|
||||
user = relationship(argument="User", lazy="joined")
|
||||
|
||||
# lazy='joined' performs a left join to reduce queries, it's magic.
|
||||
product = relationship(argument="Product", lazy="joined")
|
||||
|
||||
# lazy='dynamic' returns a query object instead of collection.
|
||||
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
|
||||
children = relationship(
|
||||
argument="Comment",
|
||||
lazy="dynamic",
|
||||
order_by="Comment.created_timestamp",
|
||||
foreign_keys=[parent_id],
|
||||
backref=backref("parent", remote_side=[id]),
|
||||
)
|
||||
|
||||
# lazy='dynamic' returns a query object instead of collection.
|
||||
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
|
||||
children_desc = relationship(
|
||||
argument="Comment",
|
||||
lazy="dynamic",
|
||||
order_by="desc(Comment.created_timestamp)",
|
||||
foreign_keys=[parent_id],
|
||||
overlaps="children,parent",
|
||||
)
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.id = uuid.uuid1()
|
||||
self.created_timestamp = now_timestamp()
|
||||
self.updated_timestamp = self.created_timestamp
|
||||
|
||||
def get_children(self, order_by="asc"):
|
||||
if order_by == "desc":
|
||||
return self.children_desc
|
||||
return self.children
|
||||
|
||||
@property
|
||||
def verified_children(self):
|
||||
return self.children.filter(Comment.verified == True)
|
||||
|
||||
@property
|
||||
def unverified_children(self):
|
||||
return self.children.filter(Comment.verified == False)
|
||||
|
||||
@property
|
||||
def path_to_root(self):
|
||||
"""The path from this comment to the root comment."""
|
||||
# TODO: memoize this result? how / when to deal with cache busting?
|
||||
comment = self
|
||||
path_to_root = []
|
||||
path_to_root.append(comment)
|
||||
while comment.parent is not None:
|
||||
comment = comment.parent
|
||||
path_to_root.append(comment)
|
||||
return path_to_root
|
||||
|
||||
@property
|
||||
def path_from_root(self):
|
||||
"""The path from the root comment to this comment."""
|
||||
return list(reversed(self.path_to_root))
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""The origin or top most parent comment without a parent."""
|
||||
if self.is_root:
|
||||
return self
|
||||
return self.dbsession.query(Comment).filter(Comment.id == self.root_id).one()
|
||||
|
||||
@property
|
||||
def is_root(self):
|
||||
if not self.parent_id or not self.root_id or self.root_id == self.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def recompute_depth(self):
|
||||
"""recompute the depth of a comment in the thread."""
|
||||
self.graph_depth = len(self.path_to_root) - 1
|
||||
|
||||
@property
|
||||
def depth(self):
|
||||
if self.graph_depth == -1:
|
||||
self.recompute_depth()
|
||||
return self.graph_depth
|
||||
|
||||
@property
|
||||
def ago_string(self):
|
||||
return timestamp_to_ago_string(self.created_timestamp)
|
||||
|
||||
@property
|
||||
def datetime(self):
|
||||
return timestamp_to_datetime(self.created_timestamp)
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
"""Check if this comment thread is locked (prevents new comments)."""
|
||||
if self.locked:
|
||||
return True
|
||||
if not self.is_root and self.root.locked:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_data(self, data):
|
||||
"""Set comment data and generate HTML."""
|
||||
self.data = data
|
||||
if data:
|
||||
self.data_html = markdown_to_html(data)
|
||||
else:
|
||||
self.data_html = None
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
def stamp_updated_timestamp(self):
|
||||
self.updated_timestamp = now_timestamp()
|
||||
|
||||
def disable(self):
|
||||
"""Disable comment (soft delete)."""
|
||||
self.disabled = True
|
||||
self.disabled_timestamp = now_timestamp()
|
||||
self.stamp_updated_timestamp()
|
||||
|
||||
def enable(self):
|
||||
"""Enable comment (undelete)."""
|
||||
self.disabled = False
|
||||
self.disabled_timestamp = None
|
||||
self.stamp_updated_timestamp()
|
||||
|
||||
|
||||
def has_user_purchased_product(self, user_id):
|
||||
"""Check if user has purchased this product (for purchase-required commenting)."""
|
||||
if not self.product:
|
||||
return False
|
||||
|
||||
# Check if user has purchased this product
|
||||
from .user_product import UserProduct
|
||||
user_product = self.dbsession.query(UserProduct).filter(
|
||||
UserProduct.user_id == user_id,
|
||||
UserProduct.product_id == self.product_id
|
||||
).first()
|
||||
|
||||
return user_product is not None
|
||||
|
||||
def can_user_comment(self, user, shop):
|
||||
"""Check if user can comment based on shop settings."""
|
||||
if not shop.comments_enabled:
|
||||
return False, "Comments are disabled for this shop"
|
||||
|
||||
if self.is_locked:
|
||||
return False, "This comment thread is locked"
|
||||
|
||||
if shop.comments_require_purchase and user:
|
||||
if not self.has_user_purchased_product(user.id):
|
||||
return False, "You must purchase this product to leave a comment"
|
||||
|
||||
return True, None
|
||||
|
||||
def can_user_comment(self, user, shop):
|
||||
"""Check if user can reply to this comment based on shop settings."""
|
||||
if not shop.comments_enabled:
|
||||
return False, "Comments are disabled for this shop"
|
||||
|
||||
if self.is_locked:
|
||||
return False, "This comment thread is locked"
|
||||
|
||||
if shop.comments_require_purchase and user:
|
||||
if not self.has_user_purchased_product(user.id):
|
||||
return False, "You must purchase this product to leave a comment"
|
||||
|
||||
return True, None
|
||||
|
||||
def can_user_moderate(self, user, shop):
|
||||
"""Check if user can moderate comments (approve, delete, etc)."""
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Shop owners and editors can moderate
|
||||
return shop.is_owner(user) or shop.is_editor(user)
|
||||
|
||||
|
||||
def get_comment_by_id(dbsession, comment_id):
|
||||
"""Get a comment by its ID."""
|
||||
return get_object_by_id(dbsession, comment_id, Comment)
|
||||
|
||||
|
||||
def get_comments_for_product(dbsession, product_id, shop=None, user=None):
|
||||
"""Get all root comments for a product, filtered by approval settings."""
|
||||
query = dbsession.query(Comment).filter(
|
||||
Comment.product_id == product_id,
|
||||
Comment.parent_id == None,
|
||||
Comment.disabled == False
|
||||
)
|
||||
|
||||
# If shop requires approval, only show approved comments to regular users
|
||||
if shop and shop.comments_require_approval:
|
||||
# Shop owners/editors can see all comments
|
||||
if not (user and (shop.is_owner(user) or shop.is_editor(user))):
|
||||
query = query.filter(Comment.approved == True)
|
||||
|
||||
return query.order_by(Comment.created_timestamp.desc()).all()
|
||||
|
||||
|
||||
def get_recent_comments(dbsession, shop_id=None, limit=10):
|
||||
"""Get recent comments, optionally filtered by shop."""
|
||||
query = dbsession.query(Comment).filter(Comment.approved == True)
|
||||
|
||||
if shop_id:
|
||||
# Join with Product to filter by shop
|
||||
from .product import Product
|
||||
query = query.join(Product).filter(Product.shop_id == shop_id)
|
||||
|
||||
return query.order_by(Comment.created_timestamp.desc()).limit(limit).all()
|
||||
|
||||
|
||||
def get_total_comment_count_for_product(dbsession, product_id, shop=None, user=None):
|
||||
"""Get total count of all approved, non-disabled comments (root + replies) for a product."""
|
||||
query = dbsession.query(Comment).filter(
|
||||
Comment.product_id == product_id,
|
||||
Comment.disabled == False,
|
||||
Comment.approved == True
|
||||
)
|
||||
|
||||
return query.count()
|
||||
|
|
@ -35,6 +35,7 @@ CLASS_TO_TABLE = {
|
|||
"ShopSearchRequest": "mps_shop_search_request",
|
||||
"StripeUserShop": "mps_stripe_user_shop",
|
||||
"Market": "mps_market",
|
||||
"Comment": "mps_comment",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,14 @@ class Product(RBase, Base):
|
|||
back_populates="product",
|
||||
)
|
||||
|
||||
# lazy="dynamic" returns a query object for all comments
|
||||
comments = relationship(
|
||||
argument="Comment",
|
||||
lazy="dynamic",
|
||||
order_by="Comment.created_timestamp",
|
||||
back_populates="product",
|
||||
)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy(
|
||||
"product_users", "user", creator=lambda u: UserProduct(user=u)
|
||||
|
|
@ -532,6 +540,41 @@ class Product(RBase, Base):
|
|||
if s3_client and bucket_name and old_visibility != new_visibility:
|
||||
self.update_s3_acls(s3_client, bucket_name)
|
||||
|
||||
def has_user_purchased_product(self, user_id):
|
||||
"""Check if user has purchased this product (for purchase-required commenting)."""
|
||||
# Check if user has purchased this product
|
||||
user_product = self.dbsession.query(UserProduct).filter(
|
||||
UserProduct.user_id == user_id,
|
||||
UserProduct.product_id == self.id
|
||||
).first()
|
||||
|
||||
return user_product is not None
|
||||
|
||||
def can_user_comment(self, user, shop):
|
||||
"""Check if user can comment on this product based on shop settings."""
|
||||
if not shop.comments_enabled:
|
||||
return False, "Comments are disabled for this shop"
|
||||
|
||||
if shop.comments_require_purchase and user:
|
||||
if not self.has_user_purchased_product(user.id):
|
||||
return False, "You must purchase this product to leave a comment"
|
||||
|
||||
return True, None
|
||||
|
||||
@property
|
||||
def public_comments(self):
|
||||
"""Return only approved, non-disabled comments for public display."""
|
||||
from .comment import Comment
|
||||
return self.comments.filter(
|
||||
Comment.approved == True,
|
||||
Comment.disabled == False
|
||||
)
|
||||
|
||||
@property
|
||||
def public_comment_count(self):
|
||||
"""Return count of approved, non-disabled comments."""
|
||||
return self.public_comments.count()
|
||||
|
||||
|
||||
def get_all_products(dbsession):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ class Shop(RBase, Base):
|
|||
maint_mode = Column(Boolean, default=False)
|
||||
favicon = Column(Boolean, default=False)
|
||||
logo_banner = Column(Boolean, default=False)
|
||||
|
||||
# Comment/Review system settings
|
||||
comments_enabled = Column(Boolean, default=True)
|
||||
comments_require_purchase = Column(Boolean, default=False)
|
||||
comments_require_approval = Column(Boolean, default=False)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
|
||||
|
|
@ -171,6 +176,24 @@ class Shop(RBase, Base):
|
|||
"""Returns a list of user objects who have the member role on this shop."""
|
||||
return self.editors + [us.user for us in self.shop_users if us.is_member]
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if the given user is an owner of this shop."""
|
||||
if not user:
|
||||
return False
|
||||
return user in self.owners
|
||||
|
||||
def is_editor(self, user):
|
||||
"""Check if the given user is an editor of this shop."""
|
||||
if not user:
|
||||
return False
|
||||
return user in self.editors
|
||||
|
||||
def is_member(self, user):
|
||||
"""Check if the given user is a member of this shop."""
|
||||
if not user:
|
||||
return False
|
||||
return user in self.members
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
"""return slug from name"""
|
||||
|
|
|
|||
|
|
@ -132,3 +132,12 @@ def includeme(config):
|
|||
config.add_route("product_edit", "/p/{product_id}/edit")
|
||||
config.add_route("product_edit2", "/p/{product_id}/{slug:.*}/edit")
|
||||
config.add_route("product_slug", "/p/{product_id}/{slug:.*}")
|
||||
|
||||
# comment routes.
|
||||
config.add_route("comment_new", "/comments/new")
|
||||
config.add_route("comment_reply", "/comments/{comment_id}/reply")
|
||||
config.add_route("comment_edit", "/comments/{comment_id}/edit")
|
||||
config.add_route("comment_delete", "/comments/{comment_id}/delete")
|
||||
config.add_route("comment_undelete", "/comments/{comment_id}/undelete")
|
||||
config.add_route("comment_approve", "/comments/{comment_id}/approve")
|
||||
config.add_route("comment_unapprove", "/comments/{comment_id}/unapprove")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"""merge migration heads and add comment settings
|
||||
|
||||
Revision ID: 81d65d8605c2
|
||||
Revises: 1b3ecdde9e65, fd9f7e2f2b78
|
||||
Create Date: 2025-06-24 08:09:33.656088
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '81d65d8605c2'
|
||||
down_revision = ('1b3ecdde9e65', 'fd9f7e2f2b78')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add comment columns that may have been missed in the merge
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# Check if columns exist before adding them
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check if comments_enabled exists
|
||||
try:
|
||||
conn.execute(text("SELECT comments_enabled FROM mps_shop LIMIT 1"))
|
||||
except:
|
||||
op.add_column('mps_shop', sa.Column('comments_enabled', sa.Boolean(), nullable=True, server_default="1"))
|
||||
|
||||
# Check if comments_require_purchase exists
|
||||
try:
|
||||
conn.execute(text("SELECT comments_require_purchase FROM mps_shop LIMIT 1"))
|
||||
except:
|
||||
op.add_column('mps_shop', sa.Column('comments_require_purchase', sa.Boolean(), nullable=True, server_default="0"))
|
||||
|
||||
# Check if comments_require_approval exists
|
||||
try:
|
||||
conn.execute(text("SELECT comments_require_approval FROM mps_shop LIMIT 1"))
|
||||
except:
|
||||
op.add_column('mps_shop', sa.Column('comments_require_approval', sa.Boolean(), nullable=True, server_default="0"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('mps_shop', 'comments_require_approval')
|
||||
op.drop_column('mps_shop', 'comments_require_purchase')
|
||||
op.drop_column('mps_shop', 'comments_enabled')
|
||||
29
make_post_sell/templates/comments/edit_comment.j2
Normal file
29
make_post_sell/templates/comments/edit_comment.j2
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="one-column">
|
||||
<section class="well">
|
||||
|
||||
<h3>Edit Comment</h3>
|
||||
|
||||
<form method="post" action="/comments/{{ comment.id }}/edit">
|
||||
|
||||
<label for="comment_data">Comment:</label>
|
||||
<textarea name="data" id="comment_data" rows="6" cols="60" required>{{ comment.data }}</textarea>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Update Comment" />
|
||||
<a href="{{ product.absolute_url(request) }}" class="mps-button">Cancel</a>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{%- endblock -%}
|
||||
32
make_post_sell/templates/comments/reply_comment.j2
Normal file
32
make_post_sell/templates/comments/reply_comment.j2
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block title %}Reply to Comment{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Reply to Comment</h2>
|
||||
|
||||
<div class="original-comment" style="background-color: #f5f5f5; padding: 15px; border-left: 4px solid #007cba; margin-bottom: 20px;">
|
||||
<div class="comment-header">
|
||||
<strong>{{ parent_comment.user.name if parent_comment.user else "Anonymous" }}</strong>
|
||||
<span class="comment-date">{{ parent_comment.ago_string }}</span>
|
||||
</div>
|
||||
<div class="comment-content">
|
||||
{{ parent_comment.data_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ request.route_url('comment_reply', comment_id=parent_comment.id) }}">
|
||||
<div class="form-group">
|
||||
<label for="comment_data">Your Reply:</label>
|
||||
<textarea name="data" id="comment_data" rows="6" cols="80" required placeholder="Write your reply..."></textarea>
|
||||
<small>You can use Markdown formatting.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<input type="submit" value="Post Reply" class="mps-submit" />
|
||||
<a href="{{ product.absolute_url(request) }}" class="mps-cancel">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
make_post_sell/templates/comments/show_comments.j2
Normal file
24
make_post_sell/templates/comments/show_comments.j2
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{% if shop.comments_enabled and comments %}
|
||||
<div class="comments-section">
|
||||
<h3>Comments & Reviews</h3>
|
||||
|
||||
{% for comment in comments %}
|
||||
{% include 'comments/comment.j2' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if shop.comments_enabled %}
|
||||
<div class="comment-form-section">
|
||||
{% if request.user.authenticated %}
|
||||
{% set can_comment, error_msg = comment.can_user_comment(request.user, shop) if comment else (True, None) %}
|
||||
{% if can_comment %}
|
||||
{% include 'comments/comment_form.j2' %}
|
||||
{% else %}
|
||||
<p class="comment-error">{{ error_msg }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p><a href="/join-or-log-in">Sign in</a> to leave a comment.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -89,4 +89,13 @@
|
|||
|
||||
</section>
|
||||
|
||||
<section class="one-column" style="max-width: 960px;">
|
||||
|
||||
<br/>
|
||||
|
||||
<!-- Comments Section -->
|
||||
{% include 'snippets/comments.j2' %}
|
||||
|
||||
</section>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@
|
|||
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<!-- Comments Section -->
|
||||
{% include 'snippets/comments.j2' %}
|
||||
|
||||
<br/>
|
||||
<a href="/" class="product-edit-button mps-button">Back to shop</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -311,5 +311,50 @@ Existing sales honored for download buy purchasers.
|
|||
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings">
|
||||
|
||||
<h3>Comment System Settings 💬</h3>
|
||||
|
||||
<input type="checkbox" name="comments-enabled-checkbox" id="comments-enabled-checkbox" {% if request.shop.comments_enabled %}checked{% endif %}></input>
|
||||
<b>Enable comments and reviews on product pages</b>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="checkbox" name="comments-require-purchase-checkbox" id="comments-require-purchase-checkbox" {% if request.shop.comments_require_purchase %}checked{% endif %}></input>
|
||||
<b>Only allow customers who purchased the product to leave comments</b>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="checkbox" name="comments-require-approval-checkbox" id="comments-require-approval-checkbox" {% if request.shop.comments_require_approval %}checked{% endif %}></input>
|
||||
<b>Hide comments until approved by shop owner/editor</b>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Allow customers to leave comments and reviews on your product pages.
|
||||
Comments appear on both product detail pages and content pages.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
98
make_post_sell/templates/snippets/comments.j2
Normal file
98
make_post_sell/templates/snippets/comments.j2
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{% macro render_comment(comment, shop, request, max_depth=5) %}
|
||||
{% if comment.depth <= max_depth and (comment.approved or (request.user.authenticated and (shop.is_owner(request.user) or shop.is_editor(request.user)))) %}
|
||||
<div id="comment-{{ comment.id }}" class="comment{% if comment.parent_id %} reply{% endif %}" style="margin-left: {{ comment.depth * 20 }}px;{% if comment.parent_id %} border-left: 2px solid #ddd; padding-left: 10px;{% endif %}">
|
||||
<div class="comment-header">
|
||||
<strong>{{ comment.user.name if comment.user else "Anonymous" }}</strong>
|
||||
<span class="comment-date">{{ comment.ago_string }}</span>
|
||||
{% if comment.title %}
|
||||
<span class="comment-title">{{ comment.title }}</span>
|
||||
{% endif %}
|
||||
{% if not comment.approved %}
|
||||
<span class="comment-status" style="color: orange;">[Pending Approval]</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comment-content">
|
||||
{{ comment.data_html | safe }}
|
||||
</div>
|
||||
|
||||
<div class="comment-actions">
|
||||
{% if request.user.authenticated and not comment.is_locked %}
|
||||
{% set can_comment, error_msg = comment.can_user_comment(request.user, shop) %}
|
||||
{% if can_comment %}
|
||||
<a href="/comments/{{ comment.id }}/reply" class="reply-link">Reply</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.authenticated and (request.user.id == comment.user_id or comment.can_user_moderate(request.user, shop)) %}
|
||||
<a href="/comments/{{ comment.id }}/edit" class="edit-link">Edit</a>
|
||||
|
||||
<form method="post" action="/comments/{{ comment.id }}/delete" style="display: inline;">
|
||||
<input type="submit" value="Delete" class="delete-link" onclick="return confirm('Are you sure you want to delete this comment?')" />
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.authenticated and comment.can_user_moderate(request.user, shop) %}
|
||||
{% if comment.approved %}
|
||||
<form method="post" action="/comments/{{ comment.id }}/unapprove" style="display: inline;">
|
||||
<input type="submit" value="Unapprove" class="moderate-link" />
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/comments/{{ comment.id }}/approve" style="display: inline;">
|
||||
<input type="submit" value="Approve" class="moderate-link" />
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if comment.children %}
|
||||
{% for child in comment.children %}
|
||||
{{ render_comment(child, shop, request, max_depth) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% if shop.comments_enabled %}
|
||||
<div class="comments-section">
|
||||
{% if comments %}
|
||||
<h3>Comments & Reviews ({{ product.public_comment_count }})</h3>
|
||||
|
||||
{% for comment in comments %}
|
||||
{{ render_comment(comment, shop, request) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Comment Form -->
|
||||
{% if request.user.authenticated %}
|
||||
{% set can_comment, error_msg = (True, None) %}
|
||||
{% if product %}
|
||||
{% set can_comment, error_msg = product.can_user_comment(request.user, shop) %}
|
||||
{% endif %}
|
||||
|
||||
{% if can_comment %}
|
||||
<div class="comment-form">
|
||||
<h4>Leave a Comment</h4>
|
||||
<form method="post" action="/comments/new">
|
||||
<input type="hidden" name="product_id" value="{{ product.id if product else '' }}" />
|
||||
<input type="hidden" name="parent_id" value="" />
|
||||
|
||||
<div>
|
||||
<label for="comment_data">Comment:</label>
|
||||
<textarea name="data" id="comment_data" rows="4" cols="50" required></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Post Comment" class="mps-submit" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="comment-error">{{ error_msg }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p><a href="/join-or-log-in">Sign in</a> to leave a comment.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
323
make_post_sell/views/comment.py
Normal file
323
make_post_sell/views/comment.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound, HTTPForbidden
|
||||
|
||||
from . import (
|
||||
get_referer_or_home,
|
||||
user_required,
|
||||
)
|
||||
|
||||
from ..models.comment import (
|
||||
Comment,
|
||||
get_comment_by_id,
|
||||
get_comments_for_product,
|
||||
)
|
||||
|
||||
from ..models.product import get_product_by_id
|
||||
|
||||
from ..models.user import get_user_by_id
|
||||
|
||||
from ..lib.render import markdown_to_html
|
||||
|
||||
|
||||
@view_config(route_name="comment_new", request_method="POST")
|
||||
@user_required()
|
||||
def comment_new(request):
|
||||
"""Create a new comment."""
|
||||
product_id = request.params.get("product_id")
|
||||
parent_id = request.params.get("parent_id") # For replies
|
||||
data = request.params.get("data", "").strip()
|
||||
|
||||
if not product_id or not data:
|
||||
request.session.flash(("Comment content is required", "error"))
|
||||
return HTTPFound(location=get_referer_or_home(request))
|
||||
|
||||
# Get product and validate
|
||||
product = get_product_by_id(request.dbsession, product_id)
|
||||
if not product:
|
||||
return HTTPNotFound("Product not found")
|
||||
|
||||
shop = product.shop
|
||||
|
||||
# Check if user can comment
|
||||
can_comment, error_msg = True, None
|
||||
|
||||
if not shop.comments_enabled:
|
||||
can_comment, error_msg = False, "Comments are disabled for this shop"
|
||||
elif shop.comments_require_purchase:
|
||||
# Check if user has purchased this product
|
||||
from ..models.user_product import UserProduct
|
||||
user_product = request.dbsession.query(UserProduct).filter(
|
||||
UserProduct.user_id == request.user.id,
|
||||
UserProduct.product_id == product_id
|
||||
).first()
|
||||
|
||||
if not user_product:
|
||||
can_comment, error_msg = False, "You must purchase this product to leave a comment"
|
||||
|
||||
if not can_comment:
|
||||
request.session.flash((error_msg, "error"))
|
||||
return HTTPFound(location=get_referer_or_home(request))
|
||||
|
||||
# Create comment
|
||||
comment = Comment()
|
||||
comment.product_id = product_id
|
||||
comment.user_id = request.user.id
|
||||
comment.set_data(data)
|
||||
|
||||
# Set approval status based on shop settings
|
||||
if shop.comments_require_approval:
|
||||
# Shop owners and editors get auto-approved
|
||||
if shop.is_owner(request.user) or shop.is_editor(request.user):
|
||||
comment.approved = True
|
||||
else:
|
||||
comment.approved = False
|
||||
else:
|
||||
# No approval required
|
||||
comment.approved = True
|
||||
|
||||
# Handle reply (set parent and root)
|
||||
if parent_id:
|
||||
parent_comment = get_comment_by_id(request.dbsession, parent_id)
|
||||
if parent_comment and parent_comment.product_id == product_id:
|
||||
comment.parent_id = parent_id
|
||||
comment.root_id = parent_comment.root_id or parent_comment.id
|
||||
comment.recompute_depth()
|
||||
else:
|
||||
# Root comment
|
||||
comment.root_id = comment.id
|
||||
comment.graph_depth = 0
|
||||
|
||||
request.dbsession.add(comment)
|
||||
request.dbsession.flush()
|
||||
|
||||
if comment.approved:
|
||||
request.session.flash(("Comment posted successfully", "success"))
|
||||
else:
|
||||
request.session.flash(("Comment submitted for approval", "success"))
|
||||
|
||||
# Redirect to the product page with fragment to scroll to the new comment
|
||||
product_url = product.absolute_url(request)
|
||||
return HTTPFound(location=f"{product_url}#comment-{comment.id}")
|
||||
|
||||
|
||||
@view_config(route_name="comment_reply", renderer="comments/reply_comment.j2")
|
||||
@user_required()
|
||||
def comment_reply_get(request):
|
||||
"""Show reply to comment form."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
parent_comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not parent_comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check if user can reply
|
||||
shop = parent_comment.product.shop
|
||||
can_comment, error_msg = parent_comment.can_user_comment(request.user, shop)
|
||||
|
||||
if not can_comment:
|
||||
request.session.flash((error_msg, "error"))
|
||||
return HTTPFound(location=parent_comment.product.absolute_url(request))
|
||||
|
||||
return {
|
||||
"parent_comment": parent_comment,
|
||||
"product": parent_comment.product,
|
||||
"shop": shop,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="comment_reply", request_method="POST")
|
||||
@user_required()
|
||||
def comment_reply_post(request):
|
||||
"""Create a reply to a comment."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
parent_comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not parent_comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check if user can reply
|
||||
shop = parent_comment.product.shop
|
||||
can_comment, error_msg = parent_comment.can_user_comment(request.user, shop)
|
||||
|
||||
if not can_comment:
|
||||
request.session.flash((error_msg, "error"))
|
||||
return HTTPFound(location=parent_comment.product.absolute_url(request))
|
||||
|
||||
data = request.params.get("data", "").strip()
|
||||
|
||||
if not data:
|
||||
request.session.flash(("Comment content is required", "error"))
|
||||
return HTTPFound(location=request.route_url("comment_reply", comment_id=comment_id))
|
||||
|
||||
# Create reply comment
|
||||
comment = Comment()
|
||||
comment.product_id = parent_comment.product_id
|
||||
comment.user_id = request.user.id
|
||||
comment.parent_id = parent_comment.id
|
||||
comment.root_id = parent_comment.root_id or parent_comment.id
|
||||
comment.set_data(data)
|
||||
comment.recompute_depth()
|
||||
|
||||
# Set approval status based on shop settings
|
||||
if shop.comments_require_approval:
|
||||
# Shop owners and editors get auto-approved
|
||||
if shop.is_owner(request.user) or shop.is_editor(request.user):
|
||||
comment.approved = True
|
||||
else:
|
||||
comment.approved = False
|
||||
else:
|
||||
# No approval required
|
||||
comment.approved = True
|
||||
|
||||
request.dbsession.add(comment)
|
||||
request.dbsession.flush()
|
||||
|
||||
if comment.approved:
|
||||
request.session.flash(("Reply posted successfully", "success"))
|
||||
else:
|
||||
request.session.flash(("Reply submitted for approval", "success"))
|
||||
|
||||
# Redirect to the product page with fragment to scroll to the new comment
|
||||
product_url = parent_comment.product.absolute_url(request)
|
||||
return HTTPFound(location=f"{product_url}#comment-{comment.id}")
|
||||
|
||||
|
||||
@view_config(route_name="comment_edit", renderer="comments/edit_comment.j2")
|
||||
@user_required()
|
||||
def comment_edit_get(request):
|
||||
"""Show edit comment form."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions
|
||||
shop = comment.product.shop
|
||||
if comment.user_id != request.user.id and not comment.can_user_moderate(request.user, shop):
|
||||
return HTTPForbidden("You can only edit your own comments")
|
||||
|
||||
return {
|
||||
"comment": comment,
|
||||
"product": comment.product,
|
||||
"shop": shop,
|
||||
}
|
||||
|
||||
|
||||
@view_config(route_name="comment_edit", request_method="POST")
|
||||
@user_required()
|
||||
def comment_edit_post(request):
|
||||
"""Update a comment."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions
|
||||
shop = comment.product.shop
|
||||
if comment.user_id != request.user.id and not comment.can_user_moderate(request.user, shop):
|
||||
return HTTPForbidden("You can only edit your own comments")
|
||||
|
||||
data = request.params.get("data", "").strip()
|
||||
|
||||
if not data:
|
||||
request.session.flash(("Comment content is required", "error"))
|
||||
return HTTPFound(location=request.route_url("comment_edit", comment_id=comment_id))
|
||||
|
||||
comment.set_data(data)
|
||||
request.session.flash(("Comment updated successfully", "success"))
|
||||
|
||||
# Redirect back to product page with fragment to scroll to the edited comment
|
||||
product_url = comment.product.absolute_url(request)
|
||||
return HTTPFound(location=f"{product_url}#comment-{comment.id}")
|
||||
|
||||
|
||||
@view_config(route_name="comment_delete", request_method="POST")
|
||||
@user_required()
|
||||
def comment_delete(request):
|
||||
"""Delete a comment."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions
|
||||
shop = comment.product.shop
|
||||
if comment.user_id != request.user.id and not comment.can_user_moderate(request.user, shop):
|
||||
return HTTPForbidden("You can only delete your own comments")
|
||||
|
||||
product_url = comment.product.absolute_url(request)
|
||||
|
||||
# Soft delete using disable method
|
||||
comment.disable()
|
||||
|
||||
request.session.flash(("Comment deleted successfully", "success"))
|
||||
return HTTPFound(location=product_url)
|
||||
|
||||
|
||||
@view_config(route_name="comment_approve", request_method="POST")
|
||||
@user_required()
|
||||
def comment_approve(request):
|
||||
"""Approve a comment (shop owners and editors only)."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions - shop owners and editors can approve
|
||||
shop = comment.product.shop
|
||||
if not comment.can_user_moderate(request.user, shop):
|
||||
return HTTPForbidden("Only shop owners and editors can approve comments")
|
||||
|
||||
comment.approved = True
|
||||
comment.stamp_updated_timestamp()
|
||||
|
||||
request.session.flash(("Comment approved", "success"))
|
||||
return HTTPFound(location=get_referer_or_home(request))
|
||||
|
||||
|
||||
@view_config(route_name="comment_unapprove", request_method="POST")
|
||||
@user_required()
|
||||
def comment_unapprove(request):
|
||||
"""Unapprove a comment (shop owners and editors only)."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions - shop owners and editors can unapprove
|
||||
shop = comment.product.shop
|
||||
if not comment.can_user_moderate(request.user, shop):
|
||||
return HTTPForbidden("Only shop owners and editors can moderate comments")
|
||||
|
||||
comment.approved = False
|
||||
comment.stamp_updated_timestamp()
|
||||
|
||||
request.session.flash(("Comment unapproved", "success"))
|
||||
return HTTPFound(location=get_referer_or_home(request))
|
||||
|
||||
|
||||
@view_config(route_name="comment_undelete", request_method="POST")
|
||||
@user_required()
|
||||
def comment_undelete(request):
|
||||
"""Undelete a comment (shop owners and editors only)."""
|
||||
comment_id = request.matchdict["comment_id"]
|
||||
comment = get_comment_by_id(request.dbsession, comment_id)
|
||||
|
||||
if not comment:
|
||||
return HTTPNotFound("Comment not found")
|
||||
|
||||
# Check permissions - comment owner, shop owners, or shop editors can undelete
|
||||
shop = comment.product.shop
|
||||
if comment.user_id != request.user.uuid_str and not (shop.is_owner(request.user) or shop.is_editor(request.user)):
|
||||
return HTTPForbidden("Only comment owners, shop owners, and shop editors can undelete comments")
|
||||
|
||||
# Re-enable the comment
|
||||
comment.enable()
|
||||
|
||||
request.session.flash(("Comment restored successfully", "success"))
|
||||
return HTTPFound(location=comment.product.absolute_url(request))
|
||||
|
|
@ -65,8 +65,19 @@ def content(request):
|
|||
if product.has_product_file:
|
||||
product_size = product.human_product_file_bytes
|
||||
|
||||
# Load comments for the content
|
||||
from ..models.comment import get_comments_for_product
|
||||
comments = get_comments_for_product(
|
||||
request.dbsession,
|
||||
product.id,
|
||||
shop=product.shop,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_size": product_size,
|
||||
"signed_get_object_url": signed_get_object_url,
|
||||
"comments": comments,
|
||||
"shop": product.shop,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,10 +84,21 @@ def product(request):
|
|||
if product.has_product_file:
|
||||
product_size = product.human_product_file_bytes
|
||||
|
||||
# Load comments for the product
|
||||
from ..models.comment import get_comments_for_product
|
||||
comments = get_comments_for_product(
|
||||
request.dbsession,
|
||||
product.id,
|
||||
shop=product.shop,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"product_size": product_size,
|
||||
"signed_get_object_url": signed_get_object_url,
|
||||
"comments": comments,
|
||||
"shop": product.shop,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ def shop_users(request):
|
|||
email_to_invite = request.params.get("email-to-invite", "")
|
||||
role_id = request.params.get("role-id")
|
||||
|
||||
_email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$")
|
||||
_email_regex = re.compile(r"^[^@]+@[^@]+\.[^.@]+$")
|
||||
|
||||
if "submit" in request.params:
|
||||
if not email_to_invite:
|
||||
|
|
@ -398,6 +398,14 @@ def shop_settings(request):
|
|||
# maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode))
|
||||
maint_mode_checkbox = request.params.get("maint-mode-checkbox", "off")
|
||||
maint_mode = checkbox_to_bool(maint_mode_checkbox)
|
||||
|
||||
# Comment system settings
|
||||
comments_enabled_checkbox = request.params.get("comments-enabled-checkbox", "off")
|
||||
comments_enabled = checkbox_to_bool(comments_enabled_checkbox)
|
||||
comments_require_purchase_checkbox = request.params.get("comments-require-purchase-checkbox", "off")
|
||||
comments_require_purchase = checkbox_to_bool(comments_require_purchase_checkbox)
|
||||
comments_require_approval_checkbox = request.params.get("comments-require-approval-checkbox", "off")
|
||||
comments_require_approval = checkbox_to_bool(comments_require_approval_checkbox)
|
||||
|
||||
s3_webhook_key = request.params.get("key")
|
||||
s3_webhook_bucket = request.params.get("bucket")
|
||||
|
|
@ -546,6 +554,37 @@ def shop_settings(request):
|
|||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle comment system settings
|
||||
if shop.comments_enabled != comments_enabled:
|
||||
shop.comments_enabled = comments_enabled
|
||||
status = "enabled" if comments_enabled else "disabled"
|
||||
request.session.flash(
|
||||
(
|
||||
f"Comments {status} for this shop",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
if shop.comments_require_purchase != comments_require_purchase:
|
||||
shop.comments_require_purchase = comments_require_purchase
|
||||
status = "enabled" if comments_require_purchase else "disabled"
|
||||
request.session.flash(
|
||||
(
|
||||
f"Purchase requirement for comments {status}",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
if shop.comments_require_approval != comments_require_approval:
|
||||
shop.comments_require_approval = comments_require_approval
|
||||
status = "enabled" if comments_require_approval else "disabled"
|
||||
request.session.flash(
|
||||
(
|
||||
f"Comment approval requirement {status}",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: Dry out this block, it's a copy pasta from views/product.py
|
||||
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue