Merge branch 'remarkbox' into 'master'

Add comment & review system for products & content

See merge request engineering/make-post-sell/make_post_sell!43
This commit is contained in:
Russell Ballestrini 2025-06-24 13:44:08 +00:00
commit 66455dc45a
18 changed files with 1057 additions and 1 deletions

View file

@ -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.

View 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()

View file

@ -35,6 +35,7 @@ CLASS_TO_TABLE = {
"ShopSearchRequest": "mps_shop_search_request",
"StripeUserShop": "mps_stripe_user_shop",
"Market": "mps_market",
"Comment": "mps_comment",
}

View file

@ -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):
"""

View file

@ -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"""

View file

@ -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")

View file

@ -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')

View 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 -%}

View 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 %}

View 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 %}

View file

@ -89,4 +89,13 @@
</section>
<section class="one-column" style="max-width: 960px;">
<br/>
<!-- Comments Section -->
{% include 'snippets/comments.j2' %}
</section>
{%- endblock -%}

View file

@ -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>

View file

@ -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 -%}

View 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 %}

View 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))

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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: