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.
1044 lines
41 KiB
Python
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))
|