From c15d5e2425ce6a216aca793986806df4b46486d4 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 24 Jun 2025 13:44:08 +0000 Subject: [PATCH] Add comment & review system for products & content --- make_post_sell/models/__init__.py | 1 + make_post_sell/models/comment.py | 300 ++++++++++++++++ make_post_sell/models/meta.py | 1 + make_post_sell/models/product.py | 43 +++ make_post_sell/models/shop.py | 23 ++ make_post_sell/routes.py | 9 + .../81d65d8605c2_merge_migration_heads.py | 52 +++ .../templates/comments/edit_comment.j2 | 29 ++ .../templates/comments/reply_comment.j2 | 32 ++ .../templates/comments/show_comments.j2 | 24 ++ make_post_sell/templates/content.j2 | 9 + make_post_sell/templates/product.j2 | 6 + make_post_sell/templates/shop_settings.j2 | 45 +++ make_post_sell/templates/snippets/comments.j2 | 98 ++++++ make_post_sell/views/comment.py | 323 ++++++++++++++++++ make_post_sell/views/content.py | 11 + make_post_sell/views/product.py | 11 + make_post_sell/views/shop.py | 41 ++- 18 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 make_post_sell/models/comment.py create mode 100644 make_post_sell/scripts/alembic/versions/81d65d8605c2_merge_migration_heads.py create mode 100644 make_post_sell/templates/comments/edit_comment.j2 create mode 100644 make_post_sell/templates/comments/reply_comment.j2 create mode 100644 make_post_sell/templates/comments/show_comments.j2 create mode 100644 make_post_sell/templates/snippets/comments.j2 create mode 100644 make_post_sell/views/comment.py diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 4e84bdb..9ce17e1 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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. diff --git a/make_post_sell/models/comment.py b/make_post_sell/models/comment.py new file mode 100644 index 0000000..9efe33e --- /dev/null +++ b/make_post_sell/models/comment.py @@ -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() \ No newline at end of file diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 814c7d2..094dffb 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -35,6 +35,7 @@ CLASS_TO_TABLE = { "ShopSearchRequest": "mps_shop_search_request", "StripeUserShop": "mps_stripe_user_shop", "Market": "mps_market", + "Comment": "mps_comment", } diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 28f91bd..bf65f6d 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -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): """ diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 30f36f1..3b3c98a 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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""" diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 9156e64..0ebe1be 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/81d65d8605c2_merge_migration_heads.py b/make_post_sell/scripts/alembic/versions/81d65d8605c2_merge_migration_heads.py new file mode 100644 index 0000000..78f6a26 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/81d65d8605c2_merge_migration_heads.py @@ -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') diff --git a/make_post_sell/templates/comments/edit_comment.j2 b/make_post_sell/templates/comments/edit_comment.j2 new file mode 100644 index 0000000..63e03ca --- /dev/null +++ b/make_post_sell/templates/comments/edit_comment.j2 @@ -0,0 +1,29 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
+
+ +

Edit Comment

+ +
+ + + + +
+
+ + + Cancel + +
+
+ +
+ +
+
+ +{%- endblock -%} \ No newline at end of file diff --git a/make_post_sell/templates/comments/reply_comment.j2 b/make_post_sell/templates/comments/reply_comment.j2 new file mode 100644 index 0000000..2dea33a --- /dev/null +++ b/make_post_sell/templates/comments/reply_comment.j2 @@ -0,0 +1,32 @@ +{% extends "base.j2" -%} + +{% block title %}Reply to Comment{% endblock %} + +{% block content %} +
+

Reply to Comment

+ +
+
+ {{ parent_comment.user.name if parent_comment.user else "Anonymous" }} + {{ parent_comment.ago_string }} +
+
+ {{ parent_comment.data_html | safe }} +
+
+ +
+
+ + + You can use Markdown formatting. +
+ +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/make_post_sell/templates/comments/show_comments.j2 b/make_post_sell/templates/comments/show_comments.j2 new file mode 100644 index 0000000..f0c8686 --- /dev/null +++ b/make_post_sell/templates/comments/show_comments.j2 @@ -0,0 +1,24 @@ +{% if shop.comments_enabled and comments %} +
+

Comments & Reviews

+ + {% for comment in comments %} + {% include 'comments/comment.j2' %} + {% endfor %} +
+{% endif %} + +{% if shop.comments_enabled %} +
+ {% 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 %} +

{{ error_msg }}

+ {% endif %} + {% else %} +

Sign in to leave a comment.

+ {% endif %} +
+{% endif %} \ No newline at end of file diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index ec157ff..4c74f01 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -89,4 +89,13 @@ +
+ +
+ + + {% include 'snippets/comments.j2' %} + +
+ {%- endblock -%} diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 1a67c0d..10dc8c2 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -157,6 +157,12 @@ {% endif %} +
+
+ + + {% include 'snippets/comments.j2' %} +
Back to shop diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index 12a804d..4e7bd9d 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -311,5 +311,50 @@ Existing sales honored for download buy purchasers. +
+
+ +
+
+ +
+ +

Comment System Settings 💬

+ + + Enable comments and reviews on product pages + +
+
+ + + Only allow customers who purchased the product to leave comments + +
+
+ + + Hide comments until approved by shop owner/editor + +
+
+ + Allow customers to leave comments and reviews on your product pages. + Comments appear on both product detail pages and content pages. + +
+
+ + + +
+
+ +
+ +
+ +
+ {%- endblock -%} diff --git a/make_post_sell/templates/snippets/comments.j2 b/make_post_sell/templates/snippets/comments.j2 new file mode 100644 index 0000000..4c859b9 --- /dev/null +++ b/make_post_sell/templates/snippets/comments.j2 @@ -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)))) %} +
+
+ {{ comment.user.name if comment.user else "Anonymous" }} + {{ comment.ago_string }} + {% if comment.title %} + {{ comment.title }} + {% endif %} + {% if not comment.approved %} + [Pending Approval] + {% endif %} +
+ +
+ {{ comment.data_html | safe }} +
+ +
+ {% if request.user.authenticated and not comment.is_locked %} + {% set can_comment, error_msg = comment.can_user_comment(request.user, shop) %} + {% if can_comment %} + Reply + {% endif %} + {% endif %} + + {% if request.user.authenticated and (request.user.id == comment.user_id or comment.can_user_moderate(request.user, shop)) %} + Edit + +
+ +
+ {% endif %} + + {% if request.user.authenticated and comment.can_user_moderate(request.user, shop) %} + {% if comment.approved %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% endif %} +
+ + {% if comment.children %} + {% for child in comment.children %} + {{ render_comment(child, shop, request, max_depth) }} + {% endfor %} + {% endif %} +
+ {% endif %} +{% endmacro %} + +{% if shop.comments_enabled %} +
+ {% if comments %} +

Comments & Reviews ({{ product.public_comment_count }})

+ + {% for comment in comments %} + {{ render_comment(comment, shop, request) }} + {% endfor %} + {% endif %} + + + {% 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 %} +
+

Leave a Comment

+
+ + + +
+ + +
+ +
+ +
+
+
+ {% else %} +

{{ error_msg }}

+ {% endif %} + {% else %} +

Sign in to leave a comment.

+ {% endif %} +
+{% endif %} \ No newline at end of file diff --git a/make_post_sell/views/comment.py b/make_post_sell/views/comment.py new file mode 100644 index 0000000..ab055e8 --- /dev/null +++ b/make_post_sell/views/comment.py @@ -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)) \ No newline at end of file diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index 3ea6b22..8602495 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -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, } diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 8bdef8f..6e9dc7c 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -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, } diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 62fb309..0f0cc6f 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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: