MPS-22: kill-switch feature flags for karaoke + torrent
Karaoke (MPS-18) and torrent (MPS-19) are broken in production. Adding
two global feature flags off by default so neither feature surfaces in
UI or accepts route traffic until they're fixed.
Pattern mirrors app.features.popout_player.enabled — reified request
properties (request.karaoke_enabled, request.torrent_enabled) read from
ini settings. Templates wrap UI in {% if %}, views return HTTPNotFound
on form sections + routes, view contexts blank out feature-specific
keys when flag off so SPA navigation does not try to render them.
test.ini sets both flags True so existing feature tests keep working.
TestKillSwitches builds a fresh app with both False and verifies the
off path: form_section POSTs return 404, settings page omits sections,
karaoke route 404s, landing page omits karaoke marketing copy.
GET /s/{shop_id}/torrent-backfill-status is shadowed by an earlier
shop_slug catch-all route in production — pre-existing routing defect
that MPS-19 needs to fix when it lands.
This commit is contained in:
parent
b3d9b2b39c
commit
2659ebdcbe
15 changed files with 458 additions and 21 deletions
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -404,6 +404,32 @@ Depth 20 covers any legitimate nesting while keeping N well below our exponentia
|
|||
Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach).
|
||||
Every webapp calling `bleach.clean(user_html)` is exposed — this is our correct fix.
|
||||
|
||||
## Feature Kill Switches (MPS-22)
|
||||
|
||||
Global feature flags live in `data/development.ini` (and override via env var
|
||||
in `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod). Pattern mirrors
|
||||
`app.features.popout_player.enabled` — read via reified request property.
|
||||
|
||||
| Flag | Property | Default | Status |
|
||||
|------|----------|---------|--------|
|
||||
| `app.features.popout_player.enabled` | `request.popout_player_enabled` | True | Working |
|
||||
| `app.features.karaoke.enabled` | `request.karaoke_enabled` | **False** | Broken (MPS-18) |
|
||||
| `app.features.torrent.enabled` | `request.torrent_enabled` | **False** | Broken (MPS-19) |
|
||||
|
||||
When a flag is off:
|
||||
1. Templates wrap UI in `{% if request.X_enabled %}` — section hidden
|
||||
2. Views return `HTTPNotFound` for routes / form sections that touch the feature
|
||||
3. Views set context values for that feature to None / "" / False
|
||||
4. Backfill paths skip work
|
||||
5. Spawn paths (karaoke detached child, torrent generation) bail early
|
||||
|
||||
`test.ini` sets both kill switches **True** so feature tests keep working;
|
||||
`TestKillSwitches` builds a fresh app with both False to verify off-path.
|
||||
|
||||
When fox is ready to flip karaoke or torrent on in prod, set
|
||||
`MPS_FEATURES_KARAOKE_ENABLED=True` (or torrent) in salt pillar
|
||||
`uwsgi/makepostsell/init.sls`, then deploy.
|
||||
|
||||
## Operation Voyeur
|
||||
|
||||
**All comms are public** from 2026-03-29. Assume every terminal session and output is observed. NEVER display secrets to stdout. NEVER pass secrets as CLI args (`ps aux` sees them). NEVER read secret file contents with Read tool or cat — content enters conversation logs. **Path is fine. Content is not.** Safe pattern: write a shell script that reads our key internally, run our script, delete it. Credential locations (paths only): GitLab `~/.config/gitlab/token`, Namecheap `~/.namecheap/api.key`, ImprovMX `~/.improvmx/api.key`.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ app.payments.adyen.enabled = ${MPS_PAYMENTS_ADYEN_ENABLED:-True}
|
|||
# Feature toggles
|
||||
app.features.popout_player.enabled = ${MPS_FEATURES_POPOUT_PLAYER_ENABLED:-True}
|
||||
|
||||
# Karaoke (MPS-18) and torrent (MPS-19) — off by default until fixed.
|
||||
# Flip to True only when the feature works end-to-end. See MPS-22.
|
||||
app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False}
|
||||
app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False}
|
||||
|
||||
# Adyen Configuration (test mode for development)
|
||||
app.adyen.test_mode = ${MPS_ADYEN_TEST_MODE:-True}
|
||||
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ mps_page_session (raw rows)
|
|||
| [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) |
|
||||
| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed |
|
||||
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed |
|
||||
| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete |
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
|
|
|||
140
docs/tickets/mps-22.md
Normal file
140
docs/tickets/mps-22.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# MPS-22: Kill-Switch Feature Flags — Karaoke + Torrent Off by Default
|
||||
|
||||
## Status
|
||||
|
||||
**TO IMPLEMENT.** Karaoke (MPS-18) and torrent (MPS-19) are broken in
|
||||
production. Per-shop opt-in toggles already exist, but shops that flipped
|
||||
them on still see broken UI. Need a global kill switch above the per-shop
|
||||
toggle so neither feature surfaces anywhere until fixed.
|
||||
|
||||
## Problem
|
||||
|
||||
- Karaoke is gated only by `shop.unsandbox_public_key` + `secret_key`. A
|
||||
shop with creds set sees broken karaoke UI on every product page.
|
||||
- Torrent is gated only by `shop.torrent_enabled`. Toggle on → broken
|
||||
magnet button + dead backfill UI.
|
||||
|
||||
We don't want to revert the code (the work is real and resumes when
|
||||
fixed) — we want a global flag that hides the UI and 404s the routes
|
||||
until we flip it back on.
|
||||
|
||||
## Proposal
|
||||
|
||||
Mirror the existing `app.features.popout_player.enabled` pattern exactly:
|
||||
|
||||
1. **Two new ini settings**, both default `False`:
|
||||
- `app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False}`
|
||||
- `app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False}`
|
||||
2. **Two reified request properties** in `request_methods.py`:
|
||||
- `request.karaoke_enabled`
|
||||
- `request.torrent_enabled`
|
||||
3. **Template guards** wrap every UI surface in `{% if request.X_enabled %}`
|
||||
4. **View + route guards** return `HTTPNotFound` when flag off (defense in
|
||||
depth — UI hiding is not security)
|
||||
5. **Watch JSON** omits karaoke / torrent keys when flag off so SPA
|
||||
navigation doesn't try to render them
|
||||
6. **Backfill scripts** skip work when flag off
|
||||
|
||||
## Why off-by-default vs `True` like popout_player?
|
||||
|
||||
`popout_player` defaults `True` because it works. Karaoke and torrent
|
||||
default `False` because they don't. When MPS-18 and MPS-19 land, flip
|
||||
the dev default to `True` and add `MPS_FEATURES_KARAOKE_ENABLED=True`
|
||||
to `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod.
|
||||
|
||||
## UI Surfaces to Hide
|
||||
|
||||
### Karaoke
|
||||
| File | Surface |
|
||||
|------|---------|
|
||||
| `templates/shop_settings.j2` | Unsandbox API keys section + backfill button |
|
||||
| `templates/content.j2` | Karaoke player toggle + URLs |
|
||||
| `templates/player.j2` | Karaoke audio source switch |
|
||||
| `templates/snippets/related_content.j2` | Karaoke indicator on related items |
|
||||
| `templates/home.j2` | Any karaoke discovery / promo |
|
||||
| `static/js/watch.js` | Karaoke toggle (JSON keys absent → no-op naturally) |
|
||||
|
||||
### Torrent
|
||||
| File | Surface |
|
||||
|------|---------|
|
||||
| `templates/shop_settings.j2` | Torrent settings section + backfill UI + status poll |
|
||||
| `templates/content.j2` | Magnet link button |
|
||||
| `templates/product_edit.j2` | Per-product opt-in toggle |
|
||||
|
||||
## Routes to 404 When Off
|
||||
|
||||
| Route | View |
|
||||
|-------|------|
|
||||
| `POST /karaoke/{product_id}` | `views/watch.py:karaoke_process` |
|
||||
| `GET /s/{shop_id}/torrent-backfill-status` | `views/shop.py` |
|
||||
| Settings `form_section=backfill-karaoke` | `views/shop.py:1019-1023` |
|
||||
| Settings `form_section=unsandbox-settings` | `views/shop.py` (creds save) |
|
||||
| Settings `form_section=torrent-settings` | `views/shop.py` |
|
||||
| Settings `form_section=backfill-torrent` (if added by MPS-19 first) | `views/shop.py` |
|
||||
| Karaoke trigger in `views/product.py:464-475` | gate on `request.karaoke_enabled` |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `data/development.ini` | Add 2 feature flag settings, default False |
|
||||
| `make_post_sell/request_methods.py` | Add 2 reified request properties |
|
||||
| `make_post_sell/templates/shop_settings.j2` | Wrap karaoke + torrent sections in flag guards |
|
||||
| `make_post_sell/templates/content.j2` | Wrap karaoke + magnet UI |
|
||||
| `make_post_sell/templates/player.j2` | Wrap karaoke toggle |
|
||||
| `make_post_sell/templates/snippets/related_content.j2` | Wrap karaoke indicator |
|
||||
| `make_post_sell/templates/home.j2` | Wrap any karaoke promo |
|
||||
| `make_post_sell/templates/product_edit.j2` | Wrap torrent opt-in toggle |
|
||||
| `make_post_sell/views/watch.py` | 404 `karaoke_process` when off; omit karaoke keys from JSON |
|
||||
| `make_post_sell/views/content.py` | Omit karaoke keys from template context when off |
|
||||
| `make_post_sell/views/product.py` | Skip karaoke spawn when off |
|
||||
| `make_post_sell/views/shop.py` | 404 form_sections + backfill status when off |
|
||||
| `make_post_sell/scripts/backfill_karaoke.py` | Bail with informative message when off |
|
||||
| `make_post_sell/tests/test_models.py` | Request property unit tests |
|
||||
| `make_post_sell/tests/test_integration.py` | Form section refusal when off |
|
||||
| `make_post_sell/tests/test_functional.py` | UI hidden / routes 404 when off |
|
||||
| `CLAUDE.md` | Document kill-switch pattern + current flag state |
|
||||
| `docs/architecture.md` | Add MPS-22 to ticket index |
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit (`test_models.py`)
|
||||
- `request.karaoke_enabled` returns False when ini value is `False` / `"False"` / `"0"` / `"no"` / `"off"`
|
||||
- Returns True when ini value is `True` / `"True"` / `"1"` / `"yes"` / `"on"`
|
||||
- Defaults to False when key missing (kill-switch posture: silent missing == off)
|
||||
- Same matrix for `request.torrent_enabled`
|
||||
|
||||
### Integration (`test_integration.py`)
|
||||
- Settings POST `form_section=unsandbox-settings` raises HTTPNotFound when karaoke off
|
||||
- Settings POST `form_section=backfill-karaoke` raises HTTPNotFound when karaoke off
|
||||
- Settings POST `form_section=torrent-settings` raises HTTPNotFound when torrent off
|
||||
- When flag on, same POSTs succeed (existing behavior)
|
||||
|
||||
### Functional (`test_functional.py`)
|
||||
- Shop settings page response **does not contain** strings "Unsandbox", "karaoke", "Backfill Vocal", "torrent", "magnet" when both flags off
|
||||
- Shop settings page **does contain** them when both flags on
|
||||
- `POST /karaoke/{product_id}` → 404 when off, 200 when on (with creds)
|
||||
- `GET /s/{shop_id}/torrent-backfill-status` → 404 when off
|
||||
- Product page response does not include magnet button or karaoke toggle when off
|
||||
- Watch JSON response does not include `karaoke_*` or `torrent_*` keys when respective flag off
|
||||
|
||||
## Go-to-Market
|
||||
|
||||
| Surface | Action |
|
||||
|---------|--------|
|
||||
| `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` | (later) add `MPS_FEATURES_KARAOKE_ENABLED` and `MPS_FEATURES_TORRENT_ENABLED` env vars when ready to flip on |
|
||||
| `docs/architecture.md` | Note MPS-22 + reference both flags in feature toggle matrix |
|
||||
| `CLAUDE.md` | Add "Feature Kill Switches" section listing current flags |
|
||||
|
||||
No marketing portal change — these are internal flags. Only flip MPS-18 / MPS-19 GTM when those tickets ship.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `source vars.sh && make test` — all pass
|
||||
2. Local dev: `make serve`, visit shop settings → no Unsandbox section, no torrent section
|
||||
3. Visit a product page → no magnet button, no karaoke toggle
|
||||
4. `curl -X POST /karaoke/{product_id}` → 404
|
||||
5. Flip both env vars to `True`, restart, verify all UI returns
|
||||
6. Flip back to `False`, verify clean hide again
|
||||
7. Push → CI green → deploy → bump GIT_HASH
|
||||
8. Prod check: visit my.makepostsell.com shop settings, confirm sections gone
|
||||
|
|
@ -440,10 +440,34 @@ def includeme(config):
|
|||
return val
|
||||
return True # Default enabled
|
||||
|
||||
def add_karaoke_enabled(request):
|
||||
"""Karaoke kill switch — see MPS-22. Off when ini missing or falsy."""
|
||||
val = request.app.get("features.karaoke.enabled")
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
return False
|
||||
|
||||
def add_torrent_enabled_global(request):
|
||||
"""Torrent kill switch — see MPS-22. Off when ini missing or falsy."""
|
||||
val = request.app.get("features.torrent.enabled")
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
return False
|
||||
|
||||
# Feature toggles
|
||||
config.add_request_method(
|
||||
add_popout_player_enabled, "popout_player_enabled", reify=True
|
||||
)
|
||||
config.add_request_method(
|
||||
add_karaoke_enabled, "karaoke_enabled", reify=True
|
||||
)
|
||||
config.add_request_method(
|
||||
add_torrent_enabled_global, "torrent_enabled", reify=True
|
||||
)
|
||||
|
||||
def add_has_xmr_refund_address(request):
|
||||
"""Check if the current user has an XMR refund address configured."""
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<div class="landing-feature-card well">
|
||||
<div class="landing-feature-icon">🎨</div>
|
||||
<h3 class="type-title">Creative Tools</h3>
|
||||
<p class="type-body-sm">Sandbox filters, karaoke vocal isolation, and more. Built for creators.</p>
|
||||
<p class="type-body-sm">Sandbox filters{% if request.karaoke_enabled %}, karaoke vocal isolation{% endif %}, and more. Built for creators.</p>
|
||||
</div>
|
||||
<div class="landing-feature-card well">
|
||||
<div class="landing-feature-icon">📈</div>
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
|
|||
<br />
|
||||
<br />
|
||||
|
||||
{% if torrent_enabled %}
|
||||
{% if request.torrent_enabled and torrent_enabled %}
|
||||
<label>Torrent Distribution</label>
|
||||
|
||||
{% if torrent_seeded_but_private %}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@
|
|||
<br />
|
||||
{% endif %}
|
||||
|
||||
{% if request.karaoke_enabled %}
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
|
|
@ -469,6 +470,7 @@
|
|||
|
||||
<br />
|
||||
<br />
|
||||
{% endif %}
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
|
@ -1288,6 +1290,7 @@ Existing sales honored for download buy purchasers.
|
|||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
{% if request.torrent_enabled %}
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Torrent Distribution</h3>
|
||||
|
|
@ -1320,6 +1323,7 @@ Existing sales honored for download buy purchasers.
|
|||
{% endif %}
|
||||
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="shop-settings-checksum well2" style="margin-top: var(--space-6);">
|
||||
<h3>File Checksums</h3>
|
||||
|
|
|
|||
|
|
@ -5402,3 +5402,124 @@ class TestRestApiV1(_AuthenticatedBase):
|
|||
path = f"/api/v1/products/{product_id}"
|
||||
headers1 = self._sign(pub1, sec1, "GET", path)
|
||||
self.testapp.get(path, headers=headers1, status=404)
|
||||
|
||||
|
||||
class TestKillSwitches(_AuthenticatedBase):
|
||||
"""MPS-22: when global karaoke / torrent kill switches are off, all
|
||||
surfaces hide and routes 404. test.ini sets both flags True so the
|
||||
feature test classes keep working — this class builds a fresh app
|
||||
with both flags False to verify the off-path. We override setUp at
|
||||
the FunctionalTests level so users are created against the kill-switch
|
||||
app's engine, not the parent app's."""
|
||||
|
||||
def setUp(self):
|
||||
# Bypass FunctionalTests.setUp — we build our own app with flags off.
|
||||
from make_post_sell import main
|
||||
|
||||
self.settings = get_appsettings("test.ini")
|
||||
off_settings = dict(self.settings)
|
||||
off_settings["app.features.karaoke.enabled"] = "False"
|
||||
off_settings["app.features.torrent.enabled"] = "False"
|
||||
|
||||
self.app = main({}, **off_settings)
|
||||
self.testapp = webtest.TestApp(self.app)
|
||||
self.session_factory = self.app.registry["dbsession_factory"]
|
||||
self.engine = self.session_factory.kw["bind"]
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.dbsession = get_tm_session(self.session_factory, transaction.manager)
|
||||
|
||||
# _AuthenticatedBase setup body — same as parent, against this engine.
|
||||
self.shop1_params = {
|
||||
"name": "russell's shop",
|
||||
"phone_number": "555-555-8688",
|
||||
"billing_address": "555 example way\nnorth pole\n555555\n",
|
||||
"description": "russell's shop sells some great digital downloads.",
|
||||
"stripe_public_api_key": environ["MPS_TEST_STRIPE_PUBLIC_API_KEY"],
|
||||
"stripe_secret_api_key": environ["MPS_TEST_STRIPE_SECRET_API_KEY"],
|
||||
"domain_name": "localhost.localhost",
|
||||
}
|
||||
|
||||
self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com")
|
||||
self.user1_creds = ("test1@example.com", self.user1.new_password())
|
||||
self.dbsession.add(self.user1)
|
||||
self.dbsession.flush()
|
||||
transaction.manager.commit()
|
||||
self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com")
|
||||
|
||||
def test_karaoke_route_404_when_off(self):
|
||||
"""POST /karaoke/{id} returns 404 when karaoke kill switch is off."""
|
||||
# The product doesn't need to exist — the flag check fires first.
|
||||
self.testapp.post("/karaoke/anything-here", status=404)
|
||||
|
||||
# NOTE: GET /s/{shop_id}/torrent-backfill-status is shadowed by the
|
||||
# earlier `shop_slug` route in production (pyramid registration order).
|
||||
# The endpoint falls through to the shop home page rather than reaching
|
||||
# `torrent_backfill_status`. This is a pre-existing routing defect that
|
||||
# MPS-19 needs to fix (move `shop_torrent_backfill_status` registration
|
||||
# before `shop_slug`). For now we cover the kill switch through the
|
||||
# form_section POST tests below — those routes are not shadowed.
|
||||
|
||||
def test_unsandbox_settings_form_404_when_karaoke_off(self):
|
||||
"""POST settings form_section=unsandbox-settings 404s when off."""
|
||||
shop = self._create_shop_helper()
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "unsandbox-settings",
|
||||
"unsandbox_public_key": "unsb-pk-test",
|
||||
"unsandbox_secret_key": "unsb-sk-test",
|
||||
"submit": "Save",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
def test_backfill_karaoke_form_404_when_off(self):
|
||||
"""POST settings form_section=backfill-karaoke 404s when off."""
|
||||
shop = self._create_shop_helper()
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "backfill-karaoke",
|
||||
"submit": "Backfill",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
def test_torrent_settings_form_404_when_torrent_off(self):
|
||||
"""POST settings form_section=torrent-settings 404s when off."""
|
||||
shop = self._create_shop_helper()
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "torrent-settings",
|
||||
"torrent_enabled_checkbox": "on",
|
||||
"submit": "Save Settings",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
def test_shop_settings_page_hides_karaoke_section(self):
|
||||
"""The Unsandbox / karaoke section does not render when flag off."""
|
||||
shop = self._create_shop_helper()
|
||||
res = self.testapp.get(f"/s/{shop.id}/settings", status=200)
|
||||
body = res.body.decode()
|
||||
self.assertNotIn("Unsandbox Settings", body)
|
||||
self.assertNotIn("Backfill Vocal Isolation", body)
|
||||
# Bare word "karaoke" should not appear in any visible setting label
|
||||
self.assertNotIn("karaoke mode", body.lower())
|
||||
|
||||
def test_shop_settings_page_hides_torrent_section(self):
|
||||
"""The Torrent Distribution section does not render when flag off."""
|
||||
shop = self._create_shop_helper()
|
||||
res = self.testapp.get(f"/s/{shop.id}/settings", status=200)
|
||||
body = res.body.decode()
|
||||
self.assertNotIn("Torrent Distribution", body)
|
||||
self.assertNotIn("torrent_enabled_checkbox", body)
|
||||
|
||||
def test_landing_hides_karaoke_marketing_when_off(self):
|
||||
"""Home page Creative Tools card omits karaoke language when off."""
|
||||
# Logout for anon view of landing page
|
||||
self.testapp.get("/log-out")
|
||||
res = self.testapp.get("/", status=200)
|
||||
body = res.body.decode()
|
||||
self.assertNotIn("karaoke vocal isolation", body)
|
||||
|
|
|
|||
|
|
@ -4096,3 +4096,72 @@ class TestMpsApiKey(unittest.TestCase):
|
|||
key2, secret2 = self._make_key()
|
||||
self.assertNotEqual(key1.public_key, key2.public_key)
|
||||
self.assertNotEqual(secret1, secret2)
|
||||
|
||||
|
||||
class TestFeatureKillSwitches(unittest.TestCase):
|
||||
"""MPS-22: karaoke + torrent global feature flags.
|
||||
|
||||
Both default off when the ini key is missing or falsy. We test the
|
||||
same resolution logic the request properties use without spinning
|
||||
up Pyramid — the only state that matters is the app settings dict.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _karaoke_resolve(app_dict):
|
||||
val = app_dict.get("features.karaoke.enabled")
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _torrent_resolve(app_dict):
|
||||
val = app_dict.get("features.torrent.enabled")
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("1", "true", "yes", "on")
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
return False
|
||||
|
||||
def test_karaoke_default_off_when_key_missing(self):
|
||||
self.assertFalse(self._karaoke_resolve({}))
|
||||
|
||||
def test_torrent_default_off_when_key_missing(self):
|
||||
self.assertFalse(self._torrent_resolve({}))
|
||||
|
||||
def test_karaoke_truthy_strings(self):
|
||||
for v in ("True", "true", "1", "yes", "on", "TRUE", " on "):
|
||||
self.assertTrue(
|
||||
self._karaoke_resolve({"features.karaoke.enabled": v}),
|
||||
f"expected truthy for {v!r}",
|
||||
)
|
||||
|
||||
def test_karaoke_falsy_strings(self):
|
||||
for v in ("False", "false", "0", "no", "off", "", " "):
|
||||
self.assertFalse(
|
||||
self._karaoke_resolve({"features.karaoke.enabled": v}),
|
||||
f"expected falsy for {v!r}",
|
||||
)
|
||||
|
||||
def test_torrent_truthy_strings(self):
|
||||
for v in ("True", "true", "1", "yes", "on"):
|
||||
self.assertTrue(
|
||||
self._torrent_resolve({"features.torrent.enabled": v}),
|
||||
f"expected truthy for {v!r}",
|
||||
)
|
||||
|
||||
def test_torrent_falsy_strings(self):
|
||||
for v in ("False", "false", "0", "no", "off"):
|
||||
self.assertFalse(
|
||||
self._torrent_resolve({"features.torrent.enabled": v}),
|
||||
f"expected falsy for {v!r}",
|
||||
)
|
||||
|
||||
def test_karaoke_bool_passthrough(self):
|
||||
self.assertTrue(self._karaoke_resolve({"features.karaoke.enabled": True}))
|
||||
self.assertFalse(self._karaoke_resolve({"features.karaoke.enabled": False}))
|
||||
|
||||
def test_torrent_bool_passthrough(self):
|
||||
self.assertTrue(self._torrent_resolve({"features.torrent.enabled": True}))
|
||||
self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False}))
|
||||
|
|
|
|||
|
|
@ -88,13 +88,14 @@ def content(request):
|
|||
# confusion where the sidebar disagrees with reality.
|
||||
apply_no_store_headers(request.response)
|
||||
|
||||
# Generate CDN URLs for karaoke tracks if available
|
||||
# Generate CDN URLs for karaoke tracks if available.
|
||||
# Global karaoke kill switch (MPS-22) gates all karaoke surfaces.
|
||||
instrumentals_url = None
|
||||
vocals_url = None
|
||||
karaoke_eligible = False
|
||||
extension = product.extensions.get("product")
|
||||
media_type = get_media_type(extension) if extension else None
|
||||
if media_type in ("video", "audio"):
|
||||
if request.karaoke_enabled and media_type in ("video", "audio"):
|
||||
shop = product.shop
|
||||
karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key)
|
||||
cdn_base = request.shop_cdn_endpoint
|
||||
|
|
@ -119,14 +120,19 @@ def content(request):
|
|||
"karaoke_eligible": karaoke_eligible,
|
||||
# Only show torrent links on the public page when product is public.
|
||||
# Private/unlisted products must not spread magnet links to visitors.
|
||||
# Global torrent kill switch (MPS-22) hides all torrent surfaces too.
|
||||
"torrent_magnet_link": (
|
||||
product.torrent_magnet_link
|
||||
if product.shop.torrent_enabled and product.visibility == 1
|
||||
if request.torrent_enabled
|
||||
and product.shop.torrent_enabled
|
||||
and product.visibility == 1
|
||||
else None
|
||||
),
|
||||
"torrent_file_url": (
|
||||
product.torrent_file_url
|
||||
if product.shop.torrent_enabled and product.visibility == 1
|
||||
if request.torrent_enabled
|
||||
and product.shop.torrent_enabled
|
||||
and product.visibility == 1
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,8 +311,9 @@ def product_edit(request):
|
|||
request.dbsession.add(product.set_price(price))
|
||||
request.session.flash(("You updated the product's price.", "success"))
|
||||
|
||||
# torrent_opt_in — explicit per-product seeding consent
|
||||
if product.shop.torrent_enabled and "submit" in request.params:
|
||||
# torrent_opt_in — explicit per-product seeding consent.
|
||||
# MPS-22: gated by global torrent kill switch.
|
||||
if request.torrent_enabled and product.shop.torrent_enabled and "submit" in request.params:
|
||||
new_opt_in = request.params.get("torrent_opt_in", "off") == "on"
|
||||
if new_opt_in != product.torrent_opt_in:
|
||||
product_modified = True
|
||||
|
|
@ -426,7 +427,8 @@ def product_edit(request):
|
|||
# - free content (!is_sellable): fire when the CONTENT file is uploaded
|
||||
torrent_trigger_key = "preview" if product.is_sellable else "product"
|
||||
if (
|
||||
file_key == torrent_trigger_key
|
||||
request.torrent_enabled
|
||||
and file_key == torrent_trigger_key
|
||||
and product.shop.torrent_enabled
|
||||
and product.torrent_opt_in
|
||||
and product.visibility == 1
|
||||
|
|
@ -460,11 +462,17 @@ def product_edit(request):
|
|||
# Generate vocal isolation tracks if this is audio/video.
|
||||
# Runs in a detached child — karaoke takes minutes and would hang
|
||||
# the response (black screen after upload) if run inline.
|
||||
# MPS-22: gated by global karaoke kill switch.
|
||||
from ..models.product import get_media_type
|
||||
from ..lib.karaoke import capture_karaoke_config, process_karaoke_detached
|
||||
upload_media_type = get_media_type(product.extensions.get(file_key))
|
||||
shop = product.shop
|
||||
if upload_media_type in ("video", "audio") and shop.unsandbox_public_key and shop.unsandbox_secret_key:
|
||||
if (
|
||||
request.karaoke_enabled
|
||||
and upload_media_type in ("video", "audio")
|
||||
and shop.unsandbox_public_key
|
||||
and shop.unsandbox_secret_key
|
||||
):
|
||||
ext = product.extensions.get(file_key)
|
||||
process_karaoke_detached(
|
||||
product_id=product.id,
|
||||
|
|
@ -570,12 +578,23 @@ def product_edit(request):
|
|||
"locations": locations,
|
||||
"inventories": inventories if product.is_physical else {},
|
||||
"price_history": price_history,
|
||||
"torrent_enabled": product.shop.torrent_enabled,
|
||||
"torrent_opt_in": product.torrent_opt_in,
|
||||
"torrent_magnet_link": product.torrent_magnet_link or "",
|
||||
"torrent_file_url": product.torrent_file_url or "",
|
||||
# MPS-22: torrent kill switch hides all torrent context from edit UI
|
||||
"torrent_enabled": (
|
||||
product.shop.torrent_enabled if request.torrent_enabled else False
|
||||
),
|
||||
"torrent_opt_in": (
|
||||
product.torrent_opt_in if request.torrent_enabled else False
|
||||
),
|
||||
"torrent_magnet_link": (
|
||||
(product.torrent_magnet_link or "") if request.torrent_enabled else ""
|
||||
),
|
||||
"torrent_file_url": (
|
||||
(product.torrent_file_url or "") if request.torrent_enabled else ""
|
||||
),
|
||||
"torrent_seeded_but_private": bool(
|
||||
product.torrent_magnet_link and product.visibility != 1
|
||||
request.torrent_enabled
|
||||
and product.torrent_magnet_link
|
||||
and product.visibility != 1
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from ..lib.phone_numbers import is_phone_number_valid
|
|||
|
||||
from ..lib.currency import dollars_to_cents, cents_to_dollars
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||
from pyramid.response import Response
|
||||
|
||||
# feel free to come up with a better plan, GPT-4 made this regex.
|
||||
|
|
@ -978,8 +978,10 @@ def shop_settings(request):
|
|||
shop.adyen_hmac_key = adyen_hmac_key
|
||||
request.session.flash(("You set the shop's Adyen HMAC Key.", "success"))
|
||||
|
||||
# Handle unsandbox settings form
|
||||
# Handle unsandbox settings form (MPS-22: gated by karaoke kill switch)
|
||||
if form_section == "unsandbox-settings":
|
||||
if not request.karaoke_enabled:
|
||||
raise HTTPNotFound()
|
||||
unsandbox_public_key = request.params.get("unsandbox_public_key", "").strip()
|
||||
unsandbox_secret_key = request.params.get("unsandbox_secret_key", "").strip()
|
||||
keys_changed = False
|
||||
|
|
@ -1016,8 +1018,10 @@ def shop_settings(request):
|
|||
)
|
||||
request.session.flash(("Backfilling vocal isolation for existing catalog in background.", "success"))
|
||||
|
||||
# Handle backfill karaoke button
|
||||
# Handle backfill karaoke button (MPS-22: gated by karaoke kill switch)
|
||||
if form_section == "backfill-karaoke":
|
||||
if not request.karaoke_enabled:
|
||||
raise HTTPNotFound()
|
||||
if shop.unsandbox_public_key and shop.unsandbox_secret_key:
|
||||
from ..lib.karaoke import backfill_karaoke_async
|
||||
backfill_karaoke_async(
|
||||
|
|
@ -1337,6 +1341,9 @@ def shop_settings(request):
|
|||
request.session.flash(("Storage bucket settings updated.", "success"))
|
||||
|
||||
if form_section == "torrent-settings":
|
||||
# MPS-22: gated by torrent kill switch
|
||||
if not request.torrent_enabled:
|
||||
raise HTTPNotFound()
|
||||
torrent_enabled = checkbox_to_bool(
|
||||
request.params.get("torrent_enabled_checkbox", "off")
|
||||
)
|
||||
|
|
@ -1556,7 +1563,8 @@ def shop_settings(request):
|
|||
"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,
|
||||
"torrent_enabled": shop.torrent_enabled,
|
||||
# MPS-22: torrent kill switch hides shop's per-shop opt-in from settings UI
|
||||
"torrent_enabled": shop.torrent_enabled if request.torrent_enabled else False,
|
||||
"signed_posts": signed_posts,
|
||||
"get_endpoints": get_endpoints,
|
||||
"api_keys": shop.api_keys.filter_by(is_active=True).order_by("created_timestamp").all(),
|
||||
|
|
@ -1626,6 +1634,9 @@ def torrent_backfill_status(request):
|
|||
- done: of those, how many already have a magnet link
|
||||
- remaining: total - done
|
||||
"""
|
||||
if not request.torrent_enabled:
|
||||
raise HTTPNotFound()
|
||||
|
||||
from ..models.product import Product
|
||||
|
||||
products = (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
|
||||
from pyramid.httpexceptions import HTTPNotFound
|
||||
from pyramid.view import view_config
|
||||
|
||||
from ..models.product import get_media_type, get_related_products, get_ring_related_products
|
||||
|
|
@ -76,11 +77,12 @@ def watch_json(request):
|
|||
ExpiresIn=900,
|
||||
)
|
||||
|
||||
# Generate presigned URLs for karaoke tracks if available
|
||||
# Generate presigned URLs for karaoke tracks if available.
|
||||
# Global karaoke kill switch (MPS-22) gates all karaoke surfaces.
|
||||
instrumentals_url = None
|
||||
vocals_url = None
|
||||
karaoke_eligible = False
|
||||
if media_type in ("video", "audio"):
|
||||
if request.karaoke_enabled and media_type in ("video", "audio"):
|
||||
shop = request.shop
|
||||
karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key)
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
|
|
@ -255,6 +257,9 @@ def karaoke_process(request):
|
|||
which double-forks and runs karaoke in a detached child. The
|
||||
watch_json 10s refresh loop picks up the new URLs when done.
|
||||
"""
|
||||
if not request.karaoke_enabled:
|
||||
raise HTTPNotFound()
|
||||
|
||||
product_id = request.matchdict.get("product_id")
|
||||
from ..models.product import Product
|
||||
try:
|
||||
|
|
|
|||
6
test.ini
6
test.ini
|
|
@ -35,6 +35,12 @@ app.payments.stripe.enabled = True
|
|||
app.payments.monero.enabled = False
|
||||
app.payments.paypal.enabled = True
|
||||
|
||||
# MPS-22: feature kill switches — ON in tests so feature tests keep working.
|
||||
# Production / development.ini default both to False (broken in prod until
|
||||
# MPS-18 + MPS-19 land). TestKillSwitches builds a fresh app with both off.
|
||||
app.features.karaoke.enabled = True
|
||||
app.features.torrent.enabled = True
|
||||
|
||||
# PayPal sandbox mode for testing
|
||||
app.paypal.sandbox_mode = True
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue