make_post_sell/make_post_sell/views/product.py
russell@unturf.com f591620424
feat: MPS-24 Phase 2.7 — per-product SPA tag chips on product edit
Operator report: adding/removing a tag on the product edit page
refreshed the whole screen. Tags lived only as a comma-separated
<input name=tags> inside the big product form, so any tag change
needed a full Save Settings POST + page reload.

- New route/view: product_tags -> /p/{id}/tags (before product_slug
  catch-all), @shop_editor_required + @trial_active_required.
  action=add (get_or_create_tag + attach) / action=remove (detach).
  AJAX (X-Requested-With) -> JSON, no reload; plain POST -> 302 back
  to edit (no-JS still works). Rebuilds discovery ring like product_edit.
- Shared is_ajax() in views/__init__.py (single source of truth;
  shop.py:_is_ajax delegates — bulk tagger behaviour unchanged).
- product_edit.j2: comma field kept as no-JS path; js-only chip
  editor added. product_tags.js reveals chips, demotes raw input to
  hidden, keeps it in lock-step so a later full Save is a no-op.
- .tag-chip-removable family in common.css (tokens-only, Grid-only,
  always-visible remove button) + /styleguide#tagchips.
- Harden tag_bulk.js: init() binds the delegated submit listener
  unconditionally (no early-return that could strand the bulk-tagger
  SPA into full reloads).
- Tests: unit (slug dedupe invariant), integration
  (TestProductTagAddRemoveIntegration), functional (TestProductTagsSpa
  incl. bulk-tagger-AJAX-returns-JSON regression guard). 1124 passed.

Docs: architecture.md, design-system.md, CLAUDE.md, mps-24.md.
2026-05-16 07:58:25 -04:00

1044 lines
41 KiB
Python

from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.response import Response
from . import (
user_required,
shop_editor_required,
trial_active_required,
get_referer_or_home,
is_ajax,
)
from ..models.product import Product
from ..models.shop import reforge_discovery_ring_async
from ..models.shop_location import ShopLocation
from ..models.inventory import Inventory
from ..lib.currency import validate_float, cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
from ..lib.cache_version import compute_cache_version, apply_no_store_headers
def checkbox_to_bool(checkbox):
return checkbox == "on"
@view_config(route_name="product", renderer="product.j2")
@view_config(route_name="product_slug", renderer="product.j2")
def product(request):
product = request.product
if not request.is_saas_domain and request.domain != request.shop.domain_name:
# The product uuid in the URI matchdict is mismatched with request shop.
request.session.flash(("Refusing to display another shop's product.", "error"))
return HTTPFound(get_referer_or_home(request))
if product.is_private:
if request.user is None or request.user.can_not_edit_product(product):
request.session.flash(("That product is private.", "error"))
return HTTPFound(get_referer_or_home(request))
if product.is_not_sellable:
return HTTPFound(f"/c/{product.id}/{product.slug}")
if "slug" not in request.matchdict:
return HTTPFound(f"/p/{product.id}/{product.slug}")
signed_get_object_url = None
bucket_name = request.shop_bucket_name
if (
request.user
and request.user.authenticated
and request.user.can_download_product(product)
):
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseContentEncoding,
# ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId,
# SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, RequestPayer, PartNumber
params = {
"Bucket": bucket_name,
"Key": product.s3_key,
}
# The content disposition string determines whether this is:
# * an inline image (thumbnails)
# * an attachment (file download)
content_disposition = product.get_content_disposition("product")
# The content type string determines whether this is a pdf, zip, etc.
content_type = product.get_content_type("product")
if content_disposition:
params["ResponseContentDisposition"] = content_disposition
if content_type:
params["ResponseContentType"] = content_type
signed_get_object_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
# 15 minutes.
ExpiresIn=900,
)
product_size = ""
if product.has_product_file:
product_size = product.human_product_file_bytes
# Load comments for the product
from ..models.comment import get_comments_for_product
comments = get_comments_for_product(
request.dbsession, product.id, shop=product.shop, user=request.user
)
related_products = []
if product.shop.watch_mode_enabled:
from ..models.product import get_ring_related_products, get_related_products
ring = product.shop.discovery_ring
if ring:
related_products = get_ring_related_products(product, ring, forward=42)
else:
related_products = get_related_products(product)
# Price history is gated by shop.show_price_history (off by default
# per operator feedback — "wait for a sale" psychology kills sales).
# When the toggle is ON, everyone sees the table. When OFF (default),
# the table is hidden for both shoppers and operators (operator can
# still review historical prices in shop analytics). The template
# gates rendering separately as belt-and-suspenders.
price_history = []
if product.shop.show_price_history and product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(ph.created_timestamp),
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
"is_current": i == 0,
})
# Dynamic product page — never cache HTML at the browser. Same
# reason as content.py: Up Next sidebar must reflect live ring state.
apply_no_store_headers(request.response)
return {
"product": product,
"product_size": product_size,
"signed_get_object_url": signed_get_object_url,
"comments": comments,
"shop": product.shop,
"related_products": related_products,
"cache_version": compute_cache_version(product.shop),
"price_history": price_history,
}
@view_config(route_name="product_new", renderer="product_new.j2")
@view_config(route_name="content_new", renderer="content_new.j2")
@user_required()
@shop_editor_required()
@trial_active_required()
def product_new(request):
title = request.params.get("title", "").strip()
description = request.params.get("description", "").strip()
price = request.params.get("price", "").strip()
is_bundle_checkbox = request.params.get("is_bundle", "off")
is_bundle = checkbox_to_bool(is_bundle_checkbox)
is_sellable_checkbox = request.params.get("is_sellable", "off")
is_sellable = checkbox_to_bool(is_sellable_checkbox)
is_physical = bool(request.params.get("is_physical", "false") == "true")
if "submit" in request.params:
# check for required fields.
if not title or not description or (is_sellable and not price):
msg = ("You must fill out all fields.", "error")
request.session.flash(msg)
else:
# Validate price if the product is sellable
price_error = False
if is_sellable:
try:
price = validate_float(price)
except Exception as e:
price_error = True
msg = (f"Please enter a valid number for price. {e}", "error")
request.session.flash(msg)
if not price_error:
# prevent trying to create a bundle and also physical product.
if is_bundle and is_physical:
msg = (
"The bundle feature only works with digital goods. Please uncheck the bundle option or mark the product as digital.",
"error",
)
request.session.flash(msg)
else:
product = Product(title, description)
product.shop = request.shop
product.is_bundle = is_bundle
product.is_sellable = is_sellable
product.is_physical = is_physical
if product.error_message:
request.session.flash((product.error_message, "error"))
else:
if is_sellable and price:
product_price = product.set_price(price)
request.dbsession.add(product_price)
request.dbsession.add(product)
request.dbsession.flush()
if request.shop.watch_mode_enabled:
reforge_discovery_ring_async(
request.shop.id, request.registry["dbsession_factory"]
)
msg = ("Great, next you may upload files.", "success")
request.session.flash(msg)
return HTTPFound(f"/p/{product.id}/edit")
return {
"title": title,
"description": description,
"price": price,
"is_bundle": is_bundle,
"is_physical": is_physical,
"create_location_url": request.route_url(
"shop_location_new", shop_id=request.shop.id
),
"shop_has_locations": len(request.shop.shop_locations.all()) > 0,
}
@view_config(route_name="product_description_edit", renderer="markup_editor.j2")
@view_config(route_name="content_description_edit", renderer="markup_editor.j2")
@shop_editor_required()
def product_edit_description(request):
product = request.product
raw_markup_data = request.params.get(
"markup-editor-textarea",
# TODO: rename this column to description_raw.
product.description,
).strip()
if raw_markup_data and raw_markup_data != product.description:
product.set_description(raw_markup_data)
msg = ("You set the Products's Description!", "success")
request.session.flash(msg)
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
return {
"markup_subject": f"Product Description for {product.title}",
"markup_rendered": product.description_html,
"markup_raw": product.description,
"markup_form_path": f"{product.absolute_edit_url(request, subject='description')}",
}
@view_config(route_name="product_edit", renderer="product_edit.j2")
@view_config(route_name="product_edit2", renderer="product_edit.j2")
@view_config(route_name="content_edit", renderer="product_edit.j2")
@view_config(route_name="content_edit2", renderer="product_edit.j2")
@shop_editor_required()
@trial_active_required()
def product_edit(request):
product_modified = False
product = request.product
title = request.params.get("title", product.title).strip()
description = request.params.get("description", product.description).strip()
price = request.params.get("price", product.price)
visibility = int(request.params.get("visibility", product.visibility))
uploaded_file_key = request.params.get("uploaded", "")
if uploaded_file_key:
request.session.flash(
(
f"You successfully uploaded a new {uploaded_file_key} file!",
"success",
)
)
try:
price = validate_float(price)
except Exception as e:
request.session.flash((f"Invalid price. {e}", "error"))
price = product.price
s3_webhook_key = request.params.get("key")
s3_webhook_bucket = request.params.get("bucket")
s3_webhook_etag = request.params.get("etag")
if title != product.title:
product_modified = True
product.title = title
request.session.flash(("You updated the product's title.", "success"))
if description != product.description:
product_modified = True
product.set_description(description)
request.session.flash(("You updated the product's description.", "success"))
if visibility != product.visibility:
product_modified = True
product.set_visibility(
visibility,
request.shop_uploads_client,
request.shop_bucket_name,
)
request.session.flash(("You updated the product's visibility.", "success"))
# Warn if switching away from public while a torrent is already seeded.
if visibility != 1 and product.torrent_magnet_link:
request.session.flash((
"Warning: this product has an active torrent. "
"The magnet link is no longer shown to visitors, "
"but the file persists in the BitTorrent network and cannot be recalled.",
"warning",
))
if price != product.price:
product_modified = True
request.dbsession.add(product.set_price(price))
request.session.flash(("You updated the product's price.", "success"))
# MPS-24: tag editing. Only act when the `tags` input is present in the
# submission, so other forms on the page (uploads, inventory) don't
# silently strip product tags.
if "tags" in request.params:
from ..models.tag import get_or_create_tag
raw = (request.params.get("tags") or "").strip()
names = [t.strip() for t in raw.split(",") if t.strip()]
desired_tags = []
seen_slugs = set()
for name in names:
tag = get_or_create_tag(request.dbsession, product.shop, name)
if tag is None or tag.slug in seen_slugs:
continue
seen_slugs.add(tag.slug)
desired_tags.append(tag)
current = list(product.tags)
current_slugs = {t.slug for t in current}
desired_slugs = {t.slug for t in desired_tags}
if current_slugs != desired_slugs:
for tag in current:
if tag.slug not in desired_slugs:
product.tags.remove(tag)
for tag in desired_tags:
if tag.slug not in current_slugs:
product.tags.append(tag)
product_modified = True
request.session.flash(
("You updated the product's tags.", "success")
)
# MPS-20 + MPS-21: pricing_mode + allow_offers (per-product overrides).
# Gated on the pricing_mode field being present — it only appears on
# the product-title-and-description edit form, so other forms on the
# page (uploads, inventory, bundle) won't trigger this block.
# NOTE: do NOT gate on "submit" in request.params — the edit form's
# onsubmit handler disables the submit button before the browser
# collects form data, so "submit" is never sent.
if "pricing_mode" in request.params:
try:
new_mode = int(request.params["pricing_mode"])
except (TypeError, ValueError):
new_mode = product.pricing_mode
if new_mode in (0, 1, 2, 3, 4) and new_mode != product.pricing_mode:
product_modified = True
product.pricing_mode = new_mode
request.dbsession.add(product)
request.dbsession.flush()
mode_label = {
0: "fixed price", 1: "auction", 2: "auction + buy now",
3: "make an offer", 4: "make an offer + buy now",
}[new_mode]
request.session.flash(
(f"Pricing mode set to {mode_label}.", "success")
)
# If switching INTO auction mode and no auction exists, create one
# in DRAFT state with sane defaults from the product price.
if new_mode in (1, 2) and product.auction is None:
from ..models.auction import (
MpsAuction, DEFAULT_BID_INCREMENT_IN_CENTS,
DEFAULT_SOFT_CLOSE_SECONDS,
)
auction = MpsAuction(
product=product,
shop=product.shop,
start_price_in_cents=product.price_in_cents or 100,
bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS,
soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS,
)
request.dbsession.add(auction)
request.session.flash((
"Auction created in draft state. Configure start/end "
"times and reserve price below before scheduling.",
"success",
))
# allow_offers — three-state radio: inherit / yes / no. Only
# process when the radio is actually present in the form.
if "allow_offers" in request.params:
new_allow = {
"inherit": None, "yes": True, "no": False,
}.get(request.params["allow_offers"], None)
if new_allow != product.allow_offers:
product_modified = True
product.allow_offers = new_allow
# MPS-20: auction config — only meaningful when product has an
# auction and the auction is still in DRAFT (locked once SCHEDULED).
if product.auction is not None and product.auction.is_draft:
from ..models.auction import (
AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
now_timestamp as auction_now_ms,
)
from datetime import datetime, timezone
auction = product.auction
def _input_to_ms(raw):
"""datetime-local 'YYYY-MM-DDTHH:MM' → ms since epoch (UTC)."""
if not raw:
return None
try:
dt = datetime.strptime(raw.strip(), "%Y-%m-%dT%H:%M")
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
except ValueError:
return None
def _dollar_to_cents(raw):
if raw is None or raw == "":
return None
try:
return int(round(float(raw) * 100))
except (TypeError, ValueError):
return None
# Quantity — physical only; digital forces 1.
try:
new_qty = int(request.params.get("auction_quantity", auction.quantity))
except (TypeError, ValueError):
new_qty = auction.quantity
if not product.is_physical:
new_qty = 1
new_qty = max(1, new_qty)
if new_qty != auction.quantity:
auction.quantity = new_qty
new_start = _input_to_ms(request.params.get("auction_start", ""))
new_end = _input_to_ms(request.params.get("auction_end", ""))
if new_start is not None and new_start != auction.start_timestamp:
auction.start_timestamp = new_start
if new_end is not None and new_end != auction.end_timestamp:
auction.end_timestamp = new_end
auction.original_end_timestamp = new_end
new_start_price = _dollar_to_cents(request.params.get("auction_start_price"))
if new_start_price is not None and new_start_price != auction.start_price_in_cents:
auction.start_price_in_cents = max(0, new_start_price)
# Reserve / buy-now: blank input clears.
reserve_raw = (request.params.get("auction_reserve_price") or "").strip()
if reserve_raw == "":
auction.reserve_price_in_cents = None
else:
rp = _dollar_to_cents(reserve_raw)
if rp is not None:
auction.reserve_price_in_cents = rp
if product.pricing_mode in (2, 4):
buy_now_raw = (request.params.get("auction_buy_now_price") or "").strip()
if buy_now_raw == "":
auction.buy_now_price_in_cents = None
else:
bn = _dollar_to_cents(buy_now_raw)
if bn is not None:
auction.buy_now_price_in_cents = bn
else:
auction.buy_now_price_in_cents = None
new_inc = _dollar_to_cents(request.params.get("auction_increment"))
if new_inc is not None and new_inc > 0:
auction.bid_increment_in_cents = new_inc
try:
new_sc = int(request.params.get("auction_soft_close", auction.soft_close_seconds))
auction.soft_close_seconds = max(0, new_sc)
except (TypeError, ValueError):
pass
auction.updated_timestamp = auction_now_ms()
# Schedule: flip DRAFT → SCHEDULED. Validate timestamps first.
if request.params.get("auction_schedule") == "on":
if not auction.start_timestamp or not auction.end_timestamp:
request.session.flash((
"Set both start and end times before scheduling the auction.",
"error",
))
elif auction.end_timestamp <= auction.start_timestamp:
request.session.flash((
"Auction end must be after start.",
"error",
))
else:
# If start has already passed, jump straight to ACTIVE.
now = auction_now_ms()
if auction.start_timestamp <= now:
auction.state = AUCTION_STATE_ACTIVE
request.session.flash((
"Auction is now ACTIVE — accepting bids.",
"success",
))
else:
auction.state = AUCTION_STATE_SCHEDULED
request.session.flash((
"Auction scheduled. Fields locked.",
"success",
))
product_modified = True
request.dbsession.add(auction)
# torrent_opt_in — explicit per-product seeding consent.
# MPS-22: gated by global torrent kill switch. Gated on pricing_mode
# being present (signals the product-edit form, not uploads/inventory)
# rather than "submit" — see the pricing_mode block note above.
if (
request.torrent_enabled
and product.shop.torrent_enabled
and "pricing_mode" 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
product.torrent_opt_in = new_opt_in
if new_opt_in:
request.session.flash((
"Torrent seeding enabled for this product. "
"The file will be seeded when you upload or re-upload. "
"Warning: once seeded, files cannot be recalled from the BitTorrent network.",
"warning",
))
else:
request.session.flash((
"Torrent seeding disabled. "
"Note: if already seeded, the file persists in the BitTorrent network.",
"success",
))
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# Check if the file exists and has a non-zero size
try:
response = request.shop_uploads_client.head_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
file_size = response.get("ContentLength", 0)
if file_size == 0:
raise ValueError("File size is zero")
except Exception:
request.session.flash(
("File upload failed. Pick a file and try again.", "error")
)
return HTTPFound(request.route_url("product_edit", product_id=product.id))
# Store the uploaded user's file_metadata in our database.
file_key = product.store_file_metadata(s3_webhook_key)
# This is how we protect our digital downloads while using the
# same bucket for thumbnails and previews related to the product.
# Use the new visibility-aware ACL method
acl = product.get_s3_acl_for_file_key(file_key)
if product.is_not_sellable:
acl = "public-read"
# don't allow proxies to cache and set the max age to 2 days.
cache_control = "private, max-age=172800"
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
content_type = product.get_content_type(file_key) or "application/octet-stream"
request.shop_uploads_client.copy_object(
ACL=acl,
Bucket=request.shop_bucket_name,
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
},
ContentDisposition=product.get_content_disposition(file_key),
ContentType=content_type,
CacheControl=cache_control,
Key=f"{product.s3_path}/{file_key}",
MetadataDirective="REPLACE",
)
# Mirror to shop's custom S3 bucket if configured
from ..lib.s3_mirror import mirror_key_async
mirror_key_async(
request.shop_uploads_client,
request.shop_bucket_name,
f"{product.s3_path}/{file_key}",
product.shop,
content_type=content_type,
cache_control=cache_control,
)
# delete original upload key.
request.shop_uploads_client.delete_object(
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
# get file size & store in our database.
response = request.shop_uploads_client.head_object(
Bucket=request.shop_bucket_name,
Key=f"{product.s3_path}/{file_key}",
)
product_content_length = response["ContentLength"]
tmp_file_bytes = product.file_bytes or {}
tmp_file_bytes[file_key] = product_content_length
product.file_bytes = tmp_file_bytes
product.stamp_updated_timestamp()
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
# Generate .torrent bundle only when:
# - torrent distribution is enabled for the shop
# - the owner explicitly opted this product in (torrent_opt_in)
# - the product is public (visibility == 1)
# Torrents are permanent — files persist in the swarm once seeded.
#
# Trigger key:
# - paid products (is_sellable): fire when the PREVIEW is uploaded
# (the paid product file is never seeded)
# - free content (!is_sellable): fire when the CONTENT file is uploaded
torrent_trigger_key = "preview" if product.is_sellable else "product"
if (
request.torrent_enabled
and file_key == torrent_trigger_key
and product.shop.torrent_enabled
and product.torrent_opt_in
and product.visibility == 1
):
from ..lib.torrent import generate_torrent_async, build_bundle_files
bundle_files = build_bundle_files(product, request.shop_cdn_endpoint)
if bundle_files:
generate_torrent_async(
request.shop_uploads_client,
request.shop_bucket_name,
bundle_name=product.slug or str(product.id),
files=bundle_files,
description=product.description or "",
s3_path=product.s3_path,
product_id=product.id,
session_factory=request.registry["dbsession_factory"],
cdn_endpoint=request.shop_cdn_endpoint,
)
# Compute MD5 + SHA-256 for every uploaded file.
from ..lib.checksums import compute_checksums_async
compute_checksums_async(
request.shop_uploads_client,
request.shop_bucket_name,
s3_key=f"{product.s3_path}/{file_key}",
file_key=file_key,
product_id=product.id,
session_factory=request.registry["dbsession_factory"],
)
# 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 (
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,
file_key=file_key,
extension=ext,
s3_path=product.s3_path,
karaoke_config=capture_karaoke_config(shop, request.app),
db_url=str(request.dbsession.get_bind().url),
)
# redirect back to this page to clear
# the params posted by the s3 webhooks.
return HTTPFound(f"{product.absolute_edit_url(request)}?uploaded={file_key}")
if product_modified and product.error_message is None:
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
return HTTPFound(product.absolute_edit_url(request))
signed_posts = {}
for file_key in product.file_keys:
key_starts_with = f"{product.s3_path}/{file_key}."
conditions = [
{"success_action_redirect": product.absolute_edit_url(request)},
["starts-with", "$key", key_starts_with],
]
signed_posts[file_key] = request.shop_uploads_client.generate_presigned_post(
Bucket=request.shop_bucket_name,
Key=key_starts_with + "${filename}",
ExpiresIn=900,
Conditions=conditions,
)
# We must create a field key/value for each extra condition.
# unfortunately `conditions` is a list of single key/value dicts
# and/or 2/3 item lists so we flatten them into a single fields dict.
# [{1:2}, [3,4]] -> {1:2, 3:4}
for condition in conditions:
if isinstance(condition, list):
if len(condition) == 2:
# convert list into dict.
condition = {condition[0]: condition[1]}
else:
continue
signed_posts[file_key]["fields"].update(condition)
# Fetch locations and inventory quantities for physical products
locations = []
if product.is_physical:
locations = (
request.dbsession.query(ShopLocation)
.filter_by(shop_id=product.shop_id)
.all()
)
inventories = {
inv.shop_location.id: inv.quantity for inv in product.inventories
}
# Edit page is operator-only — always show price history here so
# the operator can reference what they've set. The shop-wide toggle
# (shop.show_price_history) gates only shopper-facing surfaces:
# the public product view (above) and watch.py JSON. Operators
# need their own price audit trail regardless of the toggle.
price_history = []
if product.is_sellable:
history_rows = product.price_history.limit(20).all()
for i, ph in enumerate(history_rows):
price_history.append({
"price_formatted": f"${cents_to_dollars(ph.price_in_cents):.2f}",
"ago": timestamp_to_ago_string(ph.created_timestamp),
"datetime": str(timestamp_to_datetime(ph.created_timestamp)),
"is_current": i == 0,
})
signed_product_url = None
if product.has_product_file:
try:
params = {
"Bucket": request.shop_bucket_name,
"Key": product.s3_key,
"ResponseContentDisposition": f"inline; filename={product.slug}.{product.extensions.get('product', '')}",
}
content_type = product.get_content_type("product")
if content_type:
params["ResponseContentType"] = content_type
signed_product_url = request.shop_uploads_client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=900,
)
except Exception:
pass
# MPS-20: format auction timestamps for datetime-local input.
def _ms_to_input(ts_ms):
if not ts_ms:
return ""
from datetime import datetime, timezone
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M",
)
return {
"product": product,
"title": title,
"description": description,
"price": price,
"visibility": visibility,
"signed_posts": signed_posts,
"signed_product_url": signed_product_url,
"locations": locations,
"inventories": inventories if product.is_physical else {},
"price_history": price_history,
"auction_start_iso": (
_ms_to_input(product.auction.start_timestamp)
if product.auction else ""
),
"auction_end_iso": (
_ms_to_input(product.auction.end_timestamp)
if product.auction else ""
),
# 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(
request.torrent_enabled
and product.torrent_magnet_link
and product.visibility != 1
),
}
@view_config(route_name="product_tags")
@shop_editor_required()
@trial_active_required()
def product_tags(request):
"""MPS-24: per-product SPA tag add/remove (capability-driven).
The product edit form keeps its no-JS comma-separated `tags` field
(handled inline in ``product_edit``). When JS is enabled,
``static/js/product_tags.js`` turns that field into a chip editor and
POSTs each add/remove here with ``X-Requested-With: XMLHttpRequest``.
We persist immediately and return JSON so the chip mutates in place —
no full page reload. A plain (no-JS) POST still works: flash + 302
back to the edit page.
``action=add`` + ``name`` → get_or_create_tag + attach
``action=remove`` + ``tag_slug`` → detach
"""
from ..models.tag import get_or_create_tag, get_tag_by_shop_and_slug
product = request.product
shop = request.shop
if product is None or shop is None or product.shop_id != shop.id:
if is_ajax(request):
return Response(
json={
"status": "error",
"messages": [["Product not found.", "error"]],
},
status=404,
content_type="application/json",
)
raise HTTPNotFound()
action = (request.params.get("action") or "").strip()
tag = None
changed = False
if action == "add":
name = (request.params.get("name") or "").strip()
tag = get_or_create_tag(request.dbsession, shop, name)
if tag is None:
request.session.flash(("That tag name is invalid.", "error"))
elif tag in product.tags:
# Idempotent: re-adding an applied tag is a no-op, not an error.
request.session.flash(
(f"Tag '{tag.name}' is already applied.", "info")
)
else:
product.tags.append(tag)
changed = True
request.session.flash(
(
f"Tag '{tag.name}' applied to '{product.title}'.",
"success",
)
)
elif action == "remove":
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
if tag is not None and tag in product.tags:
product.tags.remove(tag)
changed = True
request.session.flash(
(
f"Tag '{tag.name}' removed from '{product.title}'.",
"success",
)
)
elif tag is None:
request.session.flash(("That tag does not exist.", "error"))
else:
request.session.flash(("Unknown tag action.", "error"))
# Mirror product_edit: a tag change alters discovery/home grouping, so
# rebuild the shop's discovery ring when watch mode is on.
if changed and product.error_message is None:
request.dbsession.add(product)
request.dbsession.flush()
if product.shop.watch_mode_enabled:
reforge_discovery_ring_async(
product.shop.id, request.registry["dbsession_factory"]
)
if is_ajax(request):
messages = list(request.session.pop_flash())
payload = {
"status": "ok",
"messages": messages,
"product_id": product.uuid_str,
"action": action,
"changed": changed,
}
if tag is not None:
payload["tag"] = {"name": tag.name, "slug": tag.slug}
return Response(json=payload, content_type="application/json")
return HTTPFound(product.absolute_edit_url(request))
@view_config(route_name="product_inventory_edit")
@shop_editor_required()
def product_inventory_edit(request):
product = request.product
if not product.is_physical:
request.session.flash(("This product is not physical.", "error"))
return HTTPFound(get_referer_or_home(request))
# Get all shop locations for the product's shop
shop_locations = request.shop.shop_locations
updated = False
for location in shop_locations:
quantity = request.params.get(f"inventory_{location.id}", None)
if quantity is not None:
try:
quantity = int(quantity)
inventory = next(
(
inv
for inv in product.inventories
if inv.shop_location_id == location.id
),
None,
)
if inventory:
if inventory.quantity != quantity:
inventory.update_quantity(quantity)
request.dbsession.add(inventory)
updated = True
else:
inventory = Inventory(
shop_location=location, product=product, quantity=quantity
)
request.dbsession.add(inventory)
updated = True
except ValueError:
request.session.flash(
(f"Invalid quantity for location {location.name}.", "error")
)
if updated:
request.dbsession.flush()
request.session.flash(("Inventory updated successfully.", "success"))
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="product_bundle_edit")
@view_config(route_name="product_bundle_edit2")
@shop_editor_required()
def product_bundle_edit(request):
from ..models.product import get_products_by_ids
from ..lib.uuid_validation import UUID_ALL_PATTERN
product = request.product
if not product.is_bundle:
request.session.flash(
("You may only add products to a Product bundle.", "error")
)
bundle_data = request.params.get("bundle-data", "").strip()
if bundle_data:
products = get_products_by_ids(
request.dbsession, UUID_ALL_PATTERN.findall(bundle_data)
)
if products is None:
request.session.flash(
("We didn't detect any product ids. Try again.", "error")
)
return HTTPFound(get_referer_or_home(request))
for product_to_bundle in products:
if not product.add_product_to_bundle(product_to_bundle):
request.session.flash((product.error_message, "error"))
product.error_message = None
continue
request.session.flash(
(
f"You added product {product_to_bundle.title} to this bundle!",
"success",
)
)
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="product_remove_bundled_product")
@shop_editor_required()
def product_bundle_remove_product(request):
from ..models.product import get_product_by_id
product = request.product
bundled_product_id = request.matchdict.get("bundled_product_id")
bundled_product = get_product_by_id(request.dbsession, bundled_product_id)
if not product.is_bundle:
request.session.flash(
("You may only remove products from a Product bundle.", "error")
)
if not product.remove_product_from_bundle(bundled_product):
request.session.flash((product.error_message, "error"))
else:
request.session.flash(("You removed a product from this bundle!", "success"))
return HTTPFound(get_referer_or_home(request))