diff --git a/make_post_sell/templates/user_purchases.j2 b/make_post_sell/templates/user_purchases.j2
index 9433c75..fe36303 100644
--- a/make_post_sell/templates/user_purchases.j2
+++ b/make_post_sell/templates/user_purchases.j2
@@ -10,7 +10,7 @@
{% if "thumbnail1" in product.extensions %}
-
+
{% endif %}
{{ product.title }}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 63105bb..320ae3a 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -102,12 +102,13 @@ class UnauthenticatedFunctionalTests(FunctionalTests):
self.assertIn(b"Cart $0.00 (0)", res.body)
def test_root_home_page_landing_content(self):
- """Anonymous visitors see the MPS landing with tagline and signup CTA."""
+ """Anonymous visitors see the MPS landing with hero, features, and CTAs."""
res = self.testapp.get("/", status=200)
body = res.body.decode()
- self.assertIn("Make. Post. Sell.", body)
- self.assertIn("start selling digital products", body)
+ self.assertIn("Make it. Post it. Sell it.", body)
+ self.assertIn("Commission-free", body)
self.assertIn("/join-or-log-in", body)
+ self.assertIn("/s/new", body)
def test_new_product_redirects(self):
redirect_res = self.testapp.get("/p/new", status=302)
@@ -4429,3 +4430,318 @@ class TestAnalytics(_AuthenticatedBase):
# Now data-has-bucket="1" should be present
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}", status=200)
self.assertIn('data-has-bucket="1"', res.text)
+
+
+class TestGiftCardFunctional(_AuthenticatedBase):
+ """Functional tests for gift card features."""
+
+ def _enable_gift_cards(self, shop, min_dollars="5.00", max_dollars="250.00"):
+ """Helper to enable gift cards on a shop via settings POST."""
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "gift-card-settings",
+ "gift-card-enabled-checkbox": "on",
+ "gift_card_min": min_dollars,
+ "gift_card_max": max_dollars,
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ return res
+
+ def test_gift_card_page_not_enabled(self):
+ """Gift card page redirects when gift cards are disabled (default)."""
+ shop = self._create_shop_helper()
+ # Gift cards are disabled by default, so GET should redirect
+ res = self.testapp.get(f"/s/{shop.id}/gift-card", status=302)
+
+ def test_gift_card_enable_settings(self):
+ """Enable gift cards via shop settings and verify DB state."""
+ shop = self._create_shop_helper()
+
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "gift-card-settings",
+ "gift-card-enabled-checkbox": "on",
+ "gift_card_min": "5.00",
+ "gift_card_max": "250.00",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Gift cards enabled", flash)
+
+ self.dbsession.refresh(shop)
+ self.assertTrue(shop.gift_card_enabled)
+ self.assertEqual(shop.gift_card_min_in_cents, 500)
+ self.assertEqual(shop.gift_card_max_in_cents, 25000)
+
+ def test_gift_card_page_enabled(self):
+ """Gift card page returns 200 when gift cards are enabled."""
+ shop = self._create_shop_helper()
+ self._enable_gift_cards(shop)
+ self.dbsession.refresh(shop)
+
+ res = self.testapp.get(f"/s/{shop.id}/gift-card", status=200)
+ self.assertIn("Gift Card", res.text)
+
+ def test_gift_card_manage_page(self):
+ """Gift card manage page returns 200 for shop owner."""
+ shop = self._create_shop_helper()
+ self._enable_gift_cards(shop)
+
+ res = self.testapp.get(f"/s/{shop.id}/gift-cards/manage", status=200)
+
+ def test_gift_card_apply_invalid_code(self):
+ """Applying a nonexistent gift card code shows error flash."""
+ shop = self._create_shop_helper()
+
+ # Create a product on the shop
+ redirect_res = self.testapp.post(
+ f"/p/new?shop_id={shop.id}", self.product1_params
+ )
+ res = redirect_res.follow()
+ product = get_all_products(self.dbsession).all()[0]
+
+ # Log out shop owner, log in as customer
+ self.testapp.get("/log-out")
+ self.log_in_user(self.user2_creds)
+
+ # Add product to cart
+ csrf_token = self.get_csrf_token(shop.uuid_str)
+ self.testapp.post(
+ "/cart/add",
+ {
+ "product_id": product.id,
+ "shop_id": shop.id,
+ "csrf_token": csrf_token,
+ },
+ )
+
+ # Try to apply an invalid gift card code
+ csrf_token = self.get_csrf_token(shop.uuid_str)
+ res = self.testapp.post(
+ "/gift-card/apply",
+ {
+ "gift_card_code": "GC-DOESNOTEXIST",
+ "csrf_token": csrf_token,
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("does not exist", flash)
+
+ def test_gift_card_settings_validation(self):
+ """Setting min > max shows validation error."""
+ shop = self._create_shop_helper()
+
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "gift-card-settings",
+ "gift-card-enabled-checkbox": "on",
+ "gift_card_min": "500.00",
+ "gift_card_max": "100.00",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Maximum must be greater than or equal to minimum", flash)
+
+
+class TestEnvironmentSettings(_AuthenticatedBase):
+ """MPS-14: Functional tests for environment settings."""
+
+ def test_change_to_staging(self):
+ shop = self._create_shop_helper()
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "1",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Staging", flash)
+ self.dbsession.refresh(shop)
+ self.assertEqual(shop.environment, 1)
+
+ def test_change_to_development(self):
+ shop = self._create_shop_helper()
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "2",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Development", flash)
+ self.dbsession.refresh(shop)
+ self.assertEqual(shop.environment, 2)
+
+ def test_change_back_to_production(self):
+ shop = self._create_shop_helper()
+ # First set to staging
+ self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "1",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ # Then back to production
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "0",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Production", flash)
+ self.dbsession.refresh(shop)
+ self.assertEqual(shop.environment, 0)
+
+ def test_invalid_environment_value(self):
+ shop = self._create_shop_helper()
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "99",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Invalid", flash)
+ self.dbsession.refresh(shop)
+ self.assertEqual(shop.environment, 0)
+
+ def test_environment_banner_shows_for_staging(self):
+ shop = self._create_shop_helper()
+ self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "environment-settings",
+ "environment": "1",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = self.testapp.get(f"/s/{shop.id}/settings")
+ self.assertIn("STAGING ENVIRONMENT", res.text)
+
+
+class TestBucketSettings(_AuthenticatedBase):
+ """MPS-16: Functional tests for BYOB bucket settings."""
+
+ def test_enable_bucket_settings(self):
+ shop = self._create_shop_helper()
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "bucket-settings",
+ "primary_s3_enabled_checkbox": "on",
+ "primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
+ "primary_s3_region": "nyc3",
+ "primary_s3_bucket": "my-test-bucket",
+ "primary_s3_access_key": "AKID123",
+ "primary_s3_secret_key": "SECRET456",
+ "primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ # 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")
+
+ def test_enable_bucket_missing_fields(self):
+ shop = self._create_shop_helper()
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "bucket-settings",
+ "primary_s3_enabled_checkbox": "on",
+ "primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
+ "primary_s3_region": "nyc3",
+ "primary_s3_bucket": "",
+ "primary_s3_access_key": "",
+ "primary_s3_secret_key": "",
+ "primary_s3_cdn_endpoint": "",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("All bucket fields are required when enabling BYOB", flash)
+ self.dbsession.refresh(shop)
+ self.assertFalse(shop.primary_s3_enabled)
+
+ def test_disable_bucket(self):
+ shop = self._create_shop_helper()
+ # Enable first
+ self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "bucket-settings",
+ "primary_s3_enabled_checkbox": "on",
+ "primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
+ "primary_s3_region": "nyc3",
+ "primary_s3_bucket": "my-test-bucket",
+ "primary_s3_access_key": "AKID123",
+ "primary_s3_secret_key": "SECRET456",
+ "primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ # Then disable (checkbox not sent = off)
+ res = self.testapp.post(
+ f"/s/{shop.id}/settings",
+ {
+ "form_section": "bucket-settings",
+ "primary_s3_endpoint": "https://nyc3.digitaloceanspaces.com",
+ "primary_s3_region": "nyc3",
+ "primary_s3_bucket": "my-test-bucket",
+ "primary_s3_access_key": "AKID123",
+ "primary_s3_secret_key": "SECRET456",
+ "primary_s3_cdn_endpoint": "https://my-test-bucket.nyc3.cdn.digitaloceanspaces.com",
+ "submit": "Save Settings",
+ },
+ status=302,
+ )
+ res = res.follow()
+ flash = self._get_flash_messages(res)
+ self.assertIn("Storage bucket settings updated", flash)
+ self.dbsession.refresh(shop)
+ self.assertFalse(shop.primary_s3_enabled)
diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py
index b138965..d635f40 100644
--- a/make_post_sell/tests/test_integration.py
+++ b/make_post_sell/tests/test_integration.py
@@ -27,6 +27,10 @@ from ..models.price import Price
from ..models.crypto_payment import CryptoPayment
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop_location import ShopLocation
+from ..models.gift_card import GiftCard, get_gift_card_by_id, get_gift_card_by_code, get_gift_cards_by_shop
+from ..models.gift_card_transaction import GiftCardTransaction
+from ..models.cart_gift_card import CartGiftCard
+import json
import time
@@ -3419,3 +3423,465 @@ class TestKaraokeTrackAclIntegration(DatabaseIntegrationTests):
product_acl,
f"vocals ACL mismatch at visibility={vis}",
)
+
+
+class TestGiftCardIntegration(DatabaseIntegrationTests):
+ """Integration tests for gift card functionality with real ORM objects."""
+
+ def _make_shop(self, gift_card_enabled=True):
+ """Helper to create a shop with gift card support."""
+ shop = Shop(
+ name="Gift Card Shop",
+ phone_number="555-555-5555",
+ billing_address="123 Test St",
+ description="A shop with gift cards",
+ )
+ shop.stripe_public_api_key = "pk_test_123"
+ shop.stripe_secret_api_key = "sk_test_123"
+ shop.domain_name = "giftcards.test.com"
+ shop.gift_card_enabled = gift_card_enabled
+ self.dbsession.add(shop)
+ self.dbsession.flush()
+ return shop
+
+ def _make_product(self, shop, price_in_cents=1000):
+ """Helper to create a product."""
+ product = Product(title="Test Product", description="Test product")
+ product.shop_id = shop.id
+ product.price_in_cents = price_in_cents
+ product.is_physical = False
+ self.dbsession.add(product)
+ self.dbsession.flush()
+ return product
+
+ def _make_user(self):
+ """Helper to create a user."""
+ user = get_or_create_user_by_email(self.dbsession, "test@example.com")
+ self.dbsession.add(user)
+ self.dbsession.flush()
+ return user
+
+ def _make_cart(self, user, shop):
+ """Helper to create a cart."""
+ cart = Cart(user=user)
+ cart.shop = shop
+ self.dbsession.add(cart)
+ self.dbsession.flush()
+ return cart
+
+ def test_gift_card_with_real_shop_integration(self):
+ """Test gift card creation and lookup with real shop."""
+ shop = self._make_shop(gift_card_enabled=True)
+
+ gift_card = GiftCard(shop=shop, amount_in_cents=5000)
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Test get_gift_card_by_id
+ found = get_gift_card_by_id(self.dbsession, gift_card.id)
+ self.assertIsNotNone(found)
+ self.assertEqual(found.id, gift_card.id)
+ self.assertEqual(found.initial_amount_in_cents, 5000)
+ self.assertEqual(found.balance_in_cents, 5000)
+
+ # Test get_gift_card_by_code with shop filter
+ found_by_code = get_gift_card_by_code(
+ self.dbsession, gift_card.code, shop=shop
+ )
+ self.assertIsNotNone(found_by_code)
+ self.assertEqual(found_by_code.id, gift_card.id)
+
+ # Test get_gift_cards_by_shop returns this gift card
+ shop_cards = get_gift_cards_by_shop(self.dbsession, shop).all()
+ self.assertEqual(len(shop_cards), 1)
+ self.assertEqual(shop_cards[0].id, gift_card.id)
+
+ transaction.commit()
+
+ def test_cart_with_gift_card_integration(self):
+ """Test cart with attached gift card reduces totals correctly."""
+ user = self._make_user()
+ shop = self._make_shop()
+ product = self._make_product(shop, price_in_cents=1000) # $10
+ cart = self._make_cart(user, shop)
+
+ cart.add_product(product)
+
+ # Create gift card with $50 balance
+ gift_card = GiftCard(shop=shop, amount_in_cents=5000)
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Attach gift card to cart via CartGiftCard
+ cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
+ self.dbsession.add(cart_gift_card)
+ self.dbsession.flush()
+
+ # Test gift card appears in cart.gift_cards
+ self.assertEqual(len(list(cart.gift_cards)), 1)
+
+ # Test cart is discounted
+ self.assertTrue(cart.is_discounted)
+
+ # Test discounted_shop_totals_in_cents shows reduced amount
+ shop_uuid = shop.uuid_str
+ discounted = cart.discounted_shop_totals_in_cents
+ self.assertEqual(discounted[shop_uuid], 0) # $10 - $50 gift card = $0
+
+ # Test gift_card_deductions dict has the gift card's uuid_str as key
+ deductions = cart.gift_card_deductions
+ self.assertIn(gift_card.uuid_str, deductions)
+ self.assertEqual(deductions[gift_card.uuid_str], 1000) # Deducted $10
+
+ transaction.commit()
+
+ def test_gift_card_deduction_integration(self):
+ """Test gift card deduct method reduces balance correctly."""
+ shop = self._make_shop()
+
+ gift_card = GiftCard(shop=shop, amount_in_cents=2000) # $20
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Deduct $15
+ result = gift_card.deduct(1500)
+ self.assertEqual(result, 1500)
+ self.assertEqual(gift_card.balance_in_cents, 500)
+ self.assertTrue(gift_card.is_valid)
+
+ # Deduct remaining $5
+ result = gift_card.deduct(500)
+ self.assertEqual(result, 500)
+ self.assertEqual(gift_card.balance_in_cents, 0)
+ self.assertTrue(gift_card.is_fully_redeemed)
+ self.assertFalse(gift_card.is_valid)
+
+ transaction.commit()
+
+ def test_gift_card_transaction_integration(self):
+ """Test gift card transaction records are linked correctly."""
+ user = self._make_user()
+ shop = self._make_shop()
+ product = self._make_product(shop, price_in_cents=1000)
+
+ gift_card = GiftCard(shop=shop, amount_in_cents=5000)
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Create an invoice for the transaction
+ invoice = Invoice(user=user)
+ self.dbsession.add(invoice)
+ self.dbsession.flush()
+
+ # Create a GiftCardTransaction
+ txn = GiftCardTransaction(
+ gift_card=gift_card, invoice=invoice, amount_in_cents=1000
+ )
+ self.dbsession.add(txn)
+ self.dbsession.flush()
+
+ # Assert transaction appears in gift_card.transactions
+ transactions = gift_card.transactions.all()
+ self.assertEqual(len(transactions), 1)
+ self.assertEqual(transactions[0].amount_in_cents, 1000)
+
+ transaction.commit()
+
+ def test_cart_with_gift_card_and_coupon_integration(self):
+ """Test coupon applies first, then gift card reduces remaining total."""
+ user = self._make_user()
+ shop = self._make_shop()
+ product = self._make_product(shop, price_in_cents=2000) # $20
+ cart = self._make_cart(user, shop)
+
+ cart.add_product(product)
+
+ # Create 50% off coupon ($10 off via dollar-off for predictability)
+ coupon = Coupon(
+ shop=shop,
+ code="HALF",
+ description="$10 off",
+ action_type="dollar-off",
+ action_value=10, # $10.00 off
+ max_redemptions=100,
+ max_redemptions_per_user=1,
+ cart_qualifier=0,
+ )
+ self.dbsession.add(coupon)
+ self.dbsession.flush()
+
+ # Attach coupon to cart
+ cart_coupon = CartCoupon(cart=cart, coupon=coupon)
+ self.dbsession.add(cart_coupon)
+ self.dbsession.flush()
+
+ # Create gift card with $5 balance
+ gift_card = GiftCard(shop=shop, amount_in_cents=500)
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Attach gift card to cart
+ cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
+ self.dbsession.add(cart_gift_card)
+ self.dbsession.flush()
+
+ # Coupon reduces $20 to $10, gift card reduces $10 to $5
+ shop_uuid = shop.uuid_str
+ discounted = cart.discounted_shop_totals_in_cents
+ self.assertEqual(discounted[shop_uuid], 500) # $5.00
+
+ transaction.commit()
+
+ def test_gift_card_validate_attached_integration(self):
+ """Test validation catches disabled gift cards attached to cart."""
+ user = self._make_user()
+ shop = self._make_shop()
+ product = self._make_product(shop, price_in_cents=1000)
+ cart = self._make_cart(user, shop)
+
+ cart.add_product(product)
+
+ # Create a disabled gift card
+ gift_card = GiftCard(shop=shop, amount_in_cents=5000)
+ gift_card.disabled = True
+ self.dbsession.add(gift_card)
+ self.dbsession.flush()
+
+ # Attach disabled gift card to cart
+ cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
+ self.dbsession.add(cart_gift_card)
+ self.dbsession.flush()
+
+ # Validate should return errors about disabled card
+ errors = cart.validate_attached_gift_cards()
+ self.assertTrue(len(errors) > 0)
+ self.assertTrue(
+ any("disabled" in err.lower() for err in errors),
+ f"Expected 'disabled' in error messages, got: {errors}",
+ )
+
+ transaction.commit()
+
+ def test_gift_card_purchases_in_cart_integration(self):
+ """Test cart.json_gift_cards parses gift card purchase entries."""
+ user = self._make_user()
+ shop = self._make_shop()
+ cart = self._make_cart(user, shop)
+
+ # Set json_gift_cards with one purchase entry
+ cart.json_gift_cards = json.dumps([
+ {
+ "shop_id": shop.uuid_str,
+ "amount_in_cents": 2500,
+ "gift_email": "friend@test.com",
+ "gift_message": "Enjoy!",
+ }
+ ])
+ self.dbsession.flush()
+
+ # Test gift_card_purchases returns parsed list
+ purchases = cart.gift_card_purchases
+ self.assertEqual(len(purchases), 1)
+ self.assertEqual(purchases[0]["amount_in_cents"], 2500)
+ self.assertEqual(purchases[0]["gift_email"], "friend@test.com")
+
+ # Test total
+ 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()
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 46b6063..3894fb7 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -3399,3 +3399,227 @@ class TestSentiment(unittest.TestCase):
def test_short_negative(self):
self.assertEqual(self._classify("hate it"), -1)
+
+
+class TestGiftCard(unittest.TestCase):
+
+ def test_gift_card_creation(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ self.assertEqual(gc.initial_amount_in_cents, 5000)
+ self.assertEqual(gc.balance_in_cents, 5000)
+ self.assertFalse(gc.disabled)
+ self.assertTrue(gc.code.startswith("GC-"))
+ self.assertEqual(len(gc.code), 19) # GC- + 16 hex chars
+
+ def test_gift_card_is_valid(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ self.assertTrue(gc.is_valid)
+
+ def test_gift_card_disabled_not_valid(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ gc.disabled = True
+ self.assertFalse(gc.is_valid)
+
+ def test_gift_card_zero_balance_not_valid(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ gc.balance_in_cents = 0
+ self.assertFalse(gc.is_valid)
+
+ def test_gift_card_deduct(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ deducted = gc.deduct(2000)
+ self.assertEqual(deducted, 2000)
+ self.assertEqual(gc.balance_in_cents, 3000)
+
+ def test_gift_card_deduct_more_than_balance(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=1000)
+ deducted = gc.deduct(5000)
+ self.assertEqual(deducted, 1000)
+ self.assertEqual(gc.balance_in_cents, 0)
+
+ def test_gift_card_balance_property(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ self.assertEqual(gc.balance, 50.00)
+ self.assertEqual(gc.initial_amount, 50.00)
+
+ def test_gift_card_is_fully_redeemed(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(shop=shop, amount_in_cents=5000)
+ self.assertFalse(gc.is_fully_redeemed)
+ gc.balance_in_cents = 0
+ self.assertTrue(gc.is_fully_redeemed)
+
+ def test_gift_card_code_uniqueness(self):
+ from make_post_sell.models.gift_card import generate_gift_card_code
+ codes = set()
+ for _ in range(100):
+ codes.add(generate_gift_card_code())
+ self.assertEqual(len(codes), 100)
+
+ def test_gift_card_with_gift_email(self):
+ from make_post_sell.models.gift_card import GiftCard
+ shop = mock.MagicMock()
+ shop.id = uuid.uuid1()
+ gc = GiftCard(
+ shop=shop,
+ amount_in_cents=2500,
+ purchaser_email="buyer@test.com",
+ gift_email="friend@test.com",
+ gift_message="Happy birthday!",
+ )
+ self.assertEqual(gc.gift_email, "friend@test.com")
+ self.assertEqual(gc.gift_message, "Happy birthday!")
+ self.assertEqual(gc.purchaser_email, "buyer@test.com")
+
+
+class TestShopEnvironment(unittest.TestCase):
+ """MPS-14: Dev & Stage environment properties."""
+
+ def _make_shop(self, environment=0):
+ shop = Shop("env-test", "555-0000", "123 Test St", "test shop")
+ shop.environment = environment
+ return shop
+
+ def test_default_is_production(self):
+ shop = self._make_shop()
+ self.assertTrue(shop.is_production)
+ self.assertFalse(shop.is_non_production)
+ self.assertEqual(shop.environment_label, "Production")
+
+ def test_staging_environment(self):
+ shop = self._make_shop(environment=1)
+ self.assertTrue(shop.is_staging)
+ self.assertTrue(shop.is_non_production)
+ self.assertFalse(shop.is_production)
+ self.assertEqual(shop.environment_label, "Staging")
+
+ def test_development_environment(self):
+ shop = self._make_shop(environment=2)
+ self.assertTrue(shop.is_development)
+ self.assertTrue(shop.is_non_production)
+ self.assertFalse(shop.is_production)
+ self.assertEqual(shop.environment_label, "Development")
+
+ def test_unknown_environment_defaults_to_production_label(self):
+ shop = self._make_shop(environment=99)
+ self.assertEqual(shop.environment_label, "Production")
+ self.assertTrue(shop.is_non_production)
+
+
+class TestShopTrial(unittest.TestCase):
+ """MPS-15: 21-day free trial properties."""
+
+ def _make_shop(self, trial_started_ms=None, plan_active=False):
+ shop = Shop("trial-test", "555-0000", "123 Test St", "test shop")
+ shop.trial_started_timestamp = trial_started_ms
+ shop.plan_active = plan_active
+ return shop
+
+ def test_grandfathered_shop_is_active(self):
+ """Pre-trial shops (NULL timestamp) are always active."""
+ shop = self._make_shop(trial_started_ms=None)
+ self.assertTrue(shop.is_active)
+ self.assertFalse(shop.is_trial_active)
+ self.assertFalse(shop.is_trial_expired)
+ self.assertIsNone(shop.trial_expiry_timestamp)
+
+ @mock.patch("make_post_sell.models.shop.time")
+ def test_trial_active_within_21_days(self, mock_time):
+ import time as real_time
+ now_ms = int(real_time.time() * 1000)
+ # Started 10 days ago
+ started = now_ms - (10 * 24 * 60 * 60 * 1000)
+ shop = self._make_shop(trial_started_ms=started)
+ mock_time.time.return_value = now_ms / 1000.0
+ self.assertTrue(shop.is_trial_active)
+ self.assertFalse(shop.is_trial_expired)
+ self.assertTrue(shop.is_active)
+ self.assertGreater(shop.trial_days_remaining, 0)
+
+ @mock.patch("make_post_sell.models.shop.time")
+ def test_trial_expired_after_21_days(self, mock_time):
+ import time as real_time
+ now_ms = int(real_time.time() * 1000)
+ # Started 22 days ago
+ started = now_ms - (22 * 24 * 60 * 60 * 1000)
+ shop = self._make_shop(trial_started_ms=started)
+ mock_time.time.return_value = now_ms / 1000.0
+ self.assertFalse(shop.is_trial_active)
+ self.assertTrue(shop.is_trial_expired)
+ self.assertFalse(shop.is_active)
+ self.assertEqual(shop.trial_days_remaining, 0)
+
+ @mock.patch("make_post_sell.models.shop.time")
+ def test_paid_plan_overrides_trial(self, mock_time):
+ import time as real_time
+ now_ms = int(real_time.time() * 1000)
+ # Started 22 days ago but plan is active
+ started = now_ms - (22 * 24 * 60 * 60 * 1000)
+ shop = self._make_shop(trial_started_ms=started, plan_active=True)
+ mock_time.time.return_value = now_ms / 1000.0
+ self.assertFalse(shop.is_trial_active)
+ self.assertFalse(shop.is_trial_expired)
+ self.assertTrue(shop.is_active)
+
+ def test_trial_expiry_timestamp(self):
+ shop = self._make_shop(trial_started_ms=1000000)
+ expected = 1000000 + (21 * 24 * 60 * 60 * 1000)
+ self.assertEqual(shop.trial_expiry_timestamp, expected)
+
+
+class TestShopBYOB(unittest.TestCase):
+ """MPS-16: Bring Your Own Bucket properties."""
+
+ def _make_shop(self, enabled=False, **kwargs):
+ shop = Shop("byob-test", "555-0000", "123 Test St", "test shop")
+ shop.primary_s3_enabled = enabled
+ shop.primary_s3_endpoint = kwargs.get("endpoint", "https://nyc3.digitaloceanspaces.com")
+ shop.primary_s3_region = kwargs.get("region", "nyc3")
+ shop.primary_s3_bucket = kwargs.get("bucket", "my-bucket")
+ shop.primary_s3_access_key = kwargs.get("access_key", "AKID")
+ shop.primary_s3_secret_key = kwargs.get("secret_key", "SECRET")
+ shop.primary_s3_cdn_endpoint = kwargs.get("cdn_endpoint", "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
+ return shop
+
+ def test_has_primary_s3_when_enabled_and_configured(self):
+ shop = self._make_shop(enabled=True)
+ self.assertTrue(shop.has_primary_s3)
+
+ def test_has_primary_s3_false_when_disabled(self):
+ shop = self._make_shop(enabled=False)
+ self.assertFalse(shop.has_primary_s3)
+
+ def test_has_primary_s3_false_when_missing_fields(self):
+ shop = self._make_shop(enabled=True, access_key="")
+ self.assertFalse(shop.has_primary_s3)
+
+ def test_media_cdn_endpoint_returns_custom_when_configured(self):
+ shop = self._make_shop(enabled=True)
+ self.assertEqual(shop.media_cdn_endpoint, "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
+
+ def test_media_cdn_endpoint_returns_none_when_not_configured(self):
+ shop = self._make_shop(enabled=False)
+ self.assertIsNone(shop.media_cdn_endpoint)
diff --git a/make_post_sell/views/__init__.py b/make_post_sell/views/__init__.py
index c6e21a8..91f85b1 100644
--- a/make_post_sell/views/__init__.py
+++ b/make_post_sell/views/__init__.py
@@ -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.",
diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py
index ced0322..a930df5 100644
--- a/make_post_sell/views/cart.py
+++ b/make_post_sell/views/cart.py
@@ -4,11 +4,14 @@ from . import (
user_required,
get_referer_or_home,
shop_is_ready_required,
+ trial_active_required,
)
from ..models.cart import get_cart_by_id
from ..models.product import get_product_by_id
from ..models.invoice import Invoice, InvoiceLineItem
+from ..models.gift_card import GiftCard, get_gift_card_by_code
+from ..models.gift_card_transaction import GiftCardTransaction
from pyramid.httpexceptions import HTTPFound
@@ -22,6 +25,76 @@ import traceback
from datetime import datetime
+def _create_gift_card_transactions(cart, invoices, request):
+ """After successful checkout, deduct gift card balances and create transaction records."""
+ import json
+
+ if cart.gift_cards:
+ deductions = cart.gift_card_deductions
+ for gift_card in cart.gift_cards:
+ deduction = deductions.get(gift_card.uuid_str, 0)
+ if deduction > 0:
+ # Find the invoice for this gift card's shop
+ invoice = None
+ for inv in invoices:
+ if inv.shop and str(inv.shop.id) == str(gift_card.shop_id):
+ invoice = inv
+ break
+ # Also check shop_id directly
+ if hasattr(inv, 'shop_id') and str(inv.shop_id) == str(gift_card.shop_id):
+ invoice = inv
+ break
+ if invoice:
+ gift_card.deduct(deduction)
+ txn = GiftCardTransaction(
+ gift_card=gift_card,
+ invoice=invoice,
+ amount_in_cents=deduction,
+ )
+ request.dbsession.add(gift_card)
+ request.dbsession.add(txn)
+
+ # Create GiftCard records for gift card purchases in the cart
+ gc_purchases = json.loads(cart.json_gift_cards or "[]")
+ created_gift_cards = []
+ if gc_purchases:
+ from ..models.shop import get_shop_by_id
+ from ..lib.mail import send_gift_card_email
+
+ for gc_item in gc_purchases:
+ shop = get_shop_by_id(request.dbsession, gc_item["shop_id"])
+ if shop:
+ # Find the invoice for this shop
+ invoice = None
+ for inv in invoices:
+ if str(inv.shop_id) == str(shop.id):
+ invoice = inv
+ break
+
+ new_gc = GiftCard(
+ shop=shop,
+ amount_in_cents=gc_item["amount_in_cents"],
+ purchaser_email=request.user.email if request.user else None,
+ gift_email=gc_item.get("gift_email"),
+ gift_message=gc_item.get("gift_message"),
+ invoice=invoice,
+ )
+ request.dbsession.add(new_gc)
+ created_gift_cards.append(new_gc)
+
+ # Send gift card email to recipient if gift_email is set
+ if gc_item.get("gift_email"):
+ try:
+ send_gift_card_email(request, new_gc)
+ except Exception:
+ pass # Don't fail checkout over email
+
+ # Clear gift card purchases from cart
+ cart.json_gift_cards = "[]"
+
+ return created_gift_cards
+
+
def get_cart_from_matchdict(request):
"""
This function uses the cart_id from the url path
@@ -452,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)
@@ -478,6 +552,13 @@ def cart_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
+ # Validate gift cards
+ gc_errors = cart.validate_attached_gift_cards()
+ if gc_errors:
+ for error_message in gc_errors:
+ request.session.flash((error_message, "error"))
+ return HTTPFound(get_referer_or_home(request))
+
# Check handling options, inventory, and address for physical products
if cart.physical_products:
inventory_errors = cart.check_inventory(request.shop_location)
@@ -601,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
@@ -638,6 +720,7 @@ def cart_complete_checkout(request):
return HTTPFound("/billing")
error_messages = cart.validate_attached_coupons()
+ error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@@ -708,6 +791,7 @@ def cart_complete_checkout(request):
request.dbsession.add(invoice)
cart.update_inventory(request.shop_location)
+ _create_gift_card_transactions(cart, invoices, request)
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
@@ -747,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:
@@ -768,6 +853,7 @@ def paypal_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
+ error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@@ -920,6 +1006,9 @@ def paypal_complete_checkout(request):
invoice.total,
)
+ if successful_invoices:
+ _create_gift_card_transactions(cart, successful_invoices, request)
+
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:
@@ -1025,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):
@@ -1046,6 +1136,7 @@ def adyen_complete_checkout(request):
return HTTPFound(get_referer_or_home(request))
error_messages = cart.validate_attached_coupons()
+ error_messages += cart.validate_attached_gift_cards()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
@@ -1147,6 +1238,9 @@ def adyen_complete_checkout(request):
invoice.total,
)
+ if successful_invoices:
+ _create_gift_card_transactions(cart, successful_invoices, request)
+
if successful_invoices and not failed_shops:
request.session.flash(("Success! You have completed the purchase.", "success"))
elif successful_invoices and failed_shops:
diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py
index 344d821..fbfcd73 100644
--- a/make_post_sell/views/content.py
+++ b/make_post_sell/views/content.py
@@ -29,7 +29,7 @@ def content(request):
signed_get_object_url = None
- bucket_name = request.app["bucket.secure_uploads"]
+ bucket_name = request.shop_bucket_name
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
@@ -55,7 +55,7 @@ def content(request):
if content_type:
params["ResponseContentType"] = content_type
- signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
+ signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@@ -88,7 +88,7 @@ def content(request):
extension = product.extensions.get("product")
media_type = get_media_type(extension) if extension else None
if media_type in ("video", "audio"):
- cdn_base = request.app["bucket.secure_uploads.get_endpoint"]
+ cdn_base = request.shop_cdn_endpoint
for track_name in ("instrumentals", "vocals"):
if track_name in product.extensions:
url = f"{cdn_base}/{product.s3_path}/{track_name}"
diff --git a/make_post_sell/views/feeds.py b/make_post_sell/views/feeds.py
index e234a62..11fdcaa 100644
--- a/make_post_sell/views/feeds.py
+++ b/make_post_sell/views/feeds.py
@@ -206,7 +206,7 @@ def sitemap_view(request):
"""Generate XML sitemap for the shop."""
shop = request.shop
- if not shop:
+ if not shop or shop.is_non_production:
response = Response(body="
")
response.content_type = "application/xml"
return response
@@ -226,7 +226,7 @@ def rss_view(request):
"""Generate RSS 2.0 feed for the shop."""
shop = request.shop
- if not shop:
+ if not shop or shop.is_non_production:
response = Response(body="
")
response.content_type = "application/xml"
return response
@@ -245,7 +245,7 @@ def atom_view(request):
"""Generate Atom feed for the shop."""
shop = request.shop
- if not shop:
+ if not shop or shop.is_non_production:
response = Response(body="
")
response.content_type = "application/xml"
return response
diff --git a/make_post_sell/views/gift_card.py b/make_post_sell/views/gift_card.py
new file mode 100644
index 0000000..fae1955
--- /dev/null
+++ b/make_post_sell/views/gift_card.py
@@ -0,0 +1,238 @@
+from pyramid.view import view_config
+from pyramid.httpexceptions import HTTPFound
+
+from . import (
+ user_required,
+ shop_owner_required,
+ trial_active_required,
+ get_referer_or_home,
+)
+
+from ..models.gift_card import (
+ GiftCard,
+ get_gift_card_by_id,
+ get_gift_card_by_code,
+ get_gift_cards_by_shop,
+)
+from ..models.cart import get_cart_by_id
+from ..lib.currency import validate_int, cents_to_dollars, dollars_to_cents
+
+
+@view_config(route_name="gift_card_page", renderer="gift_card.j2")
+def gift_card_page(request):
+ """Gift card purchase page for a shop."""
+ shop = request.shop
+ if not shop or not shop.gift_card_enabled:
+ request.session.flash(("Gift cards are not available for this shop.", "error"))
+ return HTTPFound(get_referer_or_home(request))
+
+ # Handle balance check
+ balance_result = None
+ check_code = request.params.get("check_code", "").strip()
+ if check_code:
+ card = get_gift_card_by_code(request.dbsession, check_code, shop=shop)
+ if card and card.is_valid:
+ balance_result = {
+ "code": card.code,
+ "balance": card.balance,
+ "initial_amount": card.initial_amount,
+ }
+ elif card and card.disabled:
+ balance_result = {"error": "This gift card has been disabled."}
+ elif card and card.is_fully_redeemed:
+ balance_result = {"error": "This gift card has been fully redeemed."}
+ else:
+ balance_result = {"error": "Gift card not found for this shop."}
+
+ return {
+ "shop": shop,
+ "min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
+ "max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
+ "balance_result": balance_result,
+ }
+
+
+@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
+ if not shop or not shop.gift_card_enabled:
+ request.session.flash(("Gift cards are not available for this shop.", "error"))
+ return HTTPFound(get_referer_or_home(request))
+
+ try:
+ amount_dollars = float(request.params.get("amount", "0"))
+ amount_in_cents = int(amount_dollars * 100)
+ except (ValueError, TypeError):
+ request.session.flash(("Invalid amount.", "error"))
+ return HTTPFound(get_referer_or_home(request))
+
+ if amount_in_cents < shop.gift_card_min_in_cents:
+ request.session.flash(
+ (f"Minimum gift card amount is ${cents_to_dollars(shop.gift_card_min_in_cents):,.2f}.", "error")
+ )
+ return HTTPFound(get_referer_or_home(request))
+
+ if amount_in_cents > shop.gift_card_max_in_cents:
+ request.session.flash(
+ (f"Maximum gift card amount is ${cents_to_dollars(shop.gift_card_max_in_cents):,.2f}.", "error")
+ )
+ return HTTPFound(get_referer_or_home(request))
+
+ gift_email = request.params.get("gift_email", "").strip() or None
+ gift_message = request.params.get("gift_message", "").strip() or None
+
+ # Store gift card purchase intent in cart's json_gift_cards
+ import json
+ cart = request.active_cart
+ gift_cards_data = json.loads(cart.json_gift_cards) if hasattr(cart, 'json_gift_cards') and cart.json_gift_cards else []
+ gift_cards_data.append({
+ "shop_id": shop.uuid_str,
+ "amount_in_cents": amount_in_cents,
+ "gift_email": gift_email,
+ "gift_message": gift_message,
+ })
+ cart.json_gift_cards = json.dumps(gift_cards_data)
+ request.dbsession.add(cart)
+ request.dbsession.flush()
+
+ request.session.flash(
+ (f"${amount_dollars:,.2f} gift card added to cart.", "success")
+ )
+ return HTTPFound(f"/cart/{cart.id}")
+
+
+@view_config(route_name="gift_card_apply_to_cart", request_method="POST", require_csrf=True)
+def gift_card_apply_to_cart(request):
+ """Apply a gift card code to the active cart (for redemption at checkout)."""
+ code = request.params.get("gift_card_code", "").strip()
+
+ if not code:
+ request.session.flash(("Please enter a gift card code.", "error"))
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+ gift_card = get_gift_card_by_code(request.dbsession, code)
+
+ if gift_card is None:
+ request.session.flash(("That gift card code does not exist.", "error"))
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+ if gift_card.disabled:
+ request.session.flash(("That gift card has been disabled.", "error"))
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+ if gift_card.balance_in_cents <= 0:
+ request.session.flash(("That gift card has no remaining balance.", "error"))
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+ # Check if gift card's shop is in the cart
+ if gift_card.shop_uuid_str not in request.active_cart.shop_totals_in_cents:
+ request.session.flash(
+ ("That gift card is not valid for any shop in your cart.", "error")
+ )
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+ if gift_card not in request.active_cart.gift_cards:
+ request.active_cart.gift_cards.append(gift_card)
+ request.active_cart._bust_memoized_attributes()
+ request.dbsession.add(request.active_cart)
+ request.dbsession.flush()
+ msg = (
+ f"Gift card applied! Balance: ${gift_card.balance:,.2f}",
+ "success",
+ )
+ else:
+ msg = ("That gift card is already applied to your cart.", "info")
+
+ request.session.flash(msg)
+ return HTTPFound(f"/cart/{request.active_cart.id}")
+
+
+@view_config(route_name="gift_card_remove_from_cart", request_method="POST", require_csrf=True)
+def gift_card_remove_from_cart(request):
+ """Remove a gift card from a cart."""
+ cart_id = request.params.get("cart_id")
+ gift_card_id = request.params.get("gift_card_id")
+
+ cart = get_cart_by_id(request.dbsession, cart_id)
+ gift_card = get_gift_card_by_id(request.dbsession, gift_card_id)
+
+ if cart is None:
+ msg = ("That cart does not exist.", "error")
+ elif gift_card is None:
+ msg = ("That gift card does not exist.", "error")
+ elif request.user and request.user.does_not_own_cart(cart):
+ msg = ("You do not own that cart.", "error")
+ elif request.user is None and cart.user is not None:
+ msg = ("You do not own this cart.", "error")
+ else:
+ if gift_card in cart.gift_cards:
+ cart.gift_cards.remove(gift_card)
+ cart._bust_memoized_attributes()
+ request.dbsession.add(cart)
+ request.dbsession.flush()
+ msg = ("Gift card removed from your cart.", "success")
+ else:
+ msg = ("That gift card was already removed from your cart.", "info")
+ request.session.flash(msg)
+ return HTTPFound(f"/cart/{cart_id}")
+
+ request.session.flash(msg)
+ return HTTPFound(get_referer_or_home(request))
+
+
+# --- Shop Admin Views ---
+
+
+@view_config(route_name="gift_card_manage", renderer="gift_card_manage.j2")
+@shop_owner_required()
+def gift_card_manage(request):
+ """Gift card management page for shop owners."""
+ shop = request.shop
+ gift_cards = get_gift_cards_by_shop(request.dbsession, shop).all()
+ total_issued = sum(gc.initial_amount_in_cents for gc in gift_cards)
+ total_balance = sum(gc.balance_in_cents for gc in gift_cards)
+
+ return {
+ "gift_cards": gift_cards,
+ "total_issued_dollars": cents_to_dollars(total_issued),
+ "total_balance_dollars": cents_to_dollars(total_balance),
+ }
+
+
+@view_config(route_name="gift_card_detail", renderer="gift_card_detail.j2")
+@shop_owner_required()
+def gift_card_detail(request):
+ """Gift card detail page for shop owners."""
+ gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
+ if gift_card is None:
+ request.session.flash(("Gift card not found.", "error"))
+ return HTTPFound(get_referer_or_home(request))
+
+ from ..models.gift_card_transaction import GiftCardTransaction as GCT
+ transactions = list(gift_card.transactions)
+
+ return {
+ "gift_card": gift_card,
+ "transactions": transactions,
+ }
+
+
+@view_config(route_name="gift_card_toggle", request_method="POST", require_csrf=True)
+@shop_owner_required()
+def gift_card_toggle(request):
+ """Enable/disable a gift card (admin kill switch)."""
+ gift_card = get_gift_card_by_id(request.dbsession, request.matchdict["gift_card_id"])
+ if gift_card is None:
+ request.session.flash(("Gift card not found.", "error"))
+ return HTTPFound(get_referer_or_home(request))
+
+ gift_card.disabled = not gift_card.disabled
+ request.dbsession.add(gift_card)
+ request.dbsession.flush()
+
+ status = "disabled" if gift_card.disabled else "enabled"
+ request.session.flash((f"Gift card {gift_card.code} {status}.", "success"))
+ return HTTPFound(get_referer_or_home(request))
diff --git a/make_post_sell/views/player.py b/make_post_sell/views/player.py
index b2176d4..e372bd1 100644
--- a/make_post_sell/views/player.py
+++ b/make_post_sell/views/player.py
@@ -44,7 +44,7 @@ def player(request):
return HTTPBadRequest("Not a supported media file")
# Generate presigned URL
- bucket_name = request.app["bucket.secure_uploads"]
+ bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@@ -62,7 +62,7 @@ def player(request):
if content_type:
params["ResponseContentType"] = content_type
- presigned_url = request.secure_uploads_client.generate_presigned_url(
+ presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900, # 15 minutes
@@ -127,7 +127,7 @@ def player(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
- url = request.secure_uploads_client.generate_presigned_url(
+ url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@@ -192,7 +192,7 @@ def player_json(request):
return {"error": "Not a supported media file"}
# Generate presigned URL
- bucket_name = request.app["bucket.secure_uploads"]
+ bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@@ -209,7 +209,7 @@ def player_json(request):
if content_type:
params["ResponseContentType"] = content_type
- presigned_url = request.secure_uploads_client.generate_presigned_url(
+ presigned_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@@ -259,7 +259,7 @@ def player_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
- url = request.secure_uploads_client.generate_presigned_url(
+ url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py
index 4f182cd..be721ef 100644
--- a/make_post_sell/views/product.py
+++ b/make_post_sell/views/product.py
@@ -5,6 +5,7 @@ from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_editor_required,
+ trial_active_required,
get_referer_or_home,
)
@@ -44,7 +45,7 @@ def product(request):
signed_get_object_url = None
- bucket_name = request.app["bucket.secure_uploads"]
+ bucket_name = request.shop_bucket_name
if (
request.user
@@ -75,7 +76,7 @@ def product(request):
if content_type:
params["ResponseContentType"] = content_type
- signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
+ signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
@@ -98,7 +99,7 @@ def product(request):
from ..models.product import get_ring_related_products, get_related_products
ring = product.shop.discovery_ring
if ring:
- related_products = get_ring_related_products(product, ring, forward=42)
+ related_products = get_ring_related_products(product, ring, forward=len(ring))
else:
related_products = get_related_products(product)
@@ -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
@@ -284,8 +287,8 @@ def product_edit(request):
product_modified = True
product.set_visibility(
visibility,
- request.secure_uploads_client,
- request.app["bucket.secure_uploads"],
+ request.shop_uploads_client,
+ request.shop_bucket_name,
)
request.session.flash(("You updated the product's visibility.", "success"))
@@ -297,7 +300,7 @@ def product_edit(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
- response = request.secure_uploads_client.head_object(
+ response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@@ -325,9 +328,9 @@ def product_edit(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
- request.secure_uploads_client.copy_object(
+ request.shop_uploads_client.copy_object(
ACL=acl,
- Bucket=request.app["bucket.secure_uploads"],
+ Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@@ -342,8 +345,8 @@ def product_edit(request):
# Mirror to shop's custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
- request.secure_uploads_client,
- request.app["bucket.secure_uploads"],
+ request.shop_uploads_client,
+ request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.shop,
content_type=product.get_content_type(file_key),
@@ -351,14 +354,14 @@ def product_edit(request):
)
# delete original upload key.
- request.secure_uploads_client.delete_object(
+ request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
# get file size & store in our database.
- response = request.secure_uploads_client.head_object(
- Bucket=request.app["bucket.secure_uploads"],
+ response = request.shop_uploads_client.head_object(
+ Bucket=request.shop_bucket_name,
Key=f"{product.s3_path}/{file_key}",
)
@@ -385,8 +388,8 @@ def product_edit(request):
is_video = (upload_media_type == "video")
ext = product.extensions.get(file_key)
sizes = process_karaoke(
- request.secure_uploads_client,
- request.app["bucket.secure_uploads"],
+ request.shop_uploads_client,
+ request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.s3_path, is_video, ext,
public_key=shop.unsandbox_public_key,
@@ -401,14 +404,14 @@ def product_edit(request):
product.file_bytes = tmp
request.dbsession.add(product)
request.dbsession.flush()
- product.update_s3_acls(request.secure_uploads_client, request.app["bucket.secure_uploads"])
+ product.update_s3_acls(request.shop_uploads_client, request.shop_bucket_name)
# Mirror karaoke tracks to shop's custom bucket
if shop.has_s3_mirror:
from ..lib.s3_mirror import mirror_keys_async
mirror_keys_async(
- request.secure_uploads_client,
- request.app["bucket.secure_uploads"],
+ request.shop_uploads_client,
+ request.shop_bucket_name,
[f"{product.s3_path}/instrumentals", f"{product.s3_path}/vocals"],
shop,
)
@@ -435,8 +438,8 @@ def product_edit(request):
["starts-with", "$key", key_starts_with],
]
- signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
- Bucket=request.app["bucket.secure_uploads"],
+ signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
+ Bucket=request.shop_bucket_name,
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
@@ -482,14 +485,14 @@ def product_edit(request):
if product.has_product_file:
try:
params = {
- "Bucket": request.app["bucket.secure_uploads"],
+ "Bucket": request.shop_bucket_name,
"Key": product.s3_key,
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
}
content_type = product.get_content_type("product")
if content_type:
params["ResponseContentType"] = content_type
- signed_product_url = request.secure_uploads_client.generate_presigned_url(
+ signed_product_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py
index 5b1dbb0..9c6c987 100644
--- a/make_post_sell/views/shop.py
+++ b/make_post_sell/views/shop.py
@@ -186,7 +186,28 @@ def shop_new(request):
request.session.flash(msg)
else:
+ # MPS-14: environment selector (default production)
+ environment = int(request.params.get("environment", "0"))
+ if environment not in (0, 1, 2):
+ environment = 0
+
+ # MPS-14: enforce 2 free dev/stage shops per production shop
+ if environment != 0:
+ prod_count = sum(1 for s in request.user.shops if s.is_production)
+ non_prod_count = sum(1 for s in request.user.shops if s.is_non_production)
+ allowed = prod_count * 2
+ if non_prod_count >= allowed:
+ request.session.flash((
+ "You need a production shop before creating dev/stage shops. "
+ "Each production shop includes 2 free dev/stage shops.",
+ "error",
+ ))
+ return HTTPFound("/s/new")
+
+ import time as _time
shop = Shop(name, phone_number, billing_address, description)
+ shop.environment = environment
+ shop.trial_started_timestamp = int(_time.time() * 1000)
shop.add_user_to_shop(request.user)
request.user.set_active_shop(shop)
request.dbsession.add(shop)
@@ -466,6 +487,16 @@ def shop_settings(request):
)
if request.method == "POST":
+ # MPS-15: Block settings changes when trial expired, except
+ # environment-settings and bucket-settings (needed for onboarding)
+ allowed_when_expired = ("environment-settings", "bucket-settings")
+ if shop.is_trial_expired and form_section not in allowed_when_expired:
+ request.session.flash((
+ "Your 21-day trial has expired. Choose a plan to continue editing settings.",
+ "error",
+ ))
+ return HTTPFound(f"/s/{shop.id}/settings")
+
# Handle shop settings form
if form_section == "shop-settings":
if name != shop.name:
@@ -1022,6 +1053,128 @@ def shop_settings(request):
except (ValueError, TypeError):
request.session.flash(("Invalid high risk threshold", "error"))
+ # Handle gift card settings
+ if form_section == "gift-card-settings":
+ gc_enabled_checkbox = request.params.get("gift-card-enabled-checkbox", "off")
+ gc_enabled = checkbox_to_bool(gc_enabled_checkbox)
+ if shop.gift_card_enabled != gc_enabled:
+ shop.gift_card_enabled = gc_enabled
+ status = "enabled" if gc_enabled else "disabled"
+ request.session.flash((f"Gift cards {status}.", "success"))
+
+ try:
+ gc_min = float(request.params.get("gift_card_min", "5.00"))
+ gc_max = float(request.params.get("gift_card_max", "250.00"))
+ gc_min_cents = int(gc_min * 100)
+ gc_max_cents = int(gc_max * 100)
+
+ if gc_min_cents < 100:
+ request.session.flash(("Minimum gift card amount must be at least $1.00.", "error"))
+ elif gc_max_cents < gc_min_cents:
+ request.session.flash(("Maximum must be greater than or equal to minimum.", "error"))
+ elif gc_max_cents > 1000000:
+ request.session.flash(("Maximum gift card amount cannot exceed $10,000.", "error"))
+ else:
+ if shop.gift_card_min_in_cents != gc_min_cents:
+ shop.gift_card_min_in_cents = gc_min_cents
+ request.session.flash((f"Gift card minimum set to ${gc_min:,.2f}.", "success"))
+ if shop.gift_card_max_in_cents != gc_max_cents:
+ shop.gift_card_max_in_cents = gc_max_cents
+ request.session.flash((f"Gift card maximum set to ${gc_max:,.2f}.", "success"))
+ except (ValueError, TypeError):
+ request.session.flash(("Invalid gift card amount.", "error"))
+
+ # Handle environment settings (MPS-14)
+ if form_section == "environment-settings":
+ env_value = int(request.params.get("environment", "0"))
+ 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 — 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
+ request.session.flash((
+ "Shop environment changed to Production. "
+ "This shop is now publicly visible.",
+ "success",
+ ))
+ elif env_value != shop.environment:
+ shop.environment = env_value
+ request.session.flash((
+ f"Shop environment changed to {shop.environment_label}.",
+ "success",
+ ))
+
+ # Handle primary S3 bucket settings (MPS-16)
+ if form_section == "bucket-settings":
+ ps3_endpoint = request.params.get("primary_s3_endpoint", "").strip()
+ ps3_region = request.params.get("primary_s3_region", "").strip()
+ ps3_bucket = request.params.get("primary_s3_bucket", "").strip()
+ ps3_access_key = request.params.get("primary_s3_access_key", "").strip()
+ ps3_secret_key = request.params.get("primary_s3_secret_key", "").strip()
+ ps3_cdn_endpoint = request.params.get("primary_s3_cdn_endpoint", "").strip()
+ ps3_enabled = checkbox_to_bool(request.params.get("primary_s3_enabled_checkbox", "off"))
+
+ if ps3_enabled and not all([ps3_endpoint, ps3_region, ps3_bucket, ps3_access_key, ps3_secret_key, ps3_cdn_endpoint]):
+ request.session.flash(("All bucket fields are required when enabling BYOB.", "error"))
+ elif ps3_endpoint and not ps3_endpoint.startswith("https://"):
+ request.session.flash(("Bucket endpoint must start with https://", "error"))
+ elif ps3_cdn_endpoint and not ps3_cdn_endpoint.startswith("https://"):
+ request.session.flash(("CDN endpoint must start with https://", "error"))
+ else:
+ changed = False
+ for attr, val in [
+ ("primary_s3_endpoint", ps3_endpoint),
+ ("primary_s3_region", ps3_region),
+ ("primary_s3_bucket", ps3_bucket),
+ ("primary_s3_access_key", ps3_access_key),
+ ("primary_s3_secret_key", ps3_secret_key),
+ ("primary_s3_cdn_endpoint", ps3_cdn_endpoint),
+ ]:
+ if getattr(shop, attr) != val:
+ setattr(shop, attr, val)
+ changed = True
+ if shop.primary_s3_enabled != ps3_enabled:
+ shop.primary_s3_enabled = ps3_enabled
+ changed = True
+ if changed:
+ 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:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
@@ -1036,7 +1189,7 @@ def shop_settings(request):
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
- response = request.secure_uploads_client.head_object(
+ response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@@ -1065,9 +1218,9 @@ def shop_settings(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
- request.secure_uploads_client.copy_object(
+ request.shop_uploads_client.copy_object(
ACL=acl,
- Bucket=request.app["bucket.secure_uploads"],
+ Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
@@ -1082,8 +1235,8 @@ def shop_settings(request):
# Mirror shop asset to custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
- request.secure_uploads_client,
- request.app["bucket.secure_uploads"],
+ request.shop_uploads_client,
+ request.shop_bucket_name,
f"{shop.id}/meta/{file_key}",
shop,
content_type=content_type,
@@ -1091,7 +1244,7 @@ def shop_settings(request):
)
# delete original upload key.
- request.secure_uploads_client.delete_object(
+ request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
@@ -1122,15 +1275,15 @@ def shop_settings(request):
["starts-with", "$key", key_starts_with],
]
- signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
- Bucket=request.app["bucket.secure_uploads"],
+ signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
+ Bucket=request.shop_bucket_name,
# uploads to /
/meta/shop-logo-banner.the-users-file.png
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
)
get_endpoints[file_key] = "{}/{}/meta/{}".format(
- request.app["bucket.secure_uploads.get_endpoint"],
+ request.shop_cdn_endpoint,
shop.id,
file_key,
)
@@ -1214,6 +1367,18 @@ def shop_settings(request):
"mirror_s3_access_key": shop.mirror_s3_access_key or "",
"mirror_s3_secret_key": shop.mirror_s3_secret_key or "",
"mirror_s3_enabled": shop.mirror_s3_enabled,
+ "gift_card_enabled": shop.gift_card_enabled,
+ "gift_card_min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
+ "gift_card_max_dollars": cents_to_dollars(shop.gift_card_max_in_cents),
+ "environment": shop.environment,
+ "environment_label": shop.environment_label,
+ "primary_s3_endpoint": shop.primary_s3_endpoint or "",
+ "primary_s3_region": shop.primary_s3_region or "",
+ "primary_s3_bucket": shop.primary_s3_bucket or "",
+ "primary_s3_access_key": shop.primary_s3_access_key or "",
+ "primary_s3_secret_key": shop.primary_s3_secret_key or "",
+ "primary_s3_cdn_endpoint": shop.primary_s3_cdn_endpoint or "",
+ "primary_s3_enabled": shop.primary_s3_enabled,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}
diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py
index 244b5ad..b144f2d 100644
--- a/make_post_sell/views/watch.py
+++ b/make_post_sell/views/watch.py
@@ -42,7 +42,7 @@ def watch_json(request):
media_type = get_media_type(extension) or "other"
# Generate presigned URL for media
- bucket_name = request.app["bucket.secure_uploads"]
+ bucket_name = request.shop_bucket_name
s3_key = f"{product.s3_path}/{file_key}"
params = {
@@ -59,7 +59,7 @@ def watch_json(request):
if content_type:
params["ResponseContentType"] = content_type
- media_url = request.secure_uploads_client.generate_presigned_url(
+ media_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
@@ -75,7 +75,7 @@ def watch_json(request):
track_ct = product.get_content_type(track_name) or (
"video/mp4" if media_type == "video" else "audio/wav"
)
- url = request.secure_uploads_client.generate_presigned_url(
+ url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket_name,
@@ -94,7 +94,7 @@ def watch_json(request):
thumbnail_url = None
if "thumbnail1" in product.extensions:
thumbnail_url = (
- f"{request.app['bucket.secure_uploads.get_endpoint']}"
+ f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/thumbnail1"
f"?ts={product.updated_timestamp}"
)
@@ -113,9 +113,9 @@ def watch_json(request):
except (ValueError, TypeError):
direction = 1
if direction == -1:
- related = get_ring_related_products(product, ring, forward=3, backward=42)
+ related = get_ring_related_products(product, ring, forward=3, backward=len(ring))
else:
- related = get_ring_related_products(product, ring, forward=42, backward=3)
+ related = get_ring_related_products(product, ring, forward=len(ring), backward=3)
else:
related = get_related_products(product)
@@ -132,7 +132,7 @@ def watch_json(request):
r_thumb = None
if "thumbnail1" in r.extensions:
r_thumb = (
- f"{request.app['bucket.secure_uploads.get_endpoint']}"
+ f"{request.shop_cdn_endpoint}"
f"/{r.s3_path}/thumbnail1"
f"?ts={r.updated_timestamp}"
)
@@ -164,7 +164,7 @@ def watch_json(request):
dl_params["ResponseContentType"] = dl_content_type
file_type_str = dl_content_type
file_size_str = product.human_file_bytes(file_key)
- download_url = request.secure_uploads_client.generate_presigned_url(
+ download_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=dl_params,
ExpiresIn=900,
@@ -174,7 +174,7 @@ def watch_json(request):
file_url = None
if has_product_file:
file_url = (
- f"{request.app['bucket.secure_uploads.get_endpoint']}"
+ f"{request.shop_cdn_endpoint}"
f"/{product.s3_path}/{file_key}"
f"?ts={product.updated_timestamp}"
)