implement remaining MPS-14/15/16 gaps: trial enforcement, BYOB backfill, search exclusion

- Add trial_active_required decorator to product_new, product_edit, cart_checkout,
  all checkout completions, and gift_card_add_to_cart (MPS-15)
- Make karaoke backfill and s3_mirror backfill shop-aware for BYOB buckets (MPS-16)
- Auto-test BYOB bucket connection on save, like mirror does (MPS-16)
- Add trial onboarding tip in bucket settings for trial shops (MPS-16)
- Filter non-production shops from search results (MPS-14)
- Filter non-production shops from email digests (MPS-14)
- Fix environment allowance to allow minimum 2 non-prod shops (MPS-14)
- Add 12 integration tests (6 trial + 6 BYOB), 724 total tests pass
This commit is contained in:
russell@unturf.com 2026-03-07 19:36:16 -05:00
parent 13fabb5d3f
commit 9c3e0e5e54
12 changed files with 307 additions and 18 deletions

View file

@ -122,6 +122,7 @@ def run(env, frequency_str, dry_run=False):
shops = (
dbsession.query(Shop)
.filter(Shop.subscriptions_enabled == True)
.filter(Shop.environment == 0) # MPS-14: exclude non-production shops
.all()
)

View file

@ -362,6 +362,15 @@ def backfill_karaoke_async(shop_id, session_factory, app_settings):
session = SASession(bind=engine)
def _make_s3():
# BYOB: use shop's own bucket if configured (MPS-16)
if shop and shop.has_primary_s3:
return boto3.session.Session().client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return boto3.session.Session().client(
"s3",
region_name=app_settings["bucket.secure_uploads.region"],
@ -370,14 +379,15 @@ def backfill_karaoke_async(shop_id, session_factory, app_settings):
aws_secret_access_key=app_settings["bucket.secure_uploads.secret_key"],
)
s3 = _make_s3()
bucket = app_settings["bucket.secure_uploads"]
try:
# Must load shop before _make_s3 can check has_primary_s3
shop = session.get(Shop, shop_id)
if not shop or not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
return
s3 = _make_s3()
bucket = shop.primary_s3_bucket if shop.has_primary_s3 else app_settings["bucket.secure_uploads"]
pk, sk = shop.unsandbox_public_key, shop.unsandbox_secret_key
# Query account concurrency from unsandbox API — abort if keys are bad

View file

@ -229,7 +229,16 @@ def backfill_mirror_async(shop_id, session_factory, app_settings):
engine = create_engine(db_url)
session = SASession(bind=engine)
def _make_src():
def _make_src(shop):
# BYOB: if shop has its own primary bucket, mirror from there (MPS-16)
if shop and shop.has_primary_s3:
return boto3.session.Session().client(
"s3",
region_name=shop.primary_s3_region,
endpoint_url=shop.primary_s3_endpoint,
aws_access_key_id=shop.primary_s3_access_key,
aws_secret_access_key=shop.primary_s3_secret_key,
)
return boto3.session.Session().client(
"s3",
region_name=app_settings["bucket.secure_uploads.region"],
@ -243,14 +252,14 @@ def backfill_mirror_async(shop_id, session_factory, app_settings):
if not shop or not shop.has_s3_mirror:
return
src = _make_src()
src = _make_src(shop)
dst = _make_mirror_client(
shop.mirror_s3_endpoint,
shop.mirror_s3_region,
shop.mirror_s3_access_key,
shop.mirror_s3_secret_key,
)
src_bucket = app_settings["bucket.secure_uploads"]
src_bucket = shop.primary_s3_bucket if shop.has_primary_s3 else app_settings["bucket.secure_uploads"]
dst_bucket = shop.mirror_s3_bucket
# List all objects under the shop's prefix

View file

@ -677,9 +677,12 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
for keyword in keywords:
keyword_filter = Product.title.ilike(f"%{keyword}%")
# the product _must_ be public (1).
products = (
product_query.filter(keyword_filter).filter(Product.visibility == 1).all()
)
query = product_query.filter(keyword_filter).filter(Product.visibility == 1)
# Exclude non-production shops from search results (MPS-14)
if not shop:
from .shop import Shop
query = query.join(Shop, Product.shop_id == Shop.id).filter(Shop.environment == 0)
products = query.all()
for product in products:
if product.id not in scores:

View file

@ -1212,6 +1212,10 @@ Existing sales honored for download buy purchasers.
<p>Configure your own S3-compatible storage bucket. When enabled, all media uploads and CDN URLs will use your bucket instead of the default MPS storage.</p>
{% if request.shop.is_trial_active %}
<p><strong>Trial tip:</strong> Setting up your own storage bucket during your trial ensures your media is always under your control. Any S3-compatible provider works (DigitalOcean Spaces, AWS S3, Backblaze B2, etc.).</p>
{% endif %}
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="bucket-settings" />

View file

@ -4675,7 +4675,11 @@ class TestBucketSettings(_AuthenticatedBase):
)
res = res.follow()
flash = self._get_flash_messages(res)
self.assertIn("Storage bucket settings updated", flash)
# Connection test runs on save — may fail in test env but settings are saved
self.assertTrue(
"connection test failed" in flash or "Storage bucket settings" in flash,
f"Unexpected flash: {flash}"
)
self.dbsession.refresh(shop)
self.assertTrue(shop.primary_s3_enabled)
self.assertEqual(shop.primary_s3_bucket, "my-test-bucket")

View file

@ -3689,3 +3689,199 @@ class TestGiftCardIntegration(DatabaseIntegrationTests):
self.assertEqual(cart.gift_card_purchases_total_in_cents, 2500)
transaction.commit()
class TestTrialIntegration(DatabaseIntegrationTests):
"""MPS-15: Integration tests for trial system with real ORM objects."""
def _make_shop(self, **kwargs):
shop = Shop(
name="Trial Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A trial shop",
)
shop.domain_name = "trial.test.com"
for k, v in kwargs.items():
setattr(shop, k, v)
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def test_grandfathered_shop_not_expired(self):
"""Pre-trial shops (NULL trial_started_timestamp) are never expired."""
shop = self._make_shop(trial_started_timestamp=None)
self.assertFalse(shop.is_trial_expired)
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_active)
transaction.commit()
def test_active_trial(self):
"""Shop within 21-day trial window is active."""
now_ms = int(time.time() * 1000)
shop = self._make_shop(trial_started_timestamp=now_ms)
self.assertTrue(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
self.assertIn(shop.trial_days_remaining, (20, 21)) # depends on sub-day timing
transaction.commit()
def test_expired_trial(self):
"""Shop past 21-day window is expired."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms)
self.assertFalse(shop.is_trial_active)
self.assertTrue(shop.is_trial_expired)
self.assertFalse(shop.is_active)
self.assertEqual(shop.trial_days_remaining, 0)
transaction.commit()
def test_paid_plan_overrides_trial(self):
"""Paid plan makes shop active regardless of trial status."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms, plan_active=True)
self.assertFalse(shop.is_trial_active)
self.assertFalse(shop.is_trial_expired)
self.assertTrue(shop.is_active)
transaction.commit()
def test_trial_with_products(self):
"""Products in a trial shop are accessible but cannot be created when expired."""
expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
shop = self._make_shop(trial_started_timestamp=expired_ms)
# Existing products are still in the database and readable
product = Product(title="Existing Product", description="Created before trial expired")
product.shop_id = shop.id
product.price_in_cents = 1000
self.dbsession.add(product)
self.dbsession.flush()
# Shop is expired but product still exists
self.assertTrue(shop.is_trial_expired)
self.assertEqual(product.shop_id, shop.id)
transaction.commit()
def test_environment_with_trial(self):
"""Environment and trial are independent — non-prod shop can have trial."""
now_ms = int(time.time() * 1000)
shop = self._make_shop(trial_started_timestamp=now_ms, environment=1)
self.assertTrue(shop.is_trial_active)
self.assertTrue(shop.is_staging)
self.assertTrue(shop.is_non_production)
transaction.commit()
class TestBYOBIntegration(DatabaseIntegrationTests):
"""MPS-16: Integration tests for BYOB (Bring Your Own Bucket) with real ORM objects."""
def _make_shop(self, **kwargs):
shop = Shop(
name="BYOB Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A BYOB shop",
)
shop.domain_name = "byob.test.com"
for k, v in kwargs.items():
setattr(shop, k, v)
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def test_has_primary_s3_false_by_default(self):
"""New shops do not have BYOB enabled."""
shop = self._make_shop()
self.assertFalse(shop.has_primary_s3)
transaction.commit()
def test_has_primary_s3_requires_all_fields(self):
"""BYOB requires all fields AND enabled flag."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
primary_s3_enabled=False, # not enabled
)
self.assertFalse(shop.has_primary_s3)
shop.primary_s3_enabled = True
self.assertTrue(shop.has_primary_s3)
transaction.commit()
def test_has_primary_s3_missing_field(self):
"""BYOB is false if any required field is missing."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="", # missing
primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
primary_s3_enabled=True,
)
self.assertFalse(shop.has_primary_s3)
transaction.commit()
def test_media_cdn_endpoint_byob(self):
"""media_cdn_endpoint returns shop's CDN when BYOB is active."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="my-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://custom-cdn.example.com",
primary_s3_enabled=True,
)
self.assertEqual(shop.media_cdn_endpoint, "https://custom-cdn.example.com")
# Disable BYOB — CDN returns None
shop.primary_s3_enabled = False
self.assertIsNone(shop.media_cdn_endpoint)
transaction.commit()
def test_byob_with_mirror(self):
"""BYOB and mirror can coexist on the same shop."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="primary-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://primary-cdn.example.com",
primary_s3_enabled=True,
mirror_s3_endpoint="https://sfo3.digitaloceanspaces.com",
mirror_s3_region="sfo3",
mirror_s3_bucket="mirror-bucket",
mirror_s3_access_key="AKIAMIRROR",
mirror_s3_secret_key="mirrorsecret",
mirror_s3_enabled=True,
)
self.assertTrue(shop.has_primary_s3)
self.assertTrue(shop.has_s3_mirror)
transaction.commit()
def test_byob_persists_after_flush(self):
"""BYOB fields round-trip through database correctly."""
shop = self._make_shop(
primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
primary_s3_region="nyc3",
primary_s3_bucket="test-bucket",
primary_s3_access_key="AKIATEST",
primary_s3_secret_key="secret123",
primary_s3_cdn_endpoint="https://cdn.example.com",
primary_s3_enabled=True,
)
shop_id = shop.id
self.dbsession.flush()
# Re-fetch from DB
fetched = self.dbsession.query(Shop).get(shop_id)
self.assertTrue(fetched.has_primary_s3)
self.assertEqual(fetched.primary_s3_bucket, "test-bucket")
self.assertEqual(fetched.primary_s3_cdn_endpoint, "https://cdn.example.com")
transaction.commit()

View file

@ -88,6 +88,30 @@ def shop_owner_required(
return wrapped
# view decorator.
def trial_active_required(
flash_msg="Your 21-day trial has expired. Choose a plan to continue.",
flash_level="error",
):
"""Block write operations when shop trial has expired.
Grandfathered shops (NULL trial_started_timestamp) and paid shops pass through.
Only blocks shops that have an expired trial with no active plan.
"""
def wrapped(fn):
def inner(request):
shop = request.shop
if shop and shop.is_trial_expired:
request.session.flash((flash_msg, flash_level))
return HTTPFound(get_referer_or_home(request))
return fn(request)
return inner
return wrapped
# view decorator.
def shop_editor_required(
flash_msg="You must have a shop editor role to access that.",

View file

@ -4,6 +4,7 @@ from . import (
user_required,
get_referer_or_home,
shop_is_ready_required,
trial_active_required,
)
from ..models.cart import get_cart_by_id
@ -524,6 +525,7 @@ def cart_handling_option(request):
redirect_to_route_name="join-or-log-in",
)
@shop_is_ready_required()
@trial_active_required()
def cart_checkout(request):
stripe_user_shop = request.shop.stripe_user_shop(request.user)
paypal_user_shop = request.shop.paypal_user_shop(request.user)
@ -680,6 +682,7 @@ def cart_checkout(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def cart_complete_checkout(request):
stripe_enabled = request.stripe_enabled
@ -828,6 +831,7 @@ def cart_complete_checkout(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def paypal_complete_checkout(request):
"""Complete checkout using PayPal payment."""
if not request.paypal_enabled:
@ -1110,6 +1114,7 @@ def adyen_create_session(request):
)
@user_required()
@shop_is_ready_required()
@trial_active_required()
def adyen_complete_checkout(request):
"""Complete checkout using Adyen payment."""
if not getattr(request, "adyen_enabled", False):

View file

@ -4,6 +4,7 @@ from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_owner_required,
trial_active_required,
get_referer_or_home,
)
@ -53,6 +54,7 @@ def gift_card_page(request):
@view_config(route_name="gift_card_add_to_cart", request_method="POST", require_csrf=True)
@user_required()
@trial_active_required()
def gift_card_add_to_cart(request):
"""Add a gift card to the active cart."""
shop = request.shop

View file

@ -5,6 +5,7 @@ from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_editor_required,
trial_active_required,
get_referer_or_home,
)
@ -132,6 +133,7 @@ def product(request):
@view_config(route_name="content_new", renderer="content_new.j2")
@user_required()
@shop_editor_required()
@trial_active_required()
def product_new(request):
title = request.params.get("title", "").strip()
description = request.params.get("description", "").strip()
@ -242,6 +244,7 @@ def product_edit_description(request):
@view_config(route_name="content_edit", renderer="product_edit.j2")
@view_config(route_name="content_edit2", renderer="product_edit.j2")
@shop_editor_required()
@trial_active_required()
def product_edit(request):
product_modified = False
product = request.product

View file

@ -1080,13 +1080,25 @@ def shop_settings(request):
if env_value not in (0, 1, 2):
request.session.flash(("Invalid environment value.", "error"))
elif env_value != 0 and shop.environment == 0:
# Changing from production to non-production
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}. "
"This shop is now hidden from public search and feeds.",
"success",
))
# Changing from production to non-production — check allowance
# Count OTHER production shops (excluding this one being changed)
prod_count = sum(1 for s in request.user.shops if s.is_production and s.id != shop.id)
non_prod_count = sum(1 for s in request.user.shops if s.is_non_production)
# Each production shop allows 2 non-prod shops; minimum 2 non-prod allowed
max_non_prod = max(2, prod_count * 2)
if non_prod_count >= max_non_prod:
request.session.flash((
"You need more production shops before creating additional dev/stage shops. "
"Each production shop includes 2 free dev/stage shops.",
"error",
))
else:
shop.environment = env_value
request.session.flash((
f"Shop environment changed to {shop.environment_label}. "
"This shop is now hidden from public search and feeds.",
"success",
))
elif env_value == 0 and shop.environment != 0:
# Changing from non-production to production
shop.environment = env_value
@ -1135,7 +1147,23 @@ def shop_settings(request):
shop.primary_s3_enabled = ps3_enabled
changed = True
if changed:
request.session.flash(("Storage bucket settings updated.", "success"))
if ps3_enabled:
# Test connection when enabling
try:
import boto3
test_client = boto3.session.Session().client(
"s3",
region_name=ps3_region,
endpoint_url=ps3_endpoint,
aws_access_key_id=ps3_access_key,
aws_secret_access_key=ps3_secret_key,
)
test_client.list_objects_v2(Bucket=ps3_bucket, MaxKeys=0)
request.session.flash(("Storage bucket settings saved and connection verified.", "success"))
except Exception as e:
request.session.flash((f"Bucket settings saved but connection test failed: {e}", "error"))
else:
request.session.flash(("Storage bucket settings updated.", "success"))
# If we processed any form submission, respond accordingly
if form_section: