Browsers were caching /static/css/common.css indefinitely (the <link>
had no version param), so CSS fixes didn't show up until a hard
refresh. Fox kept seeing the old squished-button CSS after deploys.
- New request property request.git_hash (reified) returns the short
git hash baked in at deploy time (views.version.GIT_HASH)
- base.j2 tokens.css and common.css links now carry ?v={{ request.git_hash }}
Each deploy bumps GIT_HASH, so the CSS URL changes, so browsers fetch
the fresh file. No more stale-CSS confusion.
Per fox: Add To Cart / Make an offer buttons looked squished (text
crammed at top, little vertical space) while .mps-button on <a>
elements (cart page) looked fine.
Root cause: <button> elements inherit UA-stylesheet line-height and
box-sizing that differ from <a>. The class set padding-top/bottom: 14px
but with the native button line-height the text didn't center properly.
Normalized .mps-button:
- appearance: none (+ -webkit-) — strip native button chrome
- box-sizing: border-box — consistent across <button>/<a>/<input>
- font-family: inherit — buttons default to a different font
- line-height: 1.4 — explicit, was relying on UA "normal"
- padding: 12px 16px (was 14px 0) — symmetric, gives horizontal
breathing room too
- vertical-align: middle
Now <button class="mps-button">, <a class="mps-button">, and
<input type=submit class="mps-button"> all render the same.
Tests pass (9 in pricing_mode slice).
Per fox: "i only see add to cart not offer" — viewing their own
offer-mode (3/4) product as the shop owner showed just Add To Cart
with no sign that offers were active. The Make-an-offer form is
correctly hidden for owners (can't offer on your own product, same
as auctions), but that left owners with no feedback.
Now when product.offers_allowed and the viewer is a shop owner,
the page shows:
✓ Offers enabled — buyers see a "Make an offer" button here.
You can't offer on your own product.
Buyers (non-owner, logged in) still see the expandable offer form.
Anon visitors still see "Log in to make an offer".
Updated test_make_offer_hidden_for_seller: owner now asserts no
Submit Offer button + no "Log in to make an offer" CTA, but DOES
see "Offers enabled" indicator.
Root cause found via prod debug log: the product edit form's
onsubmit="submit.disabled = true" handler disables the submit button
before the browser collects form data, so the "submit" key is never
sent in the POST. The pricing_mode / allow_offers / auction-config
block was gated on `if "submit" in request.params:` — always False —
so it never ran. Hence no flash, no save.
The standard handlers (title, description, price, visibility) don't
have this gate; they check whether the field's value changed. The
pricing_mode block now follows the same pattern: gated on
`if "pricing_mode" in request.params:` (that radio only appears on
the product-title-and-description edit form, so other forms on the
page — uploads, inventory, bundle — don't trigger it).
- pricing_mode: read request.params["pricing_mode"], apply if valid
and changed; immediate flush; flash; auto-create draft auction when
switching into auction mode
- allow_offers: only processed when "allow_offers" in request.params
(so a different form submitting won't reset the override)
- auction config block: unchanged, still inside the pricing_mode gate
- torrent_opt_in: also re-gated on "pricing_mode" in params instead of
"submit" (was equally broken, just masked by the torrent kill switch)
Removed all the temporary debug logging (ENTER line, /opt and /tmp
file writes, journalctl warning).
Tests pass (15 in pricing_mode + auction-config slices).
The /tmp/mps-pricing-debug.log file didn't appear after fox triggered
a save attempt. Two changes to narrow down why:
1. Move the debug log to /opt/make_post_sell/pricing-debug.log (where
uwsgi has owner write access) with /tmp as fallback. PrivateTmp or
permissions might be blocking /tmp writes on this systemd setup.
2. Add an ENTER log line at the top of product_edit() that fires for
every request — GET, POST, save, etc. If this line never appears,
the view itself isn't being reached and the form is going somewhere
else. If it appears but the pricing_mode line doesn't, the submit
field is missing from params.
Layout restructure per fox:
- "Edit Title, Description, or Visibility" rendered first at top of the
edit grid (full width) via CSS order: 1. Useful info (title, price,
pricing mode, visibility) is now the primary focus when landing on
the page.
- "Upload or Replace Preview File" extracted into its own <section
class="upload-preview well2"> sibling. It sits in the right column
of the same row as "Upload or Replace Product File" via the natural
2-col grid flow.
- Visual order: title-and-description → upload-product → upload-preview
→ upload-thumbnails → price-history. Implemented via CSS order
property so HTML structure stays simple.
- Preview section only renders for digital sellable products (not bundle,
not physical) — same guard as before but now an explicit conditional
around the new <section>.
Debug:
- pricing_mode handler now ALSO writes to /tmp/mps-pricing-debug.log
(world-writable) in addition to the journalctl warning. Lets us read
POST params on prod without sudo. Will be removed when bug is fixed.
Tests still pass (9 pricing_mode tests).
Per fox: status data was "in the wrong spot" — it had been rendered at
the top of the content area as a standalone full-width row above the
edit-page grid. Moved into the call_to_action block (top-right of the
page, same area where the permanent link lives) so the at-a-glance
state sits naturally above the form rather than splitting visual flow.
- Pills (visibility / ready / price) and permanent link now render
together in call_to_action
- .edit-status-bar restyled: no background, no border, right-justified
flex row (matches the right-aligned page header area)
- .edit-permanent-link: right-aligned, muted, small
Tests still pass (9 in target slice).
Per fox: thumbnails "not aligned properly" and "not using horizontal
space properly". Two issues:
1) Grid was at minmax(240px, 1fr) which on the full-card-width row
often fit 3 columns instead of 4 (240 * 4 + gaps > available
space). Lowered to minmax(200px, 1fr) so the auto-fit gives 4
columns on a standard desktop edit card.
2) Each upload-thumbnail-item now renders as a sub-card:
- flex-direction: column with consistent gap
- background --surface-dim, --color-border, --radius-md
- preview image fixed 140px height + object-fit: contain so all
thumbnails align even when some slots are empty or have different
aspect ratios
- form pushed to bottom via margin-top: auto, so the upload buttons
line up across cells regardless of how much text is above
- p.bold-text break-word so long filenames don't blow out the cell
- the legacy <hr/> separator between items is hidden inside the grid
(it was a vertical-flow artifact)
The grid uses align-items: stretch so all 4 cells have equal height.
Tests still pass (9 in target slice).
Per fox: the thumbnails card layout was a mess — the h3, descriptor
text, and thumbnail items were all crammed into a single grid with
auto-fit columns, so thumbnail 1+2 sat on the same row as the header
and thumbnails 3+4 wrapped to row 2 leaving the left half empty.
Fix: restructure to a proper header + grid:
<section class="upload-thumbnails well2 edit-card-full">
<div class="upload-thumbnails-header">
<h3>... icon + title ...</h3>
<p class="upload-thumbnails-hint">Your cover ...</p>
</div>
<div class="upload-thumbnails-grid">
<div class="upload-thumbnail-item">...</div>
... (4 thumbnails)
</div>
</section>
CSS:
- .upload-thumbnails-header: flex row, space-between, wraps on narrow
screens; h3 grows, hint right-aligned + max-width 320px
- .upload-thumbnails-grid: auto-fit minmax(240px, 1fr) for the 4
thumbnail slots, gap --space-4
Result: header reads cleanly across the top with the cover descriptor
on the right, and all 4 thumbnails sit in a clean grid below.
Tests still pass (9 in target slice).
Per fox: "🎨 Upload or Replace Thumbnail Files ... should be in a row
that spans two columns" — and the same for "Edit Title, Description,
or Visibility".
Both cards now get class edit-card-full, which sets grid-column: 1 / -1
so they span the entire row in the 2-column edit grid. The thumbnail
grid has up to 4 thumbnails that need room; the edit-details card
has a wide form with pricing-mode radios and description textarea.
New CSS:
div.edit-page > section.edit-card-full { grid-column: 1 / -1; }
Styleguide updated with a live half/half/full example demonstrating
the pattern.
Per fox: "make this look awesome", "use styleguide", "think material
design to make it stand out as to what to do".
Status bar at top of edit page — three pills surface the product's
current state in one glance:
- Visibility: Public (blue) / Unlisted (purple) / Private (gray)
- Ready to sell (green) / Missing required files (gold warning)
- Current price (gray)
The pills tell the owner immediately what's done and what isn't.
Section cards (.well2 on edit page) get Material-card treatment:
- Background, rounded corners, border, elevation-1 shadow
- Hover/focus-within bumps to elevation-3 (subtle lift)
- h3 header gets a 36px circular icon badge (.edit-card-icon),
thin bottom border separating header from content
- Icons: 📁 product file, 📺 preview, 🎨 thumbnails, ✏️ details,
📦 inventory, 📦 bundle
Primary save action: Save Settings button is now a filled,
prominent CTA (navy bg, white text, bold, elevation-1 shadow that
lifts to elevation-2 on hover, disabled state dims). Class
.mps-button-primary on the styleguide for reuse.
.edit-save-bar provides a sticky-bottom save container CSS for a
follow-up wrapper around the Save Settings input (not yet wired
into product_edit.j2 — same hash adds the CSS so it's available).
All wired into the styleguide:
- Status bar + 3 pill states (public ready, private incomplete, etc.)
- Section card with icon header (3 example cards)
- Primary save action button
Tokens used (with fallbacks for safety): --elevation-1/3, --radius-lg,
--radius-pill, --color-navy/navy-dark, --color-blue-tint, --space-N,
--motion-fast, --color-green-dark, --color-gold, --color-purple.
Tests still pass (10 in target slice).
Per fox's feedback on shop.unturf.com edit page:
- File Type and File Size now stack on a single line with · separator
(was 2 stacked <p> tags). Class file-meta on the wrapper, file-meta-item
on each child. Applied to product, preview, thumbnail blocks.
- File Statistics removed as a standalone well; "Total capacity" line
moved into the upload-product well at top, using the same file-meta
pattern. Eliminates the half-empty 4th column in the 2-col grid.
- Thumbnail upload sections now have an <h4 class="thumbnail-label">
showing thumbnail1 / thumbnail2 / etc. so owners can tell which slot
is the cover (thumbnail1 shows on search pages) vs additional images.
- Description label split fix: the label, helper text, and textarea
are now properly stacked. The section.product-title-and-description
rule (display: grid for product page) is overridden to display: block
on the edit page so the form flows normally.
- Helper text under the Description label uses <small class="form-help">
with token-based styling.
- Textarea max-width 600px lifted on the edit page so it fills the
available well width.
Styleguide entries added at /styleguide for: file-meta line, thumbnail
label, form-help.
Tests still pass (10 in target slice).
UI (per fox: "use styleguide to make this edit page way way better"):
- div.edit-page now max-width 1200px, centered, with token-based gap
- wells get larger internal padding (--space-4 + --space-5) so content
isn't crammed against the well edge
- well h3:first-child resets margin-top and uses 1.125rem / 600 weight
- fieldsets inside edit page get tokens-based border, padding, legend
styling that matches the rest of the design system
Debug:
- TEMPORARY log line in product_edit pricing_mode handler logs the raw
POST params for the pricing_mode field plus the submit value. Will
remove once the prod save-revert issue is diagnosed. Fox reports
saving "Make an offer with buy-now" reverts to fixed price; cannot
reproduce locally. Log will show whether the form is sending the
field at all when fox saves.
Total: tests still pass (9 pricing_mode + 15 in target slice).
Two fixes in one commit:
1) defensive: flush pricing_mode change to DB immediately after the
radio writes it, instead of relying on the trailing
product_modified-block flush at end of view. Fox reported saving
"Make an offer with buy-now" reverts to fixed price; could not
reproduce locally. Direct flush eliminates any code path between
the radio change and end-of-view that might raise and abort the
txn before the deferred save.
2) layout: div.edit-page goes from auto-fit minmax(240px, 1fr)
(fits 4+ wells on wide screens) to a strict 2-column max grid:
1 column on mobile, exactly 2 columns above 720px. The 4 wells
on the product edit page now stack 2x2 instead of 4-across.
Tests:
- test_flip_to_offer_with_buy_now_mode_4_persists — direct POST
pricing_mode=4 (Make Offer with Buy Now) persists
- test_flip_from_3_to_4_persists — Offer-only → Offer+buy-now
transition persists
- test_render_form_then_submit_mode_4_via_form — fetches the rendered
edit form via webtest and submits it back with mode=4 selected; the
closest reproduction of fox's browser flow
All three pass locally. The save path is correct in the test harness.
If the prod issue persists after this commit, it's almost certainly
a browser-level issue (form not sending the field), not server-side.
Total: 961 tests pass (was 958 + 3).
Lets a shop owner auction a portion of inventory rather than all of it,
and reach every auction field from the product edit form (today's flow
only flipped pricing_mode and left start/end/reserve/buy-now unreachable).
Schema:
- mps_auction.quantity (Integer, default 1, server_default="1") —
how many units this auction sells. Digital products force 1.
- Idempotent migration 1a419114ddf7 (column_exists guard)
Model:
- MpsAuction.is_lot_auction property (quantity > 1)
Owner-side form (product_edit.j2 + views/product.py):
- All auction config fields editable while state == DRAFT:
quantity (physical only), start/end (datetime-local), start_price,
reserve_price, buy_now_price (modes 2/4 only), bid_increment,
soft_close_seconds
- "Schedule auction" checkbox flips DRAFT → SCHEDULED (or → ACTIVE
if start_timestamp is already past)
- Validates end > start; blocks scheduling when invalid
- Locks all fields once SCHEDULED to preserve bidder trust
(no rules-changes mid-flight)
- views/product.py serializes auction timestamps to YYYY-MM-DDTHH:MM
for the datetime-local input
Cart integration:
- auction_checkout view sets cart.set_product_quantity(product, auction.quantity)
so the cart success path's update_inventory() naturally deducts
the right number of physical units. Cart total still uses the
winning bid amount via auction_offer_override_in_cents — quantity
affects inventory only, not price.
Tests:
- 2 unit (test_models.py): is_lot_auction default False, True for
quantity > 1 (multiple values)
- 7 functional (test_functional.py): set quantity on physical auction,
digital quantity forced to 1, schedule with future start →
SCHEDULED, schedule with passed start → ACTIVE, end-before-start
rejected, locked fields not overwritten after scheduled,
auction_checkout sets cart quantity to lot size
Total: 958 tests pass (was 949 + 9).
Defect: product.j2 always rendered "Add To Cart" at list price for
physical products, even when pricing_mode=1 (auction only) or
pricing_mode=3 (offer only). A buyer browsing a physical auction
could click Add To Cart and pay list price, completely bypassing
the bidding flow.
Fix: wrap the physical Add To Cart / Sold Out branches in
{% if product.is_buy_now_allowed %} so they only render in modes
that permit direct purchase (0 fixed, 2 auction+buy_now, 4 offer+
buy_now). Modes 1 (auction-only) and 3 (offer-only) hide both the
Add To Cart button and the Sold Out indicator entirely; buyers must
use the View Auction or Make Offer entry points.
Tests:
- test_digital_fixed_price_shows_add_to_cart
- test_digital_auction_only_hides_add_to_cart
- test_digital_auction_with_buy_now_shows_add_to_cart
- test_digital_offer_only_hides_add_to_cart
- test_physical_auction_only_hides_add_to_cart (the regression fix)
- test_physical_offer_only_hides_add_to_cart (same)
Total: 949 tests pass (was 943 + 6).
static/js/auction.js (progressive enhancement; page works without JS):
- Live countdown clock that ticks every 1s using data-end-timestamp
attribute pre-rendered by auction.j2
- /a/{id}.json poll every 5s for state changes (current high,
end_timestamp updates from soft-close, terminal state)
- AJAX bid form submit with X-Requested-With header; updates UI
without full page reload, shows flash with "winning" or "outbid by
proxy" message; on terminal state disables bid + buy-now buttons
static/css/common.css — token-only auction & offer components:
- .auction-stats grid + .auction-stat label/value
- .auction-state-* color per state (Draft/Scheduled/Active/Ended/
Settled/Cancelled)
- .offer-state-* color per state (Pending/Accepted/Countered/
Declined/Expired/Withdrawn/Paid)
- .auction-bid-form grid layout
- .auction-flash success/error banner
- .offer-events / .offer-event audit timeline
styleguide.j2 entries (live previewable at /styleguide):
- Auction state badges (all 6 states)
- Auction countdown stat block
- Bid form (with min and proxy ceiling)
- Offer state badges (all 7 states)
- Make Offer entry-point (collapsed details)
Functional tests:
- /styleguide renders the new components
- /static/js/auction.js is served (200, contains auction-page hook)
- auction.j2 references /static/js/auction.js
Total: 943 tests pass (was 940 + 3).
Adds three email types and wires them into the bid + offer flows:
- AUCTION_OUTBID — sent to the previous high bidder when their bid
is beaten (auction_bid view, after place_bid succeeds and is_winning
flips to a new bidder)
- OFFER_RECEIVED — sent to all shop owners when a new pending offer
arrives (offer_open view, only when offer queues; auto-accept and
auto-decline use different paths)
- OFFER_ACCEPTED — sent to the buyer when the seller (or buyer
themselves) accepts the current amount (open_offer auto-accept lane;
offer_accept view explicit accept)
Email failures are caught and logged; the bid/offer state is already
persisted by the time we attempt to send, so a misconfigured SMTP
relay does not break the user flow.
Tick-driven emails (auction won when tick.tick ends an auction; offer
expired when tick.tick expires an offer) are deferred — they require
a request context for URL building, and tick scripts run from cron
without one. A future commit can either wire a request-less email
sender or queue the events for the next view that runs.
Templates added to lib/mail_messages.py:
- AUCTION_OUTBID_TEXT / AUCTION_OUTBID_HTML
- OFFER_RECEIVED_TEXT / OFFER_RECEIVED_HTML
- OFFER_ACCEPTED_TEXT / OFFER_ACCEPTED_HTML
Send helpers in lib/mail.py:
- send_auction_outbid_email(request, to, auction)
- send_offer_received_email(request, to, offer)
- send_offer_accepted_email(request, to, offer)
Tests:
- 3 unit (test_models.py) — verify each helper builds the right
subject/body/url with mocked send_pyramid_email
- 1 functional — POST /o/{id}/accept calls send_offer_accepted_email
with the buyer's email
Total: 940 tests pass (was 936 + 4).
Without these scripts, auctions stay ACTIVE forever and offers never
expire. Cron runs them on a schedule to drive the state machines.
lib/auction_tick.py:
- Pure functions transition_scheduled_to_active and transition_active_to_ended
- tick(dbsession) orchestrator scans:
- SCHEDULED + start_timestamp <= now → ACTIVE
- ACTIVE + end_timestamp <= now → ENDED (winner from is_winning bid;
payment_deadline_timestamp set to end + 48h)
lib/offer_tick.py:
- tick(dbsession) scans non-terminal offers (PENDING / COUNTERED) past
expires_timestamp and calls lib/offer.expire_offer on each, which
flips state to EXPIRED and writes the OFFER_EVENT_EXPIRE audit row
Both ticks are idempotent — running twice on already-transitioned rows
is a no-op.
Cron entry points:
- scripts/auction_tick.py — every minute (* * * * *) recommended;
60s default soft-close window means 1-min granularity is fine
- scripts/offer_tick.py — every 15 minutes; offers expire at hour
granularity so coarse polling is enough
Tests:
- 2 unit (test_models.py): pure transition_scheduled_to_active /
transition_active_to_ended for state, timestamp, and missing-data branches
- 4 integration auction: SCHEDULED→ACTIVE on start passing, ACTIVE→
ENDED with winner recorded, ACTIVE→ENDED with no bids leaves winner
None, idempotent
- 4 integration offer: PENDING past expires → EXPIRED, live offer not
touched, terminal offer not touched, idempotent
Total: 936 tests pass (was 926 + 10).
Closes the payment loop for both auction and offer modes. After this
commit, an auction winner can pay the agreed bid amount; an offer-
accepted buyer can pay the agreed offer amount. Cart total computation
uses the auction's winning_bid or offer.current_amount when an
association is present.
New tables (idempotent migration):
- mps_cart_auction (cart_id ↔ auction_id) — cart total uses winning bid
- mps_cart_offer (cart_id ↔ offer_id) — cart total uses agreed amount
Cart.auction_offer_override_in_cents property returns the override
amount when either association is set; None otherwise. total_price_in_cents
short-circuits to the override + handling + gift-card-purchases when an
override is present.
Routes:
- POST /a/{auction_id}/checkout winner only; auction must be ENDED;
builds a fresh cart, adds product, creates MpsCartAuction
- POST /o/{offer_id}/checkout buyer only; offer must be ACCEPTED;
builds a fresh cart, adds product, creates MpsCartOffer
Both checkouts redirect to /cart on success or back to /a/{id} or
/o/{id} on rejection (with flash message).
Settle hook in views/cart.py: after invoice is paid, _finalize_auction_offer_state
flips auction.state=SETTLED (recording winner_user_id + winning_bid_id)
and calls lib/offer.mark_paid which transitions offer ACCEPTED → PAID
and writes the OFFER_EVENT_PAY audit row.
Tests:
- 4 integration: cart-auction + cart-offer associations persist + cascade
- 3 integration: cart total override (no association → list price;
with auction → winning bid; with offer → agreed amount)
- 3 functional auction checkout: winner builds cart with override,
non-winner blocked, active auction blocked
- 3 functional offer checkout: buyer builds cart with override,
non-buyer blocked, pending offer blocked
Total: 926 tests pass (was 913 + 13).
Shop owner-facing:
- shop_settings.j2: new "Make an Offer" form section (form_section=offer-settings)
with offer_enabled, auto_accept_threshold_pct, auto_decline_threshold_pct,
offer_min, offer_expiration_hours, offer_max_rounds,
offer_min_buyer_account_age_hours
- views/shop.py: form_section=offer-settings handler with input clamping
(decline forced strictly below accept; numeric inputs bounded)
- product_edit.j2: pricing_mode radio (5 options: fixed, auction, auction+
buy_now, offer, offer+buy_now), allow_offers radio (inherit/yes/no)
appearing only when product is in offer mode, link to live auction
page when one exists
- views/product.py: handles pricing_mode change; flipping into auction
mode (1 or 2) creates a draft MpsAuction with start_price seeded
from product.price_in_cents and default bid_increment / soft_close
from lib/auction defaults
Buyer-facing:
- product.j2: "View live auction" link when pricing_mode is auction;
"Make an offer" details/form when offers_allowed AND user authenticated
AND user not in shop owners; "Log in to make an offer" CTA for anon;
Add To Cart only renders when is_buy_now_allowed (modes 0, 2, 4)
Tests (9 new, all passing):
- TestOfferSettingsForm: enable+set thresholds (round-trip), decline
clamped below accept, blank offer_min clears the floor
- TestPricingModeFormSection: flip to auction creates draft auction,
flip to offer leaves auction None, allow_offers override (yes/no/
inherit), invalid pricing_mode value ignored, Make Offer button
renders when eligible, Make Offer hidden for shop owner
Total: 913 tests pass (was 904 + 9).
Routes (registered before shop_slug-style catch-alls so .json suffix is
not shadowed):
- GET /a/{auction_id}.json live state for poll
- POST /a/{auction_id}/bid place a bid (login required)
- POST /a/{auction_id}/buy-now end auction at buy_now price (mode 2)
- POST /a/{auction_id}/watch toggle watcher
- GET /a/{auction_id} live page (templates/auction.j2)
views/auction.py:
- _serialize_auction shapes the same dict for both template ctx and JSON
- _user_is_seller checks request.user against auction.shop.owners — pure
validate_bid in lib/auction does not have visibility into ownership,
so the view enforces "no self-bidding" with HTTP 403
- auction_bid parses dollar input, converts to cents, calls place_bid,
returns either {ok, bid_amount, is_winning, auction_state} or {error}
- auction_buy_now places a bid at buy_now price, sets state to ENDED,
records winner_user_id and winning_bid_id
- auction_watch toggles MpsAuctionWatcher row
templates/auction.j2:
- Live page extending base.j2 with countdown placeholder, current high,
bid form (amount + optional max_proxy), buy-now form when configured,
watch toggle
- Hides bid form when seller views own auction or anon visitor (with
log-in CTA)
Functional tests (11): page renders for anon, 404 unknown id, JSON
state, seller cannot bid (403), anon cannot bid (redirect), buyer
places first bid, bid below increment rejected (400), invalid amount
rejected (400), watch toggle round-trip, buy-now 404 when not offered,
buy-now ends auction and records winner.
Cart integration deferred to commit 5 (bundling with offer cart
integration since both add a non-list-price line item pattern).
Total: 892 tests pass (was 881 + 11).