Add email digest subscriptions, @mentions, and RSS autodiscovery
Implement "Stay in the Loop" feature set: - ShopSubscription model for daily/weekly email digest subscriptions - Subscribe/verify/unsubscribe views with token-based email verification - Digest sender console script for cron-based email delivery - @mention parsing in comments with immediate email notifications - RSS/Atom autodiscovery link tags in page head - Shop settings toggle for enabling/disabling subscriptions
This commit is contained in:
parent
fe237ba350
commit
8171960847
19 changed files with 1287 additions and 0 deletions
200
make_post_sell/lib/digest_sender.py
Normal file
200
make_post_sell/lib/digest_sender.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""
|
||||
Console script to send email digests to shop subscribers.
|
||||
|
||||
Usage:
|
||||
digest_sender data/development.ini --frequency daily
|
||||
digest_sender data/development.ini --frequency weekly --dry-run
|
||||
|
||||
Cron setup:
|
||||
0 8 * * * /path/to/env/bin/digest_sender /path/to/production.ini --frequency daily
|
||||
0 8 * * 1 /path/to/env/bin/digest_sender /path/to/production.ini --frequency weekly
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from pyramid.paster import bootstrap, setup_logging
|
||||
|
||||
from ..models.meta import now_timestamp
|
||||
from ..models.shop import Shop
|
||||
from ..models.product import Product
|
||||
from ..models.shop_subscription import (
|
||||
FREQUENCY_DAILY,
|
||||
FREQUENCY_WEEKLY,
|
||||
get_verified_subscriptions_for_shop,
|
||||
)
|
||||
from .mail import send_email
|
||||
from .mail_messages import DIGEST_TEXT, DIGEST_HTML
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
p = argparse.ArgumentParser(
|
||||
description="Send email digests to shop subscribers"
|
||||
)
|
||||
p.add_argument("config_uri", help="Pyramid config file, e.g. development.ini")
|
||||
p.add_argument(
|
||||
"--frequency",
|
||||
choices=["daily", "weekly"],
|
||||
required=True,
|
||||
help="Which frequency of subscribers to process",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Log what would be sent without actually sending",
|
||||
)
|
||||
return p.parse_args(argv[1:])
|
||||
|
||||
|
||||
def get_new_items_for_subscriber(dbsession, shop, since_timestamp):
|
||||
"""Query new public products created after since_timestamp."""
|
||||
return (
|
||||
dbsession.query(Product)
|
||||
.filter(
|
||||
Product.shop_id == shop.id,
|
||||
Product.visibility == 1,
|
||||
Product.created_timestamp > since_timestamp,
|
||||
)
|
||||
.order_by(Product.created_timestamp.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def build_digest_text(shop_name, items, unsubscribe_url):
|
||||
"""Build plain text digest email."""
|
||||
item_lines = []
|
||||
for item in items:
|
||||
label = "Product" if item.is_sellable else "Content"
|
||||
item_lines.append(f"- [{label}] {item.title}")
|
||||
items_text = "\n".join(item_lines)
|
||||
return DIGEST_TEXT.format(shop_name, items_text, unsubscribe_url)
|
||||
|
||||
|
||||
def build_digest_html(shop_name, items, unsubscribe_url, get_endpoint=""):
|
||||
"""Build HTML digest email."""
|
||||
item_parts = []
|
||||
for item in items:
|
||||
label = "Product" if item.is_sellable else "Content"
|
||||
thumbnail = ""
|
||||
if "thumbnail1" in item.extensions and get_endpoint:
|
||||
thumbnail = (
|
||||
f'<img src="{get_endpoint}/{item.s3_path}/thumbnail1?ts={item.updated_timestamp}" '
|
||||
f'style="border: 1px solid #ddd; border-radius: 4px; max-width: 120px; '
|
||||
f'max-height: 120px; width: auto; height: auto;" /><br/>'
|
||||
)
|
||||
item_parts.append(
|
||||
f'<p>{thumbnail}<b>{item.title}</b> <span style="color: #888;">({label})</span></p>'
|
||||
)
|
||||
items_html = "\n".join(item_parts)
|
||||
subject = f"New from {shop_name}"
|
||||
return DIGEST_HTML.format(subject, shop_name, items_html, unsubscribe_url)
|
||||
|
||||
|
||||
def run(env, frequency_str, dry_run=False):
|
||||
"""Process all shops and send digests."""
|
||||
frequency = FREQUENCY_DAILY if frequency_str == "daily" else FREQUENCY_WEEKLY
|
||||
request = env["request"]
|
||||
dbsession = request.dbsession
|
||||
settings = request.registry.settings
|
||||
|
||||
# Email settings
|
||||
default_sender = "no-reply@makepostsell.com"
|
||||
sender_email = settings.get("app.email.sender", default_sender)
|
||||
relay = settings.get("app.email.relay", "localhost")
|
||||
dkim_private_key_path = settings.get("app.email.dkim_private_key_path", "")
|
||||
dkim_selector = settings.get("app.email.dkim_selector", "")
|
||||
dkim_signature_algorithm = settings.get(
|
||||
"app.email.dkim_signature_algorithm", "ed25519-sha256"
|
||||
)
|
||||
get_endpoint = settings.get("app.bucket.secure_uploads.get_endpoint", "")
|
||||
|
||||
shops = (
|
||||
dbsession.query(Shop)
|
||||
.filter(Shop.subscriptions_enabled == True)
|
||||
.all()
|
||||
)
|
||||
|
||||
for shop in shops:
|
||||
subscribers = get_verified_subscriptions_for_shop(
|
||||
dbsession, shop.id, frequency=frequency
|
||||
)
|
||||
|
||||
if not subscribers:
|
||||
logger.info(f"Shop '{shop.name}': no {frequency_str} subscribers")
|
||||
continue
|
||||
|
||||
for sub in subscribers:
|
||||
items = get_new_items_for_subscriber(
|
||||
dbsession, shop, sub.last_digest_timestamp
|
||||
)
|
||||
|
||||
if not items:
|
||||
logger.info(
|
||||
f"Shop '{shop.name}': no new items for {sub.email} since last digest"
|
||||
)
|
||||
continue
|
||||
|
||||
unsubscribe_url = f"{request.host_url}/unsubscribe/{sub.unsubscribe_token}"
|
||||
|
||||
subject = f"New from {shop.name}"
|
||||
message_text = build_digest_text(shop.name, items, unsubscribe_url)
|
||||
message_html = build_digest_html(
|
||||
shop.name, items, unsubscribe_url, get_endpoint
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logger.info(
|
||||
f"[DRY RUN] Would send digest to {sub.email} for shop '{shop.name}' "
|
||||
f"with {len(items)} item(s)"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
send_email(
|
||||
sub.email,
|
||||
sender_email,
|
||||
subject,
|
||||
message_text,
|
||||
message_html,
|
||||
relay,
|
||||
dkim_private_key_path,
|
||||
dkim_selector,
|
||||
dkim_signature_algorithm,
|
||||
)
|
||||
logger.info(
|
||||
f"Sent digest to {sub.email} for shop '{shop.name}' "
|
||||
f"with {len(items)} item(s)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send digest to {sub.email} for shop '{shop.name}': {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Update last_digest_timestamp
|
||||
sub.last_digest_timestamp = now_timestamp()
|
||||
|
||||
import transaction
|
||||
transaction.commit()
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.config_uri)
|
||||
env = bootstrap(args.config_uri)
|
||||
|
||||
logger.info(
|
||||
f"Digest sender started: frequency={args.frequency}, dry_run={args.dry_run}"
|
||||
)
|
||||
|
||||
try:
|
||||
run(env, args.frequency, args.dry_run)
|
||||
finally:
|
||||
logger.info("Digest sender finished")
|
||||
env["closer"]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -206,3 +206,138 @@ Thanks for sharing! <3
|
|||
</html>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# 0: shop_name,
|
||||
# 1: verify_url,
|
||||
# 2: unsubscribe_url
|
||||
|
||||
VERIFY_SUBSCRIPTION_TEXT = """
|
||||
You requested to subscribe to email digests from {0}.
|
||||
|
||||
Please verify your email by visiting this link:
|
||||
|
||||
{1}
|
||||
|
||||
If you did not request this, you can ignore this email or unsubscribe:
|
||||
|
||||
{2}
|
||||
"""
|
||||
|
||||
# 0: subject,
|
||||
# 1: shop_name,
|
||||
# 2: verify_url,
|
||||
# 3: unsubscribe_url
|
||||
|
||||
VERIFY_SUBSCRIPTION_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{0}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>Verify Your Subscription</h2>
|
||||
|
||||
<p>
|
||||
You requested to subscribe to email digests from <b>{1}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{2}" style="font-weight: bold;">Click here to verify your email</a>
|
||||
</p>
|
||||
|
||||
<p style="font-size: .8em; color: #aaaaaa;">
|
||||
If you did not request this, you can ignore this email or
|
||||
<a href="{3}">unsubscribe</a>.
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# 0: shop_name,
|
||||
# 1: items_text,
|
||||
# 2: unsubscribe_url
|
||||
|
||||
DIGEST_TEXT = """
|
||||
New from {0}:
|
||||
|
||||
{1}
|
||||
|
||||
To unsubscribe from these digests:
|
||||
{2}
|
||||
"""
|
||||
|
||||
# 0: subject,
|
||||
# 1: shop_name,
|
||||
# 2: items_html,
|
||||
# 3: unsubscribe_url
|
||||
|
||||
DIGEST_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{0}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>New from {1}</h2>
|
||||
|
||||
{2}
|
||||
|
||||
<p style="font-size: .8em; color: #aaaaaa;">
|
||||
<a href="{3}">Unsubscribe</a> from these digests.
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# 0: commenter_name,
|
||||
# 1: product_title,
|
||||
# 2: comment_text,
|
||||
# 3: product_url
|
||||
|
||||
MENTION_TEXT = """
|
||||
{0} mentioned you in a comment on "{1}":
|
||||
|
||||
{2}
|
||||
|
||||
View the comment:
|
||||
{3}
|
||||
"""
|
||||
|
||||
# 0: subject,
|
||||
# 1: commenter_name,
|
||||
# 2: product_title,
|
||||
# 3: comment_text,
|
||||
# 4: product_url
|
||||
|
||||
MENTION_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{0}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>You were mentioned in a comment</h2>
|
||||
|
||||
<p>
|
||||
<b>{1}</b> mentioned you in a comment on "<b>{2}</b>":
|
||||
</p>
|
||||
|
||||
<blockquote style="border-left: 3px solid #ccc; padding-left: 10px; color: #555;">
|
||||
{3}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{4}" style="font-weight: bold;">View the comment</a>
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
|
|
|||
93
make_post_sell/lib/mentions.py
Normal file
93
make_post_sell/lib/mentions.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""
|
||||
@mention parsing and notification for comments.
|
||||
|
||||
Parses @username patterns from comment text and sends immediate
|
||||
email notifications to mentioned users.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
from .mail import send_pyramid_email
|
||||
from .mail_messages import MENTION_TEXT, MENTION_HTML
|
||||
|
||||
from ..models.user import get_user_by_name
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Matches @username where username is 3-64 chars of alphanumeric + dashes,
|
||||
# must start and end with alphanumeric. Preceded by start-of-string or whitespace.
|
||||
MENTION_PATTERN = re.compile(
|
||||
r"(?:^|(?<=\s))@([a-zA-Z0-9][a-zA-Z0-9\-]{1,62}[a-zA-Z0-9])"
|
||||
)
|
||||
|
||||
|
||||
def extract_mentions(text):
|
||||
"""Extract unique @mentioned usernames from text (without the @)."""
|
||||
if not text:
|
||||
return []
|
||||
matches = MENTION_PATTERN.findall(text)
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique = []
|
||||
for m in matches:
|
||||
lower = m.lower()
|
||||
if lower not in seen:
|
||||
seen.add(lower)
|
||||
unique.append(m)
|
||||
return unique
|
||||
|
||||
|
||||
def notify_mentioned_users(request, comment, author_user):
|
||||
"""
|
||||
Parse @mentions from a comment and send email notifications.
|
||||
|
||||
Skips self-mentions and unverified users.
|
||||
"""
|
||||
usernames = extract_mentions(comment.data)
|
||||
if not usernames:
|
||||
return
|
||||
|
||||
product = comment.product
|
||||
product_url = product.absolute_url(request)
|
||||
comment_url = f"{product_url}#comment-{comment.id}"
|
||||
commenter_name = author_user.name if author_user else "Someone"
|
||||
|
||||
for username in usernames:
|
||||
mentioned_user = get_user_by_name(request.dbsession, username)
|
||||
|
||||
if not mentioned_user:
|
||||
continue
|
||||
|
||||
# Skip self-mentions
|
||||
if author_user and mentioned_user.id == author_user.id:
|
||||
continue
|
||||
|
||||
# Skip unverified users (no valid email to send to)
|
||||
if not mentioned_user.verified:
|
||||
continue
|
||||
|
||||
subject = f"{commenter_name} mentioned you in a comment"
|
||||
message_text = MENTION_TEXT.format(
|
||||
commenter_name,
|
||||
product.title,
|
||||
comment.data,
|
||||
comment_url,
|
||||
)
|
||||
message_html = MENTION_HTML.format(
|
||||
subject,
|
||||
commenter_name,
|
||||
product.title,
|
||||
comment.data_html or comment.data,
|
||||
comment_url,
|
||||
)
|
||||
|
||||
try:
|
||||
send_pyramid_email(
|
||||
request, mentioned_user.email, subject, message_text, message_html
|
||||
)
|
||||
log.info(f"Sent mention notification to {mentioned_user.email}")
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Failed to send mention notification to {mentioned_user.email}: {e}"
|
||||
)
|
||||
|
|
@ -29,6 +29,7 @@ from .paypal_user_shop import *
|
|||
|
||||
from .shop_search_request import *
|
||||
from .comment import *
|
||||
from .shop_subscription import *
|
||||
|
||||
# run configure_mappers after defining all of the models
|
||||
# to ensure all relationships can be setup.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ CLASS_TO_TABLE = {
|
|||
"CryptoPayment": "mps_crypto_payment",
|
||||
"CryptoProcessor": "mps_crypto_processor",
|
||||
"UserCryptoRefundAddress": "mps_user_crypto_refund_address",
|
||||
"ShopSubscription": "mps_shop_subscription",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,9 @@ class Shop(RBase, Base):
|
|||
# Watch mode: sticky video, autoplay, related content sidebar
|
||||
watch_mode_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Email digest subscriptions
|
||||
subscriptions_enabled = Column(Boolean, default=True)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
|
||||
|
||||
|
|
|
|||
101
make_post_sell/models/shop_subscription.py
Normal file
101
make_post_sell/models/shop_subscription.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import uuid
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Boolean, Integer, Unicode
|
||||
|
||||
from .meta import (
|
||||
Base,
|
||||
RBase,
|
||||
UUIDType,
|
||||
now_timestamp,
|
||||
foreign_key,
|
||||
)
|
||||
|
||||
FREQUENCY_DAILY = 0
|
||||
FREQUENCY_WEEKLY = 1
|
||||
|
||||
FREQUENCY_HUMAN = {
|
||||
FREQUENCY_DAILY: "Daily",
|
||||
FREQUENCY_WEEKLY: "Weekly",
|
||||
}
|
||||
|
||||
|
||||
class ShopSubscription(RBase, Base):
|
||||
"""Tracks email digest subscriptions to a shop."""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
|
||||
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True, index=True)
|
||||
email = Column(Unicode(64), nullable=False)
|
||||
frequency = Column(Integer, nullable=False, default=FREQUENCY_WEEKLY)
|
||||
verified = Column(Boolean, nullable=False, default=False)
|
||||
unsubscribe_token = Column(Unicode(64), nullable=False, unique=True)
|
||||
verify_token = Column(Unicode(64), nullable=True)
|
||||
last_digest_timestamp = Column(BigInteger, nullable=False)
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
disabled = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
def __init__(self, email, shop_id, user_id=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.email = email
|
||||
self.shop_id = shop_id
|
||||
self.user_id = user_id
|
||||
self.frequency = FREQUENCY_WEEKLY
|
||||
self.disabled = False
|
||||
self.unsubscribe_token = secrets.token_urlsafe(32)
|
||||
self.created_timestamp = now_timestamp()
|
||||
self.last_digest_timestamp = now_timestamp()
|
||||
|
||||
if user_id:
|
||||
# Logged-in users are auto-verified
|
||||
self.verified = True
|
||||
self.verify_token = None
|
||||
else:
|
||||
self.verified = False
|
||||
self.verify_token = secrets.token_urlsafe(32)
|
||||
|
||||
@property
|
||||
def human_frequency(self):
|
||||
return FREQUENCY_HUMAN.get(self.frequency, "Weekly")
|
||||
|
||||
|
||||
def get_subscription_for_email_and_shop(dbsession, email, shop_id):
|
||||
"""Get subscription by email and shop_id, or None."""
|
||||
return (
|
||||
dbsession.query(ShopSubscription)
|
||||
.filter(
|
||||
ShopSubscription.email == email,
|
||||
ShopSubscription.shop_id == shop_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_subscription_by_unsubscribe_token(dbsession, token):
|
||||
"""Get subscription by unsubscribe_token, or None."""
|
||||
return (
|
||||
dbsession.query(ShopSubscription)
|
||||
.filter(ShopSubscription.unsubscribe_token == token)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_subscription_by_verify_token(dbsession, token):
|
||||
"""Get subscription by verify_token, or None."""
|
||||
return (
|
||||
dbsession.query(ShopSubscription)
|
||||
.filter(ShopSubscription.verify_token == token)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_verified_subscriptions_for_shop(dbsession, shop_id, frequency=None):
|
||||
"""Get all verified, non-disabled subscriptions for a shop."""
|
||||
query = dbsession.query(ShopSubscription).filter(
|
||||
ShopSubscription.shop_id == shop_id,
|
||||
ShopSubscription.verified == True,
|
||||
ShopSubscription.disabled == False,
|
||||
)
|
||||
if frequency is not None:
|
||||
query = query.filter(ShopSubscription.frequency == frequency)
|
||||
return query.all()
|
||||
|
|
@ -11,6 +11,11 @@ def includeme(config):
|
|||
config.add_route("feed_rss", "/feed.rss")
|
||||
config.add_route("feed_atom", "/feed.atom")
|
||||
|
||||
# Subscription routes
|
||||
config.add_route("subscribe", "/subscribe")
|
||||
config.add_route("subscribe_verify", "/subscribe/verify/{token}")
|
||||
config.add_route("unsubscribe", "/unsubscribe/{token}")
|
||||
|
||||
config.add_route("markup-editor-preview", "/markup-editor-preview")
|
||||
|
||||
config.add_route("ask-for-on-demand-tls", "/ask-for-on-demand-tls")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
"""add shop subscription
|
||||
|
||||
Revision ID: c3f9d1cdffb4
|
||||
Revises: abe5eb8acb97
|
||||
Create Date: 2026-02-07 17:50:14.865261
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c3f9d1cdffb4'
|
||||
down_revision = 'abe5eb8acb97'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add subscriptions_enabled column to shops
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"subscriptions_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
# Create shop subscription table
|
||||
op.create_table(
|
||||
"mps_shop_subscription",
|
||||
sa.Column("id", UUIDType, primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"shop_id", UUIDType, sa.ForeignKey("mps_shop.id"), nullable=False, index=True
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", UUIDType, sa.ForeignKey("mps_user.id"), nullable=True, index=True
|
||||
),
|
||||
sa.Column("email", sa.Unicode(64), nullable=False),
|
||||
sa.Column("frequency", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("verified", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("unsubscribe_token", sa.Unicode(64), nullable=False, unique=True),
|
||||
sa.Column("verify_token", sa.Unicode(64), nullable=True),
|
||||
sa.Column("last_digest_timestamp", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
|
||||
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("mps_shop_subscription")
|
||||
op.drop_column("mps_shop", "subscriptions_enabled")
|
||||
|
|
@ -71,6 +71,11 @@
|
|||
<meta name="google-site-verification" content="{{ request.shop.google_site_verification }}" />
|
||||
{% endif %}
|
||||
|
||||
{% if request.shop %}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ request.shop.name }} RSS" href="/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="{{ request.shop.name }} Atom" href="/atom.xml" />
|
||||
{% endif %}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>{{ request.domain }}</title>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if request.shop and request.shop.subscriptions_enabled %}
|
||||
<section class="one-column subscribe-cta">
|
||||
<a href="/subscribe" class="shop-theme-link-color">Stay in the Loop — get email updates</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -808,6 +808,31 @@ Existing sales honored for download buy purchasers.
|
|||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings">
|
||||
<input type="hidden" name="form_section" value="subscription-settings" />
|
||||
|
||||
<h3>Subscription Settings</h3>
|
||||
|
||||
<input type="checkbox" name="subscriptions-enabled-checkbox" id="subscriptions-enabled-checkbox" {% if request.shop.subscriptions_enabled %}checked{% endif %}></input>
|
||||
<b>Enable "Stay in the Loop" email digest subscriptions</b>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Allow visitors to subscribe to email digests of new products and content.
|
||||
Subscribers can choose daily or weekly frequency. Emails are only sent when
|
||||
new items are published.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings">
|
||||
<input type="hidden" name="form_section" value="comment-settings" />
|
||||
|
||||
|
|
|
|||
72
make_post_sell/templates/subscribe.j2
Normal file
72
make_post_sell/templates/subscribe.j2
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block append_to_head_tag_section %}
|
||||
<title>Stay in the Loop | {{ request.domain }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="one-column well">
|
||||
|
||||
<h3>Stay in the Loop</h3>
|
||||
|
||||
{% if request.shop and request.shop.subscriptions_enabled %}
|
||||
|
||||
<p>
|
||||
Get an email digest of new products and content from <b>{{ request.shop.name }}</b>.
|
||||
No spam — only a summary when new items are published.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/subscribe">
|
||||
|
||||
{% if request.user and request.user.authenticated %}
|
||||
|
||||
<p>Subscribing as <b>{{ request.user.email }}</b></p>
|
||||
|
||||
{% else %}
|
||||
|
||||
<label for="email-input">Email Address</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
id="email-input"
|
||||
placeholder="you@example.com"
|
||||
required />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% endif %}
|
||||
|
||||
<label>Digest Frequency</label>
|
||||
<br />
|
||||
<input type="radio" name="frequency" value="1" id="freq-weekly" checked />
|
||||
<label for="freq-weekly">Weekly</label>
|
||||
|
||||
<input type="radio" name="frequency" value="0" id="freq-daily" />
|
||||
<label for="freq-daily">Daily</label>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Subscribe" />
|
||||
</form>
|
||||
|
||||
<br />
|
||||
|
||||
<h4>Other Ways to Follow</h4>
|
||||
<p>
|
||||
<a href="/rss.xml" class="shop-theme-link-color">RSS Feed</a>
|
||||
|
|
||||
<a href="/atom.xml" class="shop-theme-link-color">Atom Feed</a>
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
|
||||
<p>Subscriptions are not currently available for this shop.</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -2414,3 +2414,167 @@ class AuthenticatedFunctionalTests(FunctionalTests):
|
|||
# Verify it was saved
|
||||
self.dbsession.expire(shop)
|
||||
self.assertFalse(shop.watch_mode_enabled)
|
||||
|
||||
def test_subscription_settings_default_on(self):
|
||||
"""Test that new shops have subscriptions enabled by default."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-default-shop",
|
||||
}
|
||||
)
|
||||
# subscriptions_enabled defaults to True via server_default
|
||||
self.dbsession.expire(shop)
|
||||
self.assertTrue(shop.subscriptions_enabled)
|
||||
|
||||
def test_subscription_settings_toggle(self):
|
||||
"""Test disabling and re-enabling subscriptions via settings."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-toggle-shop",
|
||||
}
|
||||
)
|
||||
|
||||
# Disable subscriptions (checkbox absent = "off")
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "subscription-settings",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Email digest subscriptions disabled", res.text)
|
||||
|
||||
self.dbsession.expire(shop)
|
||||
self.assertFalse(shop.subscriptions_enabled)
|
||||
|
||||
# Re-enable subscriptions
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "subscription-settings",
|
||||
"subscriptions-enabled-checkbox": "on",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn("Email digest subscriptions enabled", res.text)
|
||||
|
||||
self.dbsession.expire(shop)
|
||||
self.assertTrue(shop.subscriptions_enabled)
|
||||
|
||||
def test_subscribe_page_renders(self):
|
||||
"""Test that /subscribe renders for a shop with subscriptions enabled."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-render-shop",
|
||||
}
|
||||
)
|
||||
res = self.testapp.get("/subscribe", status=200)
|
||||
self.assertIn(b"Stay in the Loop", res.body)
|
||||
self.assertIn(b"Subscribe", res.body)
|
||||
|
||||
def test_subscribe_no_shop_redirects(self):
|
||||
"""Test that /subscribe redirects when no shop context (anonymous on SaaS domain)."""
|
||||
# Log out so there's no shop context on SaaS domain
|
||||
self.testapp.get("/log-out")
|
||||
|
||||
res = self.testapp.get("/subscribe", status=302)
|
||||
res = res.follow()
|
||||
self.assertIn(b"No shop found", res.body)
|
||||
|
||||
def test_subscribe_logged_in(self):
|
||||
"""Test logged-in subscription is immediate."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-login-shop",
|
||||
}
|
||||
)
|
||||
|
||||
res = self.testapp.post(
|
||||
"/subscribe",
|
||||
{
|
||||
"frequency": "0",
|
||||
"submit": "Subscribe",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn(b"You are now subscribed", res.body)
|
||||
|
||||
def test_subscribe_verify_and_unsubscribe_flow(self):
|
||||
"""Test the full subscribe -> verify is auto for logged-in, then unsubscribe flow."""
|
||||
from ..models.shop_subscription import (
|
||||
ShopSubscription,
|
||||
get_subscription_for_email_and_shop,
|
||||
)
|
||||
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-verify-shop",
|
||||
}
|
||||
)
|
||||
|
||||
# Subscribe as logged-in user (auto-verified)
|
||||
res = self.testapp.post(
|
||||
"/subscribe",
|
||||
{
|
||||
"frequency": "1",
|
||||
"submit": "Subscribe",
|
||||
},
|
||||
status=302,
|
||||
)
|
||||
res = res.follow()
|
||||
self.assertIn(b"You are now subscribed", res.body)
|
||||
|
||||
# Look up the subscription to get the unsubscribe token
|
||||
sub = get_subscription_for_email_and_shop(
|
||||
self.dbsession, "test1@example.com", shop.id
|
||||
)
|
||||
self.assertIsNotNone(sub)
|
||||
self.assertTrue(sub.verified)
|
||||
|
||||
# Test unsubscribe via token
|
||||
token = sub.unsubscribe_token
|
||||
res = self.testapp.get(f"/unsubscribe/{token}", status=302)
|
||||
res = res.follow()
|
||||
self.assertIn(b"You have been unsubscribed", res.body)
|
||||
|
||||
# Verify subscription is now disabled
|
||||
self.dbsession.expire(sub)
|
||||
self.assertTrue(sub.disabled)
|
||||
|
||||
def test_unsubscribe_invalid_token(self):
|
||||
"""Test graceful handling of invalid unsubscribe token."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "sub-bad-token-shop",
|
||||
}
|
||||
)
|
||||
|
||||
res = self.testapp.get("/unsubscribe/invalid-token-12345", status=302)
|
||||
res = res.follow()
|
||||
self.assertIn(b"Invalid unsubscribe link", res.body)
|
||||
|
||||
def test_rss_autodiscovery_links(self):
|
||||
"""Test that RSS and Atom autodiscovery links are present in shop pages."""
|
||||
shop = self._create_shop_helper(
|
||||
shop_params={
|
||||
**self.shop1_params,
|
||||
"name": "rss-disco-shop",
|
||||
}
|
||||
)
|
||||
|
||||
res = self.testapp.get("/", status=200)
|
||||
self.assertIn(b'type="application/rss+xml"', res.body)
|
||||
self.assertIn(b'type="application/atom+xml"', res.body)
|
||||
self.assertIn(b"/rss.xml", res.body)
|
||||
self.assertIn(b"/atom.xml", res.body)
|
||||
|
|
|
|||
|
|
@ -2030,3 +2030,196 @@ class TestStemmer(unittest.TestCase):
|
|||
# Custom limit
|
||||
related = get_related_products(product1, limit=3)
|
||||
self.assertLessEqual(len(related), 3)
|
||||
|
||||
|
||||
class TestShopSubscription(unittest.TestCase):
|
||||
"""Test ShopSubscription model."""
|
||||
|
||||
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
||||
def setUp(self):
|
||||
self.shop = Shop(
|
||||
"test-shop",
|
||||
"555-555-5555",
|
||||
"123 Test St",
|
||||
"Test shop description",
|
||||
)
|
||||
self.user = User("subscriber@example.com")
|
||||
|
||||
def test_create_anonymous_subscription(self):
|
||||
"""Test creating a subscription without a user (anonymous)."""
|
||||
from ..models.shop_subscription import ShopSubscription
|
||||
|
||||
sub = ShopSubscription("anon@example.com", self.shop.id)
|
||||
self.assertEqual(sub.email, "anon@example.com")
|
||||
self.assertEqual(sub.shop_id, self.shop.id)
|
||||
self.assertIsNone(sub.user_id)
|
||||
self.assertFalse(sub.verified)
|
||||
self.assertIsNotNone(sub.verify_token)
|
||||
self.assertIsNotNone(sub.unsubscribe_token)
|
||||
self.assertFalse(sub.disabled)
|
||||
self.assertGreater(sub.created_timestamp, 0)
|
||||
self.assertGreater(sub.last_digest_timestamp, 0)
|
||||
|
||||
def test_create_logged_in_subscription(self):
|
||||
"""Test creating a subscription with a user (auto-verified)."""
|
||||
from ..models.shop_subscription import ShopSubscription
|
||||
|
||||
sub = ShopSubscription("user@example.com", self.shop.id, user_id=self.user.id)
|
||||
self.assertEqual(sub.user_id, self.user.id)
|
||||
self.assertTrue(sub.verified)
|
||||
self.assertIsNone(sub.verify_token)
|
||||
|
||||
def test_unique_tokens(self):
|
||||
"""Test that each subscription gets unique tokens."""
|
||||
from ..models.shop_subscription import ShopSubscription
|
||||
|
||||
sub1 = ShopSubscription("a@example.com", self.shop.id)
|
||||
sub2 = ShopSubscription("b@example.com", self.shop.id)
|
||||
self.assertNotEqual(sub1.unsubscribe_token, sub2.unsubscribe_token)
|
||||
self.assertNotEqual(sub1.verify_token, sub2.verify_token)
|
||||
|
||||
def test_default_frequency_weekly(self):
|
||||
"""Test that default frequency is weekly."""
|
||||
from ..models.shop_subscription import ShopSubscription, FREQUENCY_WEEKLY
|
||||
|
||||
sub = ShopSubscription("test@example.com", self.shop.id)
|
||||
self.assertEqual(sub.frequency, FREQUENCY_WEEKLY)
|
||||
|
||||
def test_human_frequency(self):
|
||||
"""Test human_frequency property."""
|
||||
from ..models.shop_subscription import (
|
||||
ShopSubscription,
|
||||
FREQUENCY_DAILY,
|
||||
FREQUENCY_WEEKLY,
|
||||
)
|
||||
|
||||
sub = ShopSubscription("test@example.com", self.shop.id)
|
||||
sub.frequency = FREQUENCY_WEEKLY
|
||||
self.assertEqual(sub.human_frequency, "Weekly")
|
||||
|
||||
sub.frequency = FREQUENCY_DAILY
|
||||
self.assertEqual(sub.human_frequency, "Daily")
|
||||
|
||||
|
||||
class TestMentionParsing(unittest.TestCase):
|
||||
"""Test @mention parsing from comment text."""
|
||||
|
||||
def test_simple_mention(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("Hello @user-abcd1234 nice post!")
|
||||
self.assertEqual(result, ["user-abcd1234"])
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("@user-abc123 and @user-def456 check this out")
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertIn("user-abc123", result)
|
||||
self.assertIn("user-def456", result)
|
||||
|
||||
def test_duplicate_mentions(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("@user-abc123 said hi, @user-abc123 said bye")
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0], "user-abc123")
|
||||
|
||||
def test_no_mentions(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("No mentions here.")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_empty_text(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_none_text(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions(None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_email_not_matched(self):
|
||||
"""Email addresses should not be matched as mentions."""
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("Email me at person@example.com")
|
||||
# @example would not match because it follows a non-whitespace char
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_start_of_string(self):
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("@user-start123 at the start")
|
||||
self.assertEqual(result, ["user-start123"])
|
||||
|
||||
def test_mention_too_short(self):
|
||||
"""Usernames under 3 chars should not match."""
|
||||
from ..lib.mentions import extract_mentions
|
||||
|
||||
result = extract_mentions("@ab is too short")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestDigestBuilding(unittest.TestCase):
|
||||
"""Test digest email building functions."""
|
||||
|
||||
def test_build_digest_text(self):
|
||||
from ..lib.digest_sender import build_digest_text
|
||||
|
||||
mock_item1 = mock.Mock()
|
||||
mock_item1.title = "Cool Product"
|
||||
mock_item1.is_sellable = True
|
||||
|
||||
mock_item2 = mock.Mock()
|
||||
mock_item2.title = "Blog Post"
|
||||
mock_item2.is_sellable = False
|
||||
|
||||
result = build_digest_text(
|
||||
"Test Shop", [mock_item1, mock_item2], "https://example.com/unsub"
|
||||
)
|
||||
self.assertIn("Test Shop", result)
|
||||
self.assertIn("Cool Product", result)
|
||||
self.assertIn("Blog Post", result)
|
||||
self.assertIn("[Product]", result)
|
||||
self.assertIn("[Content]", result)
|
||||
self.assertIn("https://example.com/unsub", result)
|
||||
|
||||
def test_build_digest_html(self):
|
||||
from ..lib.digest_sender import build_digest_html
|
||||
|
||||
mock_item = mock.Mock()
|
||||
mock_item.title = "New Item"
|
||||
mock_item.is_sellable = True
|
||||
mock_item.extensions = {}
|
||||
mock_item.s3_path = "test/path"
|
||||
mock_item.updated_timestamp = 12345
|
||||
|
||||
result = build_digest_html(
|
||||
"Test Shop", [mock_item], "https://example.com/unsub"
|
||||
)
|
||||
self.assertIn("Test Shop", result)
|
||||
self.assertIn("New Item", result)
|
||||
self.assertIn("https://example.com/unsub", result)
|
||||
|
||||
def test_build_digest_html_with_thumbnail(self):
|
||||
from ..lib.digest_sender import build_digest_html
|
||||
|
||||
mock_item = mock.Mock()
|
||||
mock_item.title = "Item With Image"
|
||||
mock_item.is_sellable = False
|
||||
mock_item.extensions = {"thumbnail1": "jpg"}
|
||||
mock_item.s3_path = "shop/product"
|
||||
mock_item.updated_timestamp = 99999
|
||||
|
||||
result = build_digest_html(
|
||||
"Test Shop",
|
||||
[mock_item],
|
||||
"https://example.com/unsub",
|
||||
get_endpoint="https://cdn.example.com",
|
||||
)
|
||||
self.assertIn("https://cdn.example.com/shop/product/thumbnail1", result)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from ..models.user import get_user_by_id
|
|||
|
||||
from ..lib.render import markdown_to_html
|
||||
|
||||
from ..lib.mentions import notify_mentioned_users
|
||||
|
||||
|
||||
@view_config(route_name="comment_new", request_method="POST")
|
||||
@user_required()
|
||||
|
|
@ -99,6 +101,8 @@ def comment_new(request):
|
|||
request.dbsession.add(comment)
|
||||
request.dbsession.flush()
|
||||
|
||||
notify_mentioned_users(request, comment, request.user)
|
||||
|
||||
# AJAX request — return JSON instead of redirect
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return Response(
|
||||
|
|
@ -198,6 +202,8 @@ def comment_reply_post(request):
|
|||
request.dbsession.add(comment)
|
||||
request.dbsession.flush()
|
||||
|
||||
notify_mentioned_users(request, comment, request.user)
|
||||
|
||||
if comment.approved:
|
||||
request.session.flash(("Reply posted successfully", "success"))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -753,6 +753,22 @@ def shop_settings(request):
|
|||
)
|
||||
)
|
||||
|
||||
# Handle subscription settings
|
||||
if form_section == "subscription-settings":
|
||||
subscriptions_enabled_checkbox = request.params.get(
|
||||
"subscriptions-enabled-checkbox", "off"
|
||||
)
|
||||
subscriptions_enabled = checkbox_to_bool(subscriptions_enabled_checkbox)
|
||||
if shop.subscriptions_enabled != subscriptions_enabled:
|
||||
shop.subscriptions_enabled = subscriptions_enabled
|
||||
status = "enabled" if subscriptions_enabled else "disabled"
|
||||
request.session.flash(
|
||||
(
|
||||
f"Email digest subscriptions {status}",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle comment system settings only if comment form was submitted
|
||||
if form_section == "comment-settings":
|
||||
if shop.comments_enabled != comments_enabled:
|
||||
|
|
|
|||
204
make_post_sell/views/subscribe.py
Normal file
204
make_post_sell/views/subscribe.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from pyramid.view import view_config
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from . import get_referer_or_home
|
||||
|
||||
from ..models.shop_subscription import (
|
||||
ShopSubscription,
|
||||
FREQUENCY_DAILY,
|
||||
FREQUENCY_WEEKLY,
|
||||
get_subscription_for_email_and_shop,
|
||||
get_subscription_by_unsubscribe_token,
|
||||
get_subscription_by_verify_token,
|
||||
)
|
||||
|
||||
from ..lib.mail import send_pyramid_email
|
||||
|
||||
from ..lib.mail_messages import (
|
||||
VERIFY_SUBSCRIPTION_TEXT,
|
||||
VERIFY_SUBSCRIPTION_HTML,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@view_config(route_name="subscribe", renderer="subscribe.j2")
|
||||
def subscribe(request):
|
||||
"""Subscribe page — GET renders form, POST handles subscription."""
|
||||
shop = request.shop
|
||||
|
||||
if not shop:
|
||||
request.session.flash(("No shop found.", "error"))
|
||||
return HTTPFound("/")
|
||||
|
||||
if not shop.subscriptions_enabled:
|
||||
request.session.flash(("Subscriptions are not available for this shop.", "info"))
|
||||
return HTTPFound("/")
|
||||
|
||||
if request.method == "POST":
|
||||
frequency_str = request.params.get("frequency", "1")
|
||||
try:
|
||||
frequency = int(frequency_str)
|
||||
except (ValueError, TypeError):
|
||||
frequency = FREQUENCY_WEEKLY
|
||||
|
||||
if frequency not in (FREQUENCY_DAILY, FREQUENCY_WEEKLY):
|
||||
frequency = FREQUENCY_WEEKLY
|
||||
|
||||
if request.user and request.user.authenticated:
|
||||
# Logged-in user: one-click subscribe
|
||||
email = request.user.email
|
||||
existing = get_subscription_for_email_and_shop(
|
||||
request.dbsession, email, shop.id
|
||||
)
|
||||
|
||||
if existing:
|
||||
if existing.disabled:
|
||||
# Re-subscribe
|
||||
existing.disabled = False
|
||||
existing.verified = True
|
||||
existing.frequency = frequency
|
||||
request.session.flash(
|
||||
("You have been re-subscribed.", "success")
|
||||
)
|
||||
else:
|
||||
# Update frequency
|
||||
existing.frequency = frequency
|
||||
request.session.flash(
|
||||
("Your subscription preferences have been updated.", "success")
|
||||
)
|
||||
else:
|
||||
sub = ShopSubscription(email, shop.id, user_id=request.user.id)
|
||||
sub.frequency = frequency
|
||||
request.dbsession.add(sub)
|
||||
request.dbsession.flush()
|
||||
request.session.flash(
|
||||
("You are now subscribed.", "success")
|
||||
)
|
||||
|
||||
return HTTPFound("/subscribe")
|
||||
|
||||
else:
|
||||
# Anonymous user: needs email verification
|
||||
email = request.params.get("email", "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
request.session.flash(
|
||||
("Please enter a valid email address.", "error")
|
||||
)
|
||||
return HTTPFound("/subscribe")
|
||||
|
||||
existing = get_subscription_for_email_and_shop(
|
||||
request.dbsession, email, shop.id
|
||||
)
|
||||
|
||||
if existing:
|
||||
if existing.disabled:
|
||||
# Re-subscribe — send new verification
|
||||
existing.disabled = False
|
||||
existing.verified = False
|
||||
existing.frequency = frequency
|
||||
import secrets
|
||||
existing.verify_token = secrets.token_urlsafe(32)
|
||||
_send_verification_email(request, existing)
|
||||
request.session.flash(
|
||||
(
|
||||
"A verification email has been sent. Please check your inbox.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
elif existing.verified:
|
||||
# Already subscribed, update frequency
|
||||
existing.frequency = frequency
|
||||
request.session.flash(
|
||||
(
|
||||
"You are already subscribed. Your preferences have been updated.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Resend verification
|
||||
_send_verification_email(request, existing)
|
||||
request.session.flash(
|
||||
(
|
||||
"A verification email has been sent. Please check your inbox.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
else:
|
||||
sub = ShopSubscription(email, shop.id)
|
||||
sub.frequency = frequency
|
||||
request.dbsession.add(sub)
|
||||
request.dbsession.flush()
|
||||
_send_verification_email(request, sub)
|
||||
request.session.flash(
|
||||
(
|
||||
"A verification email has been sent. Please check your inbox.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
|
||||
return HTTPFound("/subscribe")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@view_config(route_name="subscribe_verify", renderer="subscribe.j2")
|
||||
def subscribe_verify(request):
|
||||
"""Verify an email subscription via token link."""
|
||||
token = request.matchdict.get("token", "")
|
||||
sub = get_subscription_by_verify_token(request.dbsession, token)
|
||||
|
||||
if sub:
|
||||
sub.verified = True
|
||||
sub.verify_token = None
|
||||
request.session.flash(
|
||||
("Your email has been verified. You are now subscribed.", "success")
|
||||
)
|
||||
else:
|
||||
request.session.flash(
|
||||
("Invalid or expired verification link.", "error")
|
||||
)
|
||||
|
||||
return HTTPFound("/subscribe")
|
||||
|
||||
|
||||
@view_config(route_name="unsubscribe", renderer="subscribe.j2")
|
||||
def unsubscribe(request):
|
||||
"""One-click unsubscribe via token link."""
|
||||
token = request.matchdict.get("token", "")
|
||||
sub = get_subscription_by_unsubscribe_token(request.dbsession, token)
|
||||
|
||||
if sub:
|
||||
sub.disabled = True
|
||||
request.session.flash(
|
||||
("You have been unsubscribed.", "success")
|
||||
)
|
||||
else:
|
||||
request.session.flash(
|
||||
("Invalid unsubscribe link.", "error")
|
||||
)
|
||||
|
||||
return HTTPFound("/")
|
||||
|
||||
|
||||
def _send_verification_email(request, subscription):
|
||||
"""Send a verification email for anonymous subscriptions."""
|
||||
verify_url = request.route_url(
|
||||
"subscribe_verify", token=subscription.verify_token
|
||||
)
|
||||
unsubscribe_url = request.route_url(
|
||||
"unsubscribe", token=subscription.unsubscribe_token
|
||||
)
|
||||
shop_name = request.shop.name if request.shop else "this shop"
|
||||
|
||||
subject = f"Verify your subscription to {shop_name}"
|
||||
message_text = VERIFY_SUBSCRIPTION_TEXT.format(
|
||||
shop_name, verify_url, unsubscribe_url
|
||||
)
|
||||
message_html = VERIFY_SUBSCRIPTION_HTML.format(
|
||||
subject, shop_name, verify_url, unsubscribe_url
|
||||
)
|
||||
send_pyramid_email(request, subscription.email, subject, message_text, message_html)
|
||||
1
setup.py
1
setup.py
|
|
@ -66,6 +66,7 @@ setup(
|
|||
"console_scripts": [
|
||||
"initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main",
|
||||
"crypto_watcher = make_post_sell.lib.crypto_watcher:main",
|
||||
"digest_sender = make_post_sell.lib.digest_sender:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue