diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 2a85317..6ee0098 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -200,7 +200,7 @@ def send_purchase_email(request, to_email, products, total_cost): thumbnail = "" if "thumbnail1" in p.extensions: thumbnail = ''.format( - request.app["bucket.secure_uploads.get_endpoint"], + request.shop_cdn_endpoint, p.s3_path, p.updated_timestamp, ) @@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost): thumbnail = "" if "thumbnail1" in p.extensions: thumbnail = ''.format( - request.app["bucket.secure_uploads.get_endpoint"], + request.shop_cdn_endpoint, p.s3_path, p.updated_timestamp, ) diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 827b40c..1d04f39 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -166,6 +166,23 @@ class Shop(RBase, Base): gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500) gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000) + # Shop environment: 0=production, 1=staging, 2=development + environment = Column(BigInteger, nullable=False, default=0) + + # Trial and billing + trial_started_timestamp = Column(BigInteger, nullable=True) + trial_ended = Column(Boolean, default=False) + plan_active = Column(Boolean, default=False) + + # Primary S3 bucket (Bring Your Own Bucket) + primary_s3_endpoint = Column(Unicode(256), nullable=True) + primary_s3_region = Column(Unicode(64), nullable=True) + primary_s3_bucket = Column(Unicode(128), nullable=True) + primary_s3_access_key = Column(Unicode(128), nullable=True) + primary_s3_secret_key = Column(Unicode(128), nullable=True) + primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True) + primary_s3_enabled = Column(Boolean, default=False) + # Precomputed discovery ring: circular ordering of all public products json_discovery_ring = Column(UnicodeText, nullable=True) @@ -248,6 +265,94 @@ class Shop(RBase, Base): us.role_id = role_id return us + # --- Environment properties (MPS-14) --- + + @property + def is_production(self): + return self.environment == 0 + + @property + def is_staging(self): + return self.environment == 1 + + @property + def is_development(self): + return self.environment == 2 + + @property + def is_non_production(self): + return self.environment != 0 + + @property + def environment_label(self): + return {0: "Production", 1: "Staging", 2: "Development"}.get( + self.environment, "Production" + ) + + # --- Trial properties (MPS-15) --- + + TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days + + @property + def trial_expiry_timestamp(self): + if self.trial_started_timestamp is None: + return None + return self.trial_started_timestamp + self.TRIAL_DURATION_MS + + @property + def is_trial_active(self): + if self.plan_active: + return False + if self.trial_started_timestamp is None: + return False + import time + now = int(time.time() * 1000) + return now < self.trial_expiry_timestamp + + @property + def is_trial_expired(self): + if self.plan_active or self.trial_started_timestamp is None: + return False + import time + now = int(time.time() * 1000) + return now >= self.trial_expiry_timestamp + + @property + def trial_days_remaining(self): + if not self.is_trial_active: + return 0 + import time + remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000) + return max(0, remaining_ms // (24 * 60 * 60 * 1000)) + + @property + def is_active(self): + """Shop can operate: either paid plan, active trial, or pre-trial (existing).""" + if self.plan_active: + return True + if self.trial_started_timestamp is None: + return True # grandfathered pre-trial shop + return self.is_trial_active + + # --- BYOB properties (MPS-16) --- + + @property + def has_primary_s3(self): + return bool( + self.primary_s3_enabled + and self.primary_s3_endpoint + and self.primary_s3_bucket + and self.primary_s3_access_key + and self.primary_s3_secret_key + and self.primary_s3_cdn_endpoint + ) + + @property + def media_cdn_endpoint(self): + if self.has_primary_s3: + return self.primary_s3_cdn_endpoint + return None + @property def has_s3_mirror(self): """Return True if shop has S3 mirror bucket configured and enabled.""" diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index f75293b..e4d5cce 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -155,6 +155,35 @@ def includeme(config): aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"], ) + def add_shop_uploads_client(request): + """Return S3 client for the current shop (BYOB or MPS default).""" + shop = request.shop + if shop and shop.has_primary_s3: + import boto3 + session = boto3.session.Session() + return session.client( + "s3", + region_name=shop.primary_s3_region, + endpoint_url=shop.primary_s3_endpoint, + aws_access_key_id=shop.primary_s3_access_key, + aws_secret_access_key=shop.primary_s3_secret_key, + ) + return request.secure_uploads_client + + def add_shop_bucket_name(request): + """Return the bucket name for the current shop.""" + shop = request.shop + if shop and shop.has_primary_s3: + return shop.primary_s3_bucket + return request.app["bucket.secure_uploads"] + + def add_shop_cdn_endpoint(request): + """Return the CDN endpoint for the current shop's media.""" + shop = request.shop + if shop and shop.has_primary_s3: + return shop.primary_s3_cdn_endpoint + return request.app["bucket.secure_uploads.get_endpoint"] + def add_is_shop_domain(request): """ Returns True or False. @@ -373,6 +402,11 @@ def includeme(config): add_secure_uploads_client, "secure_uploads_client", reify=True ) + # BYOB: shop-aware S3 client and bucket + config.add_request_method(add_shop_uploads_client, "shop_uploads_client", reify=True) + config.add_request_method(add_shop_bucket_name, "shop_bucket_name", reify=True) + config.add_request_method(add_shop_cdn_endpoint, "shop_cdn_endpoint", reify=True) + # Payment method checks config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True) config.add_request_method( diff --git a/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py b/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py new file mode 100644 index 0000000..e2b71ef --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py @@ -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") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 7f893ba..4b3a489 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -497,6 +497,40 @@ section.main { overflow-x: hidden; } +.environment-banner { + display: grid; + align-items: center; + text-align: center; + font-weight: bold; + font-size: var(--font-size-sm); + padding: var(--space-2) var(--space-4); + color: var(--color-white); + background-color: var(--color-warning, #e6a700); +} + +.environment-banner-development { + background-color: var(--color-info, #3b82f6); +} + +.trial-banner { + display: grid; + align-items: center; + text-align: center; + font-size: var(--font-size-sm); + padding: var(--space-2) var(--space-4); + background-color: var(--color-info, #3b82f6); + color: var(--color-white); +} + +.trial-banner a { + color: var(--color-white); + text-decoration: underline; +} + +.trial-banner-expired { + background-color: var(--color-error, #dc2626); +} + /* section.grid-nav { grid-column: 1/-1; diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index 6c381ab..eb9c21e 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -65,7 +65,7 @@ {% if request.is_saas_domain == false and request.shop and request.shop.favicon %} - + {% endif %} {% if request.path == '/' and request.shop and request.shop.google_site_verification %} @@ -87,6 +87,20 @@ {% include 'snippets/ribbon.j2' %} +{% if request.shop and request.shop.is_non_production %} +
+ {{ request.shop.environment_label|upper }} ENVIRONMENT — This shop is not visible to the public. +
+{% endif %} +{% if request.shop and request.shop.is_trial_active %} +
+ Trial: {{ request.shop.trial_days_remaining }} day{{ 's' if request.shop.trial_days_remaining != 1 else '' }} remaining — Choose a plan +
+{% elif request.shop and request.shop.is_trial_expired %} +
+ Trial expired — Choose a plan to continue editing +
+{% endif %}
+
+
+ +
+
+ +

Environment Settings

+ +

Non-production shops are hidden from search, feeds, and discovery. Use them to stage or test before going live.

+ +
+ + +
+ Environment + + + + + + + + + +
+ +
+
+ + + +
+ +
+ +
+ +
+
+ +
+
+ +

Storage Bucket (BYOB)

+ +

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.

+ +
+ + + + +

+ + + + +

+ + + + +

+ + + + +

+ + + + +

+ + + + +

+ + + + +

+ + + +
+ +
+ +
+