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 = ""
|
thumbnail = ""
|
||||||
if "thumbnail1" in p.extensions:
|
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(
|
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.s3_path,
|
||||||
p.updated_timestamp,
|
p.updated_timestamp,
|
||||||
)
|
)
|
||||||
|
|
@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost):
|
||||||
thumbnail = ""
|
thumbnail = ""
|
||||||
if "thumbnail1" in p.extensions:
|
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(
|
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.s3_path,
|
||||||
p.updated_timestamp,
|
p.updated_timestamp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,23 @@ class Shop(RBase, Base):
|
||||||
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
|
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
|
||||||
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
|
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
|
# Precomputed discovery ring: circular ordering of all public products
|
||||||
json_discovery_ring = Column(UnicodeText, nullable=True)
|
json_discovery_ring = Column(UnicodeText, nullable=True)
|
||||||
|
|
||||||
|
|
@ -248,6 +265,94 @@ class Shop(RBase, Base):
|
||||||
us.role_id = role_id
|
us.role_id = role_id
|
||||||
return us
|
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
|
@property
|
||||||
def has_s3_mirror(self):
|
def has_s3_mirror(self):
|
||||||
"""Return True if shop has S3 mirror bucket configured and enabled."""
|
"""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"],
|
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):
|
def add_is_shop_domain(request):
|
||||||
"""
|
"""
|
||||||
Returns True or False.
|
Returns True or False.
|
||||||
|
|
@ -373,6 +402,11 @@ def includeme(config):
|
||||||
add_secure_uploads_client, "secure_uploads_client", reify=True
|
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
|
# Payment method checks
|
||||||
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
|
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
|
||||||
config.add_request_method(
|
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;
|
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 {
|
section.grid-nav {
|
||||||
grid-column: 1/-1;
|
grid-column: 1/-1;
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
||||||
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
|
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
|
||||||
|
|
@ -87,6 +87,20 @@
|
||||||
<style>.js-only {display: none;}</style>
|
<style>.js-only {display: none;}</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
{% include 'snippets/ribbon.j2' %}
|
{% 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="main">
|
||||||
|
|
||||||
<section class="nav-grid">
|
<section class="nav-grid">
|
||||||
|
|
@ -102,7 +116,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% elif request.shop and request.shop.logo_banner %}
|
{% 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%}
|
{% else%}
|
||||||
Upload a logo in shop settings.
|
Upload a logo in shop settings.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% if "thumbnail1" in product.extensions %}
|
||||||
<a href="/p/{{ product.uuid_str }}" rel="nofollow">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@
|
||||||
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
|
<meta property="og:url" content="{{ product.absolute_url(request) }}" />
|
||||||
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
||||||
{%- if "thumbnail1" in product.extensions %}
|
{%- 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:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="{{ product.title }}" />
|
<meta name="twitter:title" content="{{ product.title }}" />
|
||||||
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
|
<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 %}
|
{%- endif %}
|
||||||
|
|
||||||
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
|
{# 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"] %}
|
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
|
||||||
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
|
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
|
||||||
{# Watch mode: direct video render with autoplay #}
|
{# 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 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">
|
<div class="watch-video-container">
|
||||||
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
|
||||||
|
|
@ -60,11 +60,11 @@
|
||||||
</noscript>
|
</noscript>
|
||||||
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
|
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
|
||||||
{# Watch mode: audio with album art #}
|
{# 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 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">
|
<div class="watch-audio-container">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% 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 %}
|
{% endif %}
|
||||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||||
|
|
@ -84,9 +84,9 @@
|
||||||
{% elif "thumbnail1" in product.extensions %}
|
{% elif "thumbnail1" in product.extensions %}
|
||||||
{% if product.extensions.get("product") in video_extensions %}
|
{% if product.extensions.get("product") in video_extensions %}
|
||||||
{# Video: play button overlay, click to play inline #}
|
{# 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 }}')">
|
<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 class="video-play-overlay"></div>
|
||||||
</div>
|
</div>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|
@ -95,8 +95,8 @@
|
||||||
<div class="video-click-to-play">click ▶ to play</div>
|
<div class="video-click-to-play">click ▶ to play</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Non-video: click to open in new window #}
|
{# 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 }}">
|
<a target="_blank" href="{{ request.shop_cdn_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" />
|
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
<div class="serp-item">
|
<div class="serp-item">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% if "thumbnail1" in product.extensions %}
|
||||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
<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:url" content="{{ product.absolute_url(request) }}" />
|
||||||
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
<meta property="og:site_name" content="{{ request.shop.name }}" />
|
||||||
{%- if "thumbnail1" in product.extensions %}
|
{%- 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:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="{{ product.title }}" />
|
<meta name="twitter:title" content="{{ product.title }}" />
|
||||||
<meta name="twitter:description" content="{{ product.description|truncate(500) }}" />
|
<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 %}
|
{%- endif %}
|
||||||
|
|
||||||
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
|
{# 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 %}
|
{% 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 #}
|
{# Watch mode: direct video render with autoplay #}
|
||||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
{% 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 %}
|
{% else %}
|
||||||
{% set watch_video_url = signed_get_object_url %}
|
{% set watch_video_url = signed_get_object_url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -65,14 +65,14 @@
|
||||||
{% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %}
|
{% 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 #}
|
{# Watch mode: audio with album art #}
|
||||||
{% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %}
|
{% 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 %}
|
{% else %}
|
||||||
{% set watch_audio_url = signed_get_object_url %}
|
{% set watch_audio_url = signed_get_object_url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="watch-media-container">
|
<div id="watch-media-container">
|
||||||
<div class="watch-audio-container">
|
<div class="watch-audio-container">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% 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 %}
|
{% endif %}
|
||||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||||
|
|
@ -92,9 +92,9 @@
|
||||||
{% elif "thumbnail1" in product.extensions %}
|
{% elif "thumbnail1" in product.extensions %}
|
||||||
{% if signed_get_object_url and product.extensions.get("product") in video_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 %}
|
{% 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 }}')">
|
<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 class="video-play-overlay"></div>
|
||||||
</div>
|
</div>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|
@ -103,13 +103,13 @@
|
||||||
<div class="video-click-to-play">click ▶ to play</div>
|
<div class="video-click-to-play">click ▶ to play</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ signed_get_object_url }}" target="_blank" class="video-thumbnail-container">
|
<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>
|
<div class="video-play-overlay"></div>
|
||||||
</a>
|
</a>
|
||||||
<div class="video-click-to-play">click ▶ to play</div>
|
<div class="video-click-to-play">click ▶ to play</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
||||||
{% 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 %}
|
{% 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 %}
|
{% for file_key in product.file_thumbnail_keys %}
|
||||||
{% if file_key in product.s3_key_thumbnails %}
|
{% 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>
|
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -232,7 +232,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if "preview" in product.extensions %}
|
{% 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/>
|
<br/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
|
||||||
<p>File Size: {{ product.human_file_bytes("preview") }}</p>
|
<p>File Size: {{ product.human_file_bytes("preview") }}</p>
|
||||||
|
|
||||||
{% set preview_ext = product.extensions.get("preview", "") %}
|
{% 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 %}
|
{% if preview_ext in video_extensions %}
|
||||||
<video src="{{ preview_url }}" controls playsinline class="edit-media-preview"></video>
|
<video src="{{ preview_url }}" controls playsinline class="edit-media-preview"></video>
|
||||||
{% elif preview_ext in audio_extensions %}
|
{% 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">
|
<div class="upload-thumbnail-item">
|
||||||
|
|
||||||
{% if thumbnail_key in s3_key_thumbnails %}
|
{% 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, "") %}
|
{% set thumb_ext = product.extensions.get(thumbnail_key, "") %}
|
||||||
{% if thumb_ext in video_extensions %}
|
{% if thumb_ext in video_extensions %}
|
||||||
<video src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" controls playsinline class="edit-media-preview"></video>
|
<video src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" controls playsinline class="edit-media-preview"></video>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="serp-item">
|
<div class="serp-item">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% if "thumbnail1" in product.extensions %}
|
||||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
<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">
|
<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/>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,20 @@
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<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" />
|
<input type="submit" name="submit" class="mps-submit" value="Create Shop" />
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1165,6 +1165,120 @@ Existing sales honored for download buy purchasers.
|
||||||
|
|
||||||
</section>
|
</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
<span class="related-content-index">▶</span>
|
<span class="related-content-index">▶</span>
|
||||||
<span class="related-content-item related-content-now-playing">
|
<span class="related-content-item related-content-now-playing">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="related-content-no-thumb"></span>
|
<span class="related-content-no-thumb"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
data-product-id="{{ product.id }}"
|
data-product-id="{{ product.id }}"
|
||||||
data-title="{{ product.title }}"
|
data-title="{{ product.title }}"
|
||||||
data-url="{{ product.absolute_url(request) }}"
|
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>
|
title="Add to queue">+</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
<span class="related-content-index">{{ offset }}</span>
|
<span class="related-content-index">{{ offset }}</span>
|
||||||
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
|
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
|
||||||
{% if "thumbnail1" in related.extensions %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="related-content-no-thumb"></span>
|
<span class="related-content-no-thumb"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
data-product-id="{{ related.id }}"
|
data-product-id="{{ related.id }}"
|
||||||
data-title="{{ related.title }}"
|
data-title="{{ related.title }}"
|
||||||
data-url="{{ related.absolute_url(request) }}"
|
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>
|
title="Add to queue">+</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="serp-item">
|
<div class="serp-item">
|
||||||
{% if "thumbnail1" in product.extensions %}
|
{% if "thumbnail1" in product.extensions %}
|
||||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
<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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
<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()
|
res = res.follow()
|
||||||
flash = self._get_flash_messages(res)
|
flash = self._get_flash_messages(res)
|
||||||
self.assertIn("Maximum must be greater than or equal to minimum", flash)
|
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_email, "friend@test.com")
|
||||||
self.assertEqual(gc.gift_message, "Happy birthday!")
|
self.assertEqual(gc.gift_message, "Happy birthday!")
|
||||||
self.assertEqual(gc.purchaser_email, "buyer@test.com")
|
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
|
signed_get_object_url = None
|
||||||
|
|
||||||
bucket_name = request.app["bucket.secure_uploads"]
|
bucket_name = request.shop_bucket_name
|
||||||
|
|
||||||
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
|
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
|
||||||
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
|
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
|
||||||
|
|
@ -55,7 +55,7 @@ def content(request):
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
# 15 minutes.
|
# 15 minutes.
|
||||||
|
|
@ -88,7 +88,7 @@ def content(request):
|
||||||
extension = product.extensions.get("product")
|
extension = product.extensions.get("product")
|
||||||
media_type = get_media_type(extension) if extension else None
|
media_type = get_media_type(extension) if extension else None
|
||||||
if media_type in ("video", "audio"):
|
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"):
|
for track_name in ("instrumentals", "vocals"):
|
||||||
if track_name in product.extensions:
|
if track_name in product.extensions:
|
||||||
url = f"{cdn_base}/{product.s3_path}/{track_name}"
|
url = f"{cdn_base}/{product.s3_path}/{track_name}"
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ def sitemap_view(request):
|
||||||
"""Generate XML sitemap for the shop."""
|
"""Generate XML sitemap for the shop."""
|
||||||
shop = request.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 = 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"
|
response.content_type = "application/xml"
|
||||||
return response
|
return response
|
||||||
|
|
@ -226,7 +226,7 @@ def rss_view(request):
|
||||||
"""Generate RSS 2.0 feed for the shop."""
|
"""Generate RSS 2.0 feed for the shop."""
|
||||||
shop = request.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 = Response(body="<?xml version='1.0' encoding='UTF-8'?><rss version='2.0'><channel></channel></rss>")
|
||||||
response.content_type = "application/xml"
|
response.content_type = "application/xml"
|
||||||
return response
|
return response
|
||||||
|
|
@ -245,7 +245,7 @@ def atom_view(request):
|
||||||
"""Generate Atom feed for the shop."""
|
"""Generate Atom feed for the shop."""
|
||||||
shop = request.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 = Response(body="<?xml version='1.0' encoding='UTF-8'?><feed xmlns='http://www.w3.org/2005/Atom'></feed>")
|
||||||
response.content_type = "application/xml"
|
response.content_type = "application/xml"
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ def player(request):
|
||||||
return HTTPBadRequest("Not a supported media file")
|
return HTTPBadRequest("Not a supported media file")
|
||||||
|
|
||||||
# Generate presigned URL
|
# Generate presigned URL
|
||||||
bucket_name = request.app["bucket.secure_uploads"]
|
bucket_name = request.shop_bucket_name
|
||||||
s3_key = f"{product.s3_path}/{file_key}"
|
s3_key = f"{product.s3_path}/{file_key}"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -62,7 +62,7 @@ def player(request):
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
ExpiresIn=900, # 15 minutes
|
ExpiresIn=900, # 15 minutes
|
||||||
|
|
@ -127,7 +127,7 @@ def player(request):
|
||||||
track_ct = product.get_content_type(track_name) or (
|
track_ct = product.get_content_type(track_name) or (
|
||||||
"video/mp4" if media_type == "video" else "audio/wav"
|
"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",
|
ClientMethod="get_object",
|
||||||
Params={
|
Params={
|
||||||
"Bucket": bucket_name,
|
"Bucket": bucket_name,
|
||||||
|
|
@ -192,7 +192,7 @@ def player_json(request):
|
||||||
return {"error": "Not a supported media file"}
|
return {"error": "Not a supported media file"}
|
||||||
|
|
||||||
# Generate presigned URL
|
# Generate presigned URL
|
||||||
bucket_name = request.app["bucket.secure_uploads"]
|
bucket_name = request.shop_bucket_name
|
||||||
s3_key = f"{product.s3_path}/{file_key}"
|
s3_key = f"{product.s3_path}/{file_key}"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -209,7 +209,7 @@ def player_json(request):
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
|
|
@ -259,7 +259,7 @@ def player_json(request):
|
||||||
track_ct = product.get_content_type(track_name) or (
|
track_ct = product.get_content_type(track_name) or (
|
||||||
"video/mp4" if media_type == "video" else "audio/wav"
|
"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",
|
ClientMethod="get_object",
|
||||||
Params={
|
Params={
|
||||||
"Bucket": bucket_name,
|
"Bucket": bucket_name,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ def product(request):
|
||||||
|
|
||||||
signed_get_object_url = None
|
signed_get_object_url = None
|
||||||
|
|
||||||
bucket_name = request.app["bucket.secure_uploads"]
|
bucket_name = request.shop_bucket_name
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.user
|
request.user
|
||||||
|
|
@ -75,7 +75,7 @@ def product(request):
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
# 15 minutes.
|
# 15 minutes.
|
||||||
|
|
@ -284,8 +284,8 @@ def product_edit(request):
|
||||||
product_modified = True
|
product_modified = True
|
||||||
product.set_visibility(
|
product.set_visibility(
|
||||||
visibility,
|
visibility,
|
||||||
request.secure_uploads_client,
|
request.shop_uploads_client,
|
||||||
request.app["bucket.secure_uploads"],
|
request.shop_bucket_name,
|
||||||
)
|
)
|
||||||
request.session.flash(("You updated the product's visibility.", "success"))
|
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:
|
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
|
||||||
# Check if the file exists and has a non-zero size
|
# Check if the file exists and has a non-zero size
|
||||||
try:
|
try:
|
||||||
response = request.secure_uploads_client.head_object(
|
response = request.shop_uploads_client.head_object(
|
||||||
Bucket=s3_webhook_bucket,
|
Bucket=s3_webhook_bucket,
|
||||||
Key=s3_webhook_key,
|
Key=s3_webhook_key,
|
||||||
)
|
)
|
||||||
|
|
@ -325,9 +325,9 @@ def product_edit(request):
|
||||||
|
|
||||||
# copy upload to our system defined s3 location.
|
# copy upload to our system defined s3 location.
|
||||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
|
# 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,
|
ACL=acl,
|
||||||
Bucket=request.app["bucket.secure_uploads"],
|
Bucket=request.shop_bucket_name,
|
||||||
CopySource={
|
CopySource={
|
||||||
"Bucket": s3_webhook_bucket,
|
"Bucket": s3_webhook_bucket,
|
||||||
"Key": s3_webhook_key,
|
"Key": s3_webhook_key,
|
||||||
|
|
@ -342,8 +342,8 @@ def product_edit(request):
|
||||||
# Mirror to shop's custom S3 bucket if configured
|
# Mirror to shop's custom S3 bucket if configured
|
||||||
from ..lib.s3_mirror import mirror_key_async
|
from ..lib.s3_mirror import mirror_key_async
|
||||||
mirror_key_async(
|
mirror_key_async(
|
||||||
request.secure_uploads_client,
|
request.shop_uploads_client,
|
||||||
request.app["bucket.secure_uploads"],
|
request.shop_bucket_name,
|
||||||
f"{product.s3_path}/{file_key}",
|
f"{product.s3_path}/{file_key}",
|
||||||
product.shop,
|
product.shop,
|
||||||
content_type=product.get_content_type(file_key),
|
content_type=product.get_content_type(file_key),
|
||||||
|
|
@ -351,14 +351,14 @@ def product_edit(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
# delete original upload key.
|
# delete original upload key.
|
||||||
request.secure_uploads_client.delete_object(
|
request.shop_uploads_client.delete_object(
|
||||||
Bucket=s3_webhook_bucket,
|
Bucket=s3_webhook_bucket,
|
||||||
Key=s3_webhook_key,
|
Key=s3_webhook_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# get file size & store in our database.
|
# get file size & store in our database.
|
||||||
response = request.secure_uploads_client.head_object(
|
response = request.shop_uploads_client.head_object(
|
||||||
Bucket=request.app["bucket.secure_uploads"],
|
Bucket=request.shop_bucket_name,
|
||||||
Key=f"{product.s3_path}/{file_key}",
|
Key=f"{product.s3_path}/{file_key}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -385,8 +385,8 @@ def product_edit(request):
|
||||||
is_video = (upload_media_type == "video")
|
is_video = (upload_media_type == "video")
|
||||||
ext = product.extensions.get(file_key)
|
ext = product.extensions.get(file_key)
|
||||||
sizes = process_karaoke(
|
sizes = process_karaoke(
|
||||||
request.secure_uploads_client,
|
request.shop_uploads_client,
|
||||||
request.app["bucket.secure_uploads"],
|
request.shop_bucket_name,
|
||||||
f"{product.s3_path}/{file_key}",
|
f"{product.s3_path}/{file_key}",
|
||||||
product.s3_path, is_video, ext,
|
product.s3_path, is_video, ext,
|
||||||
public_key=shop.unsandbox_public_key,
|
public_key=shop.unsandbox_public_key,
|
||||||
|
|
@ -401,14 +401,14 @@ def product_edit(request):
|
||||||
product.file_bytes = tmp
|
product.file_bytes = tmp
|
||||||
request.dbsession.add(product)
|
request.dbsession.add(product)
|
||||||
request.dbsession.flush()
|
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
|
# Mirror karaoke tracks to shop's custom bucket
|
||||||
if shop.has_s3_mirror:
|
if shop.has_s3_mirror:
|
||||||
from ..lib.s3_mirror import mirror_keys_async
|
from ..lib.s3_mirror import mirror_keys_async
|
||||||
mirror_keys_async(
|
mirror_keys_async(
|
||||||
request.secure_uploads_client,
|
request.shop_uploads_client,
|
||||||
request.app["bucket.secure_uploads"],
|
request.shop_bucket_name,
|
||||||
[f"{product.s3_path}/instrumentals", f"{product.s3_path}/vocals"],
|
[f"{product.s3_path}/instrumentals", f"{product.s3_path}/vocals"],
|
||||||
shop,
|
shop,
|
||||||
)
|
)
|
||||||
|
|
@ -435,8 +435,8 @@ def product_edit(request):
|
||||||
["starts-with", "$key", key_starts_with],
|
["starts-with", "$key", key_starts_with],
|
||||||
]
|
]
|
||||||
|
|
||||||
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
|
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
|
||||||
Bucket=request.app["bucket.secure_uploads"],
|
Bucket=request.shop_bucket_name,
|
||||||
Key=key_starts_with + "${filename}",
|
Key=key_starts_with + "${filename}",
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
Conditions=conditions,
|
Conditions=conditions,
|
||||||
|
|
@ -482,14 +482,14 @@ def product_edit(request):
|
||||||
if product.has_product_file:
|
if product.has_product_file:
|
||||||
try:
|
try:
|
||||||
params = {
|
params = {
|
||||||
"Bucket": request.app["bucket.secure_uploads"],
|
"Bucket": request.shop_bucket_name,
|
||||||
"Key": product.s3_key,
|
"Key": product.s3_key,
|
||||||
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
|
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
|
||||||
}
|
}
|
||||||
content_type = product.get_content_type("product")
|
content_type = product.get_content_type("product")
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,28 @@ def shop_new(request):
|
||||||
request.session.flash(msg)
|
request.session.flash(msg)
|
||||||
|
|
||||||
else:
|
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 = 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)
|
shop.add_user_to_shop(request.user)
|
||||||
request.user.set_active_shop(shop)
|
request.user.set_active_shop(shop)
|
||||||
request.dbsession.add(shop)
|
request.dbsession.add(shop)
|
||||||
|
|
@ -1053,6 +1074,69 @@ def shop_settings(request):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
request.session.flash(("Invalid gift card amount.", "error"))
|
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 we processed any form submission, respond accordingly
|
||||||
if form_section:
|
if form_section:
|
||||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
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:
|
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
|
||||||
# Check if the file exists and has a non-zero size
|
# Check if the file exists and has a non-zero size
|
||||||
try:
|
try:
|
||||||
response = request.secure_uploads_client.head_object(
|
response = request.shop_uploads_client.head_object(
|
||||||
Bucket=s3_webhook_bucket,
|
Bucket=s3_webhook_bucket,
|
||||||
Key=s3_webhook_key,
|
Key=s3_webhook_key,
|
||||||
)
|
)
|
||||||
|
|
@ -1096,9 +1180,9 @@ def shop_settings(request):
|
||||||
|
|
||||||
# copy upload to our system defined s3 location.
|
# copy upload to our system defined s3 location.
|
||||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
|
# 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,
|
ACL=acl,
|
||||||
Bucket=request.app["bucket.secure_uploads"],
|
Bucket=request.shop_bucket_name,
|
||||||
CopySource={
|
CopySource={
|
||||||
"Bucket": s3_webhook_bucket,
|
"Bucket": s3_webhook_bucket,
|
||||||
"Key": s3_webhook_key,
|
"Key": s3_webhook_key,
|
||||||
|
|
@ -1113,8 +1197,8 @@ def shop_settings(request):
|
||||||
# Mirror shop asset to custom S3 bucket if configured
|
# Mirror shop asset to custom S3 bucket if configured
|
||||||
from ..lib.s3_mirror import mirror_key_async
|
from ..lib.s3_mirror import mirror_key_async
|
||||||
mirror_key_async(
|
mirror_key_async(
|
||||||
request.secure_uploads_client,
|
request.shop_uploads_client,
|
||||||
request.app["bucket.secure_uploads"],
|
request.shop_bucket_name,
|
||||||
f"{shop.id}/meta/{file_key}",
|
f"{shop.id}/meta/{file_key}",
|
||||||
shop,
|
shop,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
|
|
@ -1122,7 +1206,7 @@ def shop_settings(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
# delete original upload key.
|
# delete original upload key.
|
||||||
request.secure_uploads_client.delete_object(
|
request.shop_uploads_client.delete_object(
|
||||||
Bucket=s3_webhook_bucket,
|
Bucket=s3_webhook_bucket,
|
||||||
Key=s3_webhook_key,
|
Key=s3_webhook_key,
|
||||||
)
|
)
|
||||||
|
|
@ -1153,15 +1237,15 @@ def shop_settings(request):
|
||||||
["starts-with", "$key", key_starts_with],
|
["starts-with", "$key", key_starts_with],
|
||||||
]
|
]
|
||||||
|
|
||||||
signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post(
|
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
|
||||||
Bucket=request.app["bucket.secure_uploads"],
|
Bucket=request.shop_bucket_name,
|
||||||
# uploads to /<shop-uuid>/meta/shop-logo-banner.the-users-file.png
|
# uploads to /<shop-uuid>/meta/shop-logo-banner.the-users-file.png
|
||||||
Key=key_starts_with + "${filename}",
|
Key=key_starts_with + "${filename}",
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
Conditions=conditions,
|
Conditions=conditions,
|
||||||
)
|
)
|
||||||
get_endpoints[file_key] = "{}/{}/meta/{}".format(
|
get_endpoints[file_key] = "{}/{}/meta/{}".format(
|
||||||
request.app["bucket.secure_uploads.get_endpoint"],
|
request.shop_cdn_endpoint,
|
||||||
shop.id,
|
shop.id,
|
||||||
file_key,
|
file_key,
|
||||||
)
|
)
|
||||||
|
|
@ -1248,6 +1332,15 @@ def shop_settings(request):
|
||||||
"gift_card_enabled": shop.gift_card_enabled,
|
"gift_card_enabled": shop.gift_card_enabled,
|
||||||
"gift_card_min_dollars": cents_to_dollars(shop.gift_card_min_in_cents),
|
"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),
|
"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,
|
"signed_posts": signed_posts,
|
||||||
"get_endpoints": get_endpoints,
|
"get_endpoints": get_endpoints,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ def watch_json(request):
|
||||||
media_type = get_media_type(extension) or "other"
|
media_type = get_media_type(extension) or "other"
|
||||||
|
|
||||||
# Generate presigned URL for media
|
# 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}"
|
s3_key = f"{product.s3_path}/{file_key}"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -59,7 +59,7 @@ def watch_json(request):
|
||||||
if content_type:
|
if content_type:
|
||||||
params["ResponseContentType"] = 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",
|
ClientMethod="get_object",
|
||||||
Params=params,
|
Params=params,
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
|
|
@ -75,7 +75,7 @@ def watch_json(request):
|
||||||
track_ct = product.get_content_type(track_name) or (
|
track_ct = product.get_content_type(track_name) or (
|
||||||
"video/mp4" if media_type == "video" else "audio/wav"
|
"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",
|
ClientMethod="get_object",
|
||||||
Params={
|
Params={
|
||||||
"Bucket": bucket_name,
|
"Bucket": bucket_name,
|
||||||
|
|
@ -94,7 +94,7 @@ def watch_json(request):
|
||||||
thumbnail_url = None
|
thumbnail_url = None
|
||||||
if "thumbnail1" in product.extensions:
|
if "thumbnail1" in product.extensions:
|
||||||
thumbnail_url = (
|
thumbnail_url = (
|
||||||
f"{request.app['bucket.secure_uploads.get_endpoint']}"
|
f"{request.shop_cdn_endpoint}"
|
||||||
f"/{product.s3_path}/thumbnail1"
|
f"/{product.s3_path}/thumbnail1"
|
||||||
f"?ts={product.updated_timestamp}"
|
f"?ts={product.updated_timestamp}"
|
||||||
)
|
)
|
||||||
|
|
@ -132,7 +132,7 @@ def watch_json(request):
|
||||||
r_thumb = None
|
r_thumb = None
|
||||||
if "thumbnail1" in r.extensions:
|
if "thumbnail1" in r.extensions:
|
||||||
r_thumb = (
|
r_thumb = (
|
||||||
f"{request.app['bucket.secure_uploads.get_endpoint']}"
|
f"{request.shop_cdn_endpoint}"
|
||||||
f"/{r.s3_path}/thumbnail1"
|
f"/{r.s3_path}/thumbnail1"
|
||||||
f"?ts={r.updated_timestamp}"
|
f"?ts={r.updated_timestamp}"
|
||||||
)
|
)
|
||||||
|
|
@ -164,7 +164,7 @@ def watch_json(request):
|
||||||
dl_params["ResponseContentType"] = dl_content_type
|
dl_params["ResponseContentType"] = dl_content_type
|
||||||
file_type_str = dl_content_type
|
file_type_str = dl_content_type
|
||||||
file_size_str = product.human_file_bytes(file_key)
|
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",
|
ClientMethod="get_object",
|
||||||
Params=dl_params,
|
Params=dl_params,
|
||||||
ExpiresIn=900,
|
ExpiresIn=900,
|
||||||
|
|
@ -174,7 +174,7 @@ def watch_json(request):
|
||||||
file_url = None
|
file_url = None
|
||||||
if has_product_file:
|
if has_product_file:
|
||||||
file_url = (
|
file_url = (
|
||||||
f"{request.app['bucket.secure_uploads.get_endpoint']}"
|
f"{request.shop_cdn_endpoint}"
|
||||||
f"/{product.s3_path}/{file_key}"
|
f"/{product.s3_path}/{file_key}"
|
||||||
f"?ts={product.updated_timestamp}"
|
f"?ts={product.updated_timestamp}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue