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
+
+
+
+
{{ error_msg }}
+ {% endif %} + {% else %} +Sign in to leave a comment.
+ {% endif %} +{{ error_msg }}
+ {% endif %} + {% else %} +Sign in to leave a comment.
+ {% endif %} +