feat: implement MPS-14 (environment), MPS-15 (trial), MPS-16 (BYOB)
MPS-14: Add environment column (production/staging/development) to shops. Non-production shops excluded from feeds, search, and discovery. Environment banner in base template. Settings UI and creation selector. MPS-15: Add 21-day free trial with trial_started_timestamp on shop creation. Properties: is_trial_active, is_trial_expired, trial_days_remaining, is_active. Grandfathered pre-trial shops (NULL timestamp = paid). Trial banner in base template. MPS-16: BYOB (Bring Your Own Bucket) - per-shop S3 storage with primary_s3_* columns. Shop-aware request methods (shop_uploads_client, shop_bucket_name, shop_cdn_endpoint) that fall back to MPS default. All templates and views updated from global to shop-aware S3 references. Bucket settings UI in shop settings. Migration: 9884324a48e3 (idempotent, 11 new columns on mps_shop). Tests: 14 unit tests + 8 functional tests (712 total, all pass).
This commit is contained in:
parent
0fc8b62aa1
commit
2cb1e79400
25 changed files with 918 additions and 85 deletions
|
|
@ -200,7 +200,7 @@ def send_purchase_email(request, to_email, products, total_cost):
|
|||
thumbnail = ""
|
||||
if "thumbnail1" in p.extensions:
|
||||
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
|
||||
request.app["bucket.secure_uploads.get_endpoint"],
|
||||
request.shop_cdn_endpoint,
|
||||
p.s3_path,
|
||||
p.updated_timestamp,
|
||||
)
|
||||
|
|
@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost):
|
|||
thumbnail = ""
|
||||
if "thumbnail1" in p.extensions:
|
||||
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
|
||||
request.app["bucket.secure_uploads.get_endpoint"],
|
||||
request.shop_cdn_endpoint,
|
||||
p.s3_path,
|
||||
p.updated_timestamp,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -166,6 +166,23 @@ class Shop(RBase, Base):
|
|||
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
|
||||
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
|
||||
|
||||
# Shop environment: 0=production, 1=staging, 2=development
|
||||
environment = Column(BigInteger, nullable=False, default=0)
|
||||
|
||||
# Trial and billing
|
||||
trial_started_timestamp = Column(BigInteger, nullable=True)
|
||||
trial_ended = Column(Boolean, default=False)
|
||||
plan_active = Column(Boolean, default=False)
|
||||
|
||||
# Primary S3 bucket (Bring Your Own Bucket)
|
||||
primary_s3_endpoint = Column(Unicode(256), nullable=True)
|
||||
primary_s3_region = Column(Unicode(64), nullable=True)
|
||||
primary_s3_bucket = Column(Unicode(128), nullable=True)
|
||||
primary_s3_access_key = Column(Unicode(128), nullable=True)
|
||||
primary_s3_secret_key = Column(Unicode(128), nullable=True)
|
||||
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
|
||||
primary_s3_enabled = Column(Boolean, default=False)
|
||||
|
||||
# Precomputed discovery ring: circular ordering of all public products
|
||||
json_discovery_ring = Column(UnicodeText, nullable=True)
|
||||
|
||||
|
|
@ -248,6 +265,94 @@ class Shop(RBase, Base):
|
|||
us.role_id = role_id
|
||||
return us
|
||||
|
||||
# --- Environment properties (MPS-14) ---
|
||||
|
||||
@property
|
||||
def is_production(self):
|
||||
return self.environment == 0
|
||||
|
||||
@property
|
||||
def is_staging(self):
|
||||
return self.environment == 1
|
||||
|
||||
@property
|
||||
def is_development(self):
|
||||
return self.environment == 2
|
||||
|
||||
@property
|
||||
def is_non_production(self):
|
||||
return self.environment != 0
|
||||
|
||||
@property
|
||||
def environment_label(self):
|
||||
return {0: "Production", 1: "Staging", 2: "Development"}.get(
|
||||
self.environment, "Production"
|
||||
)
|
||||
|
||||
# --- Trial properties (MPS-15) ---
|
||||
|
||||
TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days
|
||||
|
||||
@property
|
||||
def trial_expiry_timestamp(self):
|
||||
if self.trial_started_timestamp is None:
|
||||
return None
|
||||
return self.trial_started_timestamp + self.TRIAL_DURATION_MS
|
||||
|
||||
@property
|
||||
def is_trial_active(self):
|
||||
if self.plan_active:
|
||||
return False
|
||||
if self.trial_started_timestamp is None:
|
||||
return False
|
||||
import time
|
||||
now = int(time.time() * 1000)
|
||||
return now < self.trial_expiry_timestamp
|
||||
|
||||
@property
|
||||
def is_trial_expired(self):
|
||||
if self.plan_active or self.trial_started_timestamp is None:
|
||||
return False
|
||||
import time
|
||||
now = int(time.time() * 1000)
|
||||
return now >= self.trial_expiry_timestamp
|
||||
|
||||
@property
|
||||
def trial_days_remaining(self):
|
||||
if not self.is_trial_active:
|
||||
return 0
|
||||
import time
|
||||
remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
|
||||
return max(0, remaining_ms // (24 * 60 * 60 * 1000))
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Shop can operate: either paid plan, active trial, or pre-trial (existing)."""
|
||||
if self.plan_active:
|
||||
return True
|
||||
if self.trial_started_timestamp is None:
|
||||
return True # grandfathered pre-trial shop
|
||||
return self.is_trial_active
|
||||
|
||||
# --- BYOB properties (MPS-16) ---
|
||||
|
||||
@property
|
||||
def has_primary_s3(self):
|
||||
return bool(
|
||||
self.primary_s3_enabled
|
||||
and self.primary_s3_endpoint
|
||||
and self.primary_s3_bucket
|
||||
and self.primary_s3_access_key
|
||||
and self.primary_s3_secret_key
|
||||
and self.primary_s3_cdn_endpoint
|
||||
)
|
||||
|
||||
@property
|
||||
def media_cdn_endpoint(self):
|
||||
if self.has_primary_s3:
|
||||
return self.primary_s3_cdn_endpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_s3_mirror(self):
|
||||
"""Return True if shop has S3 mirror bucket configured and enabled."""
|
||||
|
|
|
|||
|
|
@ -155,6 +155,35 @@ def includeme(config):
|
|||
aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"],
|
||||
)
|
||||
|
||||
def add_shop_uploads_client(request):
|
||||
"""Return S3 client for the current shop (BYOB or MPS default)."""
|
||||
shop = request.shop
|
||||
if shop and shop.has_primary_s3:
|
||||
import boto3
|
||||
session = boto3.session.Session()
|
||||
return 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 request.secure_uploads_client
|
||||
|
||||
def add_shop_bucket_name(request):
|
||||
"""Return the bucket name for the current shop."""
|
||||
shop = request.shop
|
||||
if shop and shop.has_primary_s3:
|
||||
return shop.primary_s3_bucket
|
||||
return request.app["bucket.secure_uploads"]
|
||||
|
||||
def add_shop_cdn_endpoint(request):
|
||||
"""Return the CDN endpoint for the current shop's media."""
|
||||
shop = request.shop
|
||||
if shop and shop.has_primary_s3:
|
||||
return shop.primary_s3_cdn_endpoint
|
||||
return request.app["bucket.secure_uploads.get_endpoint"]
|
||||
|
||||
def add_is_shop_domain(request):
|
||||
"""
|
||||
Returns True or False.
|
||||
|
|
@ -373,6 +402,11 @@ def includeme(config):
|
|||
add_secure_uploads_client, "secure_uploads_client", reify=True
|
||||
)
|
||||
|
||||
# BYOB: shop-aware S3 client and bucket
|
||||
config.add_request_method(add_shop_uploads_client, "shop_uploads_client", reify=True)
|
||||
config.add_request_method(add_shop_bucket_name, "shop_bucket_name", reify=True)
|
||||
config.add_request_method(add_shop_cdn_endpoint, "shop_cdn_endpoint", reify=True)
|
||||
|
||||
# Payment method checks
|
||||
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
|
||||
config.add_request_method(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
"""add environment trial and primary s3 columns to shop
|
||||
|
||||
Revision ID: 9884324a48e3
|
||||
Revises: f8201a9ba045
|
||||
Create Date: 2026-03-07 17:49:35.847633
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9884324a48e3'
|
||||
down_revision = 'f8201a9ba045'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
# MPS-14: Shop environment
|
||||
if not _column_exists("mps_shop", "environment"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("environment", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
# MPS-15: Trial and billing
|
||||
if not _column_exists("mps_shop", "trial_started_timestamp"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("trial_started_timestamp", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "trial_ended"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("trial_ended", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "plan_active"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("plan_active", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
# MPS-16: Primary S3 bucket (BYOB)
|
||||
if not _column_exists("mps_shop", "primary_s3_endpoint"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_endpoint", sa.Unicode(256), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_region"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_region", sa.Unicode(64), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_bucket"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_bucket", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_access_key"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_access_key", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_secret_key"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_secret_key", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_cdn_endpoint", sa.Unicode(256), nullable=True),
|
||||
)
|
||||
|
||||
if not _column_exists("mps_shop", "primary_s3_enabled"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("primary_s3_enabled", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "primary_s3_enabled")
|
||||
op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
|
||||
op.drop_column("mps_shop", "primary_s3_secret_key")
|
||||
op.drop_column("mps_shop", "primary_s3_access_key")
|
||||
op.drop_column("mps_shop", "primary_s3_bucket")
|
||||
op.drop_column("mps_shop", "primary_s3_region")
|
||||
op.drop_column("mps_shop", "primary_s3_endpoint")
|
||||
op.drop_column("mps_shop", "plan_active")
|
||||
op.drop_column("mps_shop", "trial_ended")
|
||||
op.drop_column("mps_shop", "trial_started_timestamp")
|
||||
op.drop_column("mps_shop", "environment")
|
||||
|
|
@ -497,6 +497,40 @@ section.main {
|
|||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.environment-banner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-warning, #e6a700);
|
||||
}
|
||||
|
||||
.environment-banner-development {
|
||||
background-color: var(--color-info, #3b82f6);
|
||||
}
|
||||
|
||||
.trial-banner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--color-info, #3b82f6);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.trial-banner a {
|
||||
color: var(--color-white);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.trial-banner-expired {
|
||||
background-color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
/*
|
||||
section.grid-nav {
|
||||
grid-column: 1/-1;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
<meta charset="utf-8">
|
||||
|
||||
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
|
||||
<link rel="icon" href="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
|
||||
<link rel="icon" href="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />
|
||||
{% endif %}
|
||||
|
||||
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
|
||||
|
|
@ -87,6 +87,20 @@
|
|||
<style>.js-only {display: none;}</style>
|
||||
</noscript>
|
||||
{% include 'snippets/ribbon.j2' %}
|
||||
{% if request.shop and request.shop.is_non_production %}
|
||||
<div class="environment-banner environment-banner-{{ request.shop.environment_label }}">
|
||||
{{ request.shop.environment_label|upper }} ENVIRONMENT — This shop is not visible to the public.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.shop and request.shop.is_trial_active %}
|
||||
<div class="trial-banner">
|
||||
Trial: {{ request.shop.trial_days_remaining }} day{{ 's' if request.shop.trial_days_remaining != 1 else '' }} remaining — <a href="/actions/view">Choose a plan</a>
|
||||
</div>
|
||||
{% elif request.shop and request.shop.is_trial_expired %}
|
||||
<div class="trial-banner trial-banner-expired">
|
||||
Trial expired — <a href="/actions/view">Choose a plan</a> to continue editing
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="main">
|
||||
|
||||
<section class="nav-grid">
|
||||
|
|
@ -102,7 +116,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% elif request.shop and request.shop.logo_banner %}
|
||||
<a href="/"><img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
|
||||
<a href="/"><img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner?ts={{ request.shop.updated_timestamp }}" /></a>
|
||||
{% else%}
|
||||
Upload a logo in shop settings.
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
<div class="cart-item">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="/p/{{ product.uuid_str }}" rel="nofollow">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
|
||||
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
||||
{%- if "thumbnail1" in product.extensions %}
|
||||
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{ product.title }}" />
|
||||
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
|
||||
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
{%- endif %}
|
||||
|
||||
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
|
||||
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
|
||||
{# Watch mode: direct video render with autoplay #}
|
||||
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
|
||||
<div class="watch-video-container">
|
||||
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
||||
|
|
@ -60,11 +60,11 @@
|
|||
</noscript>
|
||||
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
|
||||
{# Watch mode: audio with album art #}
|
||||
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
|
||||
<div class="watch-audio-container">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
{% endif %}
|
||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
|
|
@ -84,9 +84,9 @@
|
|||
{% elif "thumbnail1" in product.extensions %}
|
||||
{% if product.extensions.get("product") in video_extensions %}
|
||||
{# Video: play button overlay, click to play inline #}
|
||||
{% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
{% set video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<div class="video-play-overlay"></div>
|
||||
</div>
|
||||
<noscript>
|
||||
|
|
@ -95,8 +95,8 @@
|
|||
<div class="video-click-to-play">click ▶ to play</div>
|
||||
{% else %}
|
||||
{# Non-video: click to open in new window #}
|
||||
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<a target="_blank" href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
<div class="serp-item">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
|
||||
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
||||
{%- if "thumbnail1" in product.extensions %}
|
||||
<meta property="og:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta property="og:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{ product.title }}" />
|
||||
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
|
||||
<meta name="twitter:image" content="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<meta name="twitter:image" content="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
{%- endif %}
|
||||
|
||||
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
{% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %}
|
||||
{# Watch mode: direct video render with autoplay #}
|
||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
||||
{% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% else %}
|
||||
{% set watch_video_url = signed_get_object_url %}
|
||||
{% endif %}
|
||||
|
|
@ -65,14 +65,14 @@
|
|||
{% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %}
|
||||
{# Watch mode: audio with album art #}
|
||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
||||
{% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% else %}
|
||||
{% set watch_audio_url = signed_get_object_url %}
|
||||
{% endif %}
|
||||
<div id="watch-media-container">
|
||||
<div class="watch-audio-container">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
{% endif %}
|
||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
|
|
@ -92,9 +92,9 @@
|
|||
{% elif "thumbnail1" in product.extensions %}
|
||||
{% if signed_get_object_url and product.extensions.get("product") in video_extensions %}
|
||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
||||
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<div class="video-play-overlay"></div>
|
||||
</div>
|
||||
<noscript>
|
||||
|
|
@ -103,13 +103,13 @@
|
|||
<div class="video-click-to-play">click ▶ to play</div>
|
||||
{% else %}
|
||||
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<div class="video-play-overlay"></div>
|
||||
</a>
|
||||
<div class="video-click-to-play">click ▶ to play</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.shop.watch_mode_enabled and product.extensions.get("product") not in video_extensions and product.extensions.get("product") not in audio_extensions %}
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
|
||||
{% for file_key in product.file_thumbnail_keys %}
|
||||
{% if file_key in product.s3_key_thumbnails %}
|
||||
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + product.s3_key_thumbnails[file_key] %}
|
||||
{% set get_endpoint = request.shop_cdn_endpoint + "/" + product.s3_key_thumbnails[file_key] %}
|
||||
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -232,7 +232,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if "preview" in product.extensions %}
|
||||
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
|
||||
<a href="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/preview" target="_blank" class="product-preview-button mps-button">▶ Play Preview</a>
|
||||
<br/>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
|
|||
<p>File Size: {{ product.human_file_bytes("preview") }}</p>
|
||||
|
||||
{% set preview_ext = product.extensions.get("preview", "") %}
|
||||
{% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
|
||||
{% set preview_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/preview?ts=" ~ product.updated_timestamp %}
|
||||
{% if preview_ext in video_extensions %}
|
||||
<video src="{{ preview_url }}" controls playsinline class="edit-media-preview"></video>
|
||||
{% elif preview_ext in audio_extensions %}
|
||||
|
|
@ -177,7 +177,7 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
|
|||
<div class="upload-thumbnail-item">
|
||||
|
||||
{% if thumbnail_key in s3_key_thumbnails %}
|
||||
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
|
||||
{% set get_endpoint = request.shop_cdn_endpoint ~ "/" ~ s3_key_thumbnails[thumbnail_key] %}
|
||||
{% set thumb_ext = product.extensions.get(thumbnail_key, "") %}
|
||||
{% if thumb_ext in video_extensions %}
|
||||
<video src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" controls playsinline class="edit-media-preview"></video>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<div class="serp-item">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
<section class="one-column well">
|
||||
|
||||
<img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
|
||||
<img class="logo" src="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
|
|
|||
|
|
@ -59,7 +59,20 @@
|
|||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
|
||||
<fieldset>
|
||||
<legend>Environment</legend>
|
||||
<input type="radio" name="environment" id="env_production" value="0" checked />
|
||||
<label for="env_production" class="inline-label">Production</label>
|
||||
<input type="radio" name="environment" id="env_staging" value="1" />
|
||||
<label for="env_staging" class="inline-label">Staging</label>
|
||||
<input type="radio" name="environment" id="env_development" value="2" />
|
||||
<label for="env_development" class="inline-label">Development</label>
|
||||
</fieldset>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Create Shop" />
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,120 @@ Existing sales honored for download buy purchasers.
|
|||
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Environment Settings</h3>
|
||||
|
||||
<p>Non-production shops are hidden from search, feeds, and discovery. Use them to stage or test before going live.</p>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="environment-settings" />
|
||||
|
||||
<fieldset>
|
||||
<legend>Environment</legend>
|
||||
|
||||
<input type="radio" name="environment" id="env_production" value="0" {% if environment == 0 %}checked{% endif %} />
|
||||
<label for="env_production" class="inline-label">Production</label>
|
||||
|
||||
<input type="radio" name="environment" id="env_staging" value="1" {% if environment == 1 %}checked{% endif %} />
|
||||
<label for="env_staging" class="inline-label">Staging</label>
|
||||
|
||||
<input type="radio" name="environment" id="env_development" value="2" {% if environment == 2 %}checked{% endif %} />
|
||||
<label for="env_development" class="inline-label">Development</label>
|
||||
</fieldset>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Storage Bucket (BYOB)</h3>
|
||||
|
||||
<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>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="bucket-settings" />
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="primary_s3_enabled_checkbox"
|
||||
{% if primary_s3_enabled %}checked{% endif %} />
|
||||
Enable Custom Bucket
|
||||
</label>
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_endpoint">S3 Endpoint URL</label>
|
||||
<input type="url" name="primary_s3_endpoint" id="primary_s3_endpoint"
|
||||
value="{{ primary_s3_endpoint or '' }}"
|
||||
placeholder="https://nyc3.digitaloceanspaces.com"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_region">Region</label>
|
||||
<input type="text" name="primary_s3_region" id="primary_s3_region"
|
||||
value="{{ primary_s3_region or '' }}"
|
||||
placeholder="nyc3"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_bucket">Bucket Name</label>
|
||||
<input type="text" name="primary_s3_bucket" id="primary_s3_bucket"
|
||||
value="{{ primary_s3_bucket or '' }}"
|
||||
placeholder="my-shop-media"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_access_key">Access Key</label>
|
||||
<input type="text" name="primary_s3_access_key" id="primary_s3_access_key"
|
||||
value="{{ primary_s3_access_key or '' }}"
|
||||
placeholder="Access Key"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_secret_key">Secret Key</label>
|
||||
<input type="password" name="primary_s3_secret_key" id="primary_s3_secret_key"
|
||||
value="{{ primary_s3_secret_key or '' }}"
|
||||
placeholder="Secret Key"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<label for="primary_s3_cdn_endpoint">CDN Endpoint URL</label>
|
||||
<input type="url" name="primary_s3_cdn_endpoint" id="primary_s3_cdn_endpoint"
|
||||
value="{{ primary_s3_cdn_endpoint or '' }}"
|
||||
placeholder="https://my-shop-media.nyc3.cdn.digitaloceanspaces.com"
|
||||
class="mps-text-input" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<span class="related-content-index">▶</span>
|
||||
<span class="related-content-item related-content-now-playing">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" />
|
||||
{% else %}
|
||||
<span class="related-content-no-thumb"></span>
|
||||
{% endif %}
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
data-product-id="{{ product.id }}"
|
||||
data-title="{{ product.title }}"
|
||||
data-url="{{ product.absolute_url(request) }}"
|
||||
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
|
||||
data-thumbnail="{% if 'thumbnail1' in product.extensions %}{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}{% endif %}"
|
||||
title="Add to queue">+</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
<span class="related-content-index">{{ offset }}</span>
|
||||
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
|
||||
{% if "thumbnail1" in related.extensions %}
|
||||
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
|
||||
<img {% if offset > 7 %}loading="lazy" {% endif %}src="{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
|
||||
{% else %}
|
||||
<span class="related-content-no-thumb"></span>
|
||||
{% endif %}
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
data-product-id="{{ related.id }}"
|
||||
data-title="{{ related.title }}"
|
||||
data-url="{{ related.absolute_url(request) }}"
|
||||
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
|
||||
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.shop_cdn_endpoint }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}"
|
||||
title="Add to queue">+</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<div class="serp-item">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
||||
|
|
|
|||
|
|
@ -4554,3 +4554,190 @@ class TestGiftCardFunctional(_AuthenticatedBase):
|
|||
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)
|
||||
self.assertIn("Storage bucket settings updated", 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)
|
||||
|
|
|
|||
|
|
@ -3493,3 +3493,133 @@ class TestGiftCard(unittest.TestCase):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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="<?xml version='1.0' encoding='UTF-8'?><urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'></urlset>")
|
||||
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="<?xml version='1.0' encoding='UTF-8'?><rss version='2.0'><channel></channel></rss>")
|
||||
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="<?xml version='1.0' encoding='UTF-8'?><feed xmlns='http://www.w3.org/2005/Atom'></feed>")
|
||||
response.content_type = "application/xml"
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,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 +75,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.
|
||||
|
|
@ -284,8 +284,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 +297,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 +325,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 +342,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 +351,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 +385,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 +401,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 +435,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 +482,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1053,6 +1074,69 @@ def shop_settings(request):
|
|||
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
|
||||
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:
|
||||
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":
|
||||
|
|
@ -1067,7 +1151,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,
|
||||
)
|
||||
|
|
@ -1096,9 +1180,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,
|
||||
|
|
@ -1113,8 +1197,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,
|
||||
|
|
@ -1122,7 +1206,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,
|
||||
)
|
||||
|
|
@ -1153,15 +1237,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 /<shop-uuid>/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,
|
||||
)
|
||||
|
|
@ -1248,6 +1332,15 @@ def shop_settings(request):
|
|||
"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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue