MPS-21: public profile page, offer-history identity, shop offers inbox, actions hub rebuild

- Offer history & offer page show the buyer's display name (User.display_name
  = the public `name` handle; `full_name` is private) linked to a profile
  page — never the email. _serialize_offer drops buyer_email; events carry
  actor_name/actor_handle/actor_id, header carries buyer_name/buyer_handle.
- New public profile page: GET /profile/{handle} (views/user.py:user_profile,
  template profile.j2). Shows gravatar (User.gravatar_url(size) — forced
  identicon unless the user opted into Gravatar), member-since, owned/edited
  shops, and a <details> "Show email" that is server-gated: only the user
  themselves, or a shop owner/editor viewing in that shop's context
  (?shop={shop_id}) when the profile user has transacted there (an offer or
  an invoice).
- New operator offers inbox: GET /s/{shop_id}/offers (@shop_editor_required,
  shop_offers.j2) — open offers first, each row links to /o/{id} and the
  buyer's profile. Reachable from /actions/view via a new "Offers" button
  (shown when shop.offer_enabled).
- /actions/view rebuilt: one flat .action-button-grid (Grid auto-fit,
  minmax(15rem,1fr)) inside a properly-padded .action-columns well — fixes
  the off-balance two-column layout and buttons overflowing the well; no
  <br> spacers. Styleguide gains profile-card and action-button-grid
  patterns.
- offer.j2: buyer name shown (linked to profile); "Buyer:"/"Seller:" message
  lines renamed "Buyer note:"/"Seller note:" to disambiguate.

Tests: 11 new functional tests (profile render + email gating, offers inbox,
actions button, styleguide). 989 passed.
This commit is contained in:
russell@unturf.com 2026-05-12 17:47:24 -04:00
parent cb61227b2c
commit e97d18bccd
No known key found for this signature in database
14 changed files with 654 additions and 68 deletions

View file

@ -452,10 +452,34 @@ Form sections:
Routes (registered before `product_slug` / `shop_slug` catch-alls):
- `/a/{auction_id}` + `/a/{id}.json` + `/a/{id}/{bid,buy-now,watch,checkout}`
- `/p/{product_id}/offer` (open) + `/o/{offer_id}` + `/o/{id}/{counter,accept,decline,withdraw,checkout}`
- `/s/{shop_id}/offers` — operator inbox (`@shop_editor_required`), linked from `/actions/view`
Offer/auction POST routes are **capability-driven**: a plain browser
submit gets a flash + `302` redirect; an AJAX submit (`X-Requested-With:
XMLHttpRequest`) gets JSON. `static/js/offer.js` + `auction.js` are the
enhancement layers. `offer.j2` shows a `.offer-state-notice` banner so
the state is clear without a flash.
Identity/privacy: never render a user's email in offer/auction UI. Show
`User.display_name` (= the public `name` handle; **`full_name` is
private**) linked to `/profile/{handle}`. The public profile page
(`views/user.py:user_profile`, route `user_profile``/profile/{name}`)
reveals the email only to the user themselves, or to a shop owner/editor
viewing in that shop's context (`?shop={shop_id}`) when the profile user
has transacted there. `User.gravatar_url(size)` forces an identicon
unless the user opted into Gravatar (`user.gravatar`).
See `docs/auction-house.md` and `docs/make-offer.md` for full state
machines and architecture.
### Actions hub (`/actions/view`)
`actions_view.j2` is one flat `.action-button-grid` (Grid `auto-fit`,
`minmax(15rem, 1fr)`) of `.mps-button` links inside an `.action-columns`
well — no `<br>` spacers, no fixed two-column split. Add new operator
shortcuts as another `<a class="mps-button product-edit-button">` in that
grid; it balances and wraps on its own.
## Feature Kill Switches (MPS-22)
Global feature flags live in `data/development.ini` (and override via env var

View file

@ -232,7 +232,7 @@ All components are documented with live examples at `/styleguide`. The styleguid
| Wells | `#wells` | Content wells and containers |
| Alerts | `#alerts` | Success, info, warning, danger alerts |
| Status | `#status` | Status indicators |
| Product Cards | `#cards` | Product grid cards |
| Product Cards | `#cards` | Product grid cards, profile card (`.profile-card-header` / `.profile-avatar` / `.profile-handle` / `.profile-email-reveal`), action button grid (`.action-columns` / `.action-button-grid`) |
| Cart | `#cart` | Cart and checkout components |
| Gift Cards | `#gift-cards` | Gift card purchase, balance check, management |
| Comments | `#comments` | Comment form and list |

View file

@ -111,11 +111,34 @@ POST /o/{offer_id}/accept accept current amount (terminal)
POST /o/{offer_id}/decline decline current amount (terminal)
POST /o/{offer_id}/withdraw buyer-only terminal pull
POST /o/{offer_id}/checkout buyer pays accepted offer
GET /s/{shop_id}/offers operator inbox of all offers for the shop
```
`offer_open` is registered before the `product_slug` catch-all so
`/p/{id}/offer` is not shadowed.
### Operator inbox (`/s/{shop_id}/offers`)
`views/offer.py:shop_offers` (`@shop_editor_required`) lists every offer
for the shop — open (pending/countered) first, sorted by last action, then
terminal offers — in `shop_offers.j2`. Each row links to `/o/{id}` and to
the buyer's profile (`/profile/{handle}?shop={shop_id}`). Reachable from
`/actions/view` via the "🤝 Offers" button (shown when `shop.offer_enabled`).
Incoming offers still email the shop owners (`send_offer_received_email`);
this inbox is the in-app counterpart.
### Identity / privacy
Offer history and the offer page show the buyer's **display name**
(`User.display_name`, which is the public `name` handle — `full_name` is
private) linked to `/profile/{handle}`, never the email. The profile page
reveals the email only to the user themselves, or to a shop owner/editor
viewing in that shop's context (`?shop={shop_id}`) when the profile user
has actually transacted there (an offer or an invoice) — see
`views/user.py:user_profile`. `_serialize_offer` carries `buyer_name` /
`buyer_handle` and per-event `actor_name` / `actor_handle` / `actor_id`
(no email).
### Capability-driven presentation
Every POST route works as a plain browser form submit: the server flashes

View file

@ -1,3 +1,4 @@
import hashlib
import uuid
import bcrypt
@ -196,6 +197,31 @@ class User(RBase, Base):
and self.s3_access_key and self.s3_secret_key
)
@property
def display_name(self):
"""Public-facing name — always the user-chosen handle (``name``).
``full_name`` is *private* (collected for billing/shipping), so it
must never be surfaced here. We expose ``display_name`` in public
contexts (offer history, profile page, emails to the user) instead
of the email address."""
return self.name
def gravatar_url(self, size=80):
"""Deterministic avatar URL.
Always derived from the md5 of the (lowercased) email the hash
is one-way, so this does not disclose the address. When the user
has NOT opted into Gravatar we force the generated identicon
(``f=y``) so their real photo is never surfaced; opted-in users
get their actual Gravatar with the identicon as the fallback."""
email = (self.email or "").strip().lower()
digest = hashlib.md5(email.encode("utf-8")).hexdigest()
url = f"https://www.gravatar.com/avatar/{digest}?d=identicon&s={int(size)}"
if not self.gravatar:
url += "&f=y"
return url
def set_active_shop(self, shop):
self.active_shop_id = shop.id
self.dbsession.add(self)

View file

@ -134,6 +134,7 @@ def includeme(config):
config.add_route("shop_products", "/s/{shop_id}/products")
config.add_route("shop_sales", "/s/{shop_id}/sales")
config.add_route("shop_offers", "/s/{shop_id}/offers")
config.add_route("shop_comments", "/s/{shop_id}/comments")
config.add_route("shop_analytics", "/s/{shop_id}/analytics")
config.add_route("product_analytics", "/s/{shop_id}/analytics/{product_id}")
@ -256,3 +257,9 @@ def includeme(config):
config.add_route("offer_withdraw", "/o/{offer_id}/withdraw")
config.add_route("offer_checkout", "/o/{offer_id}/checkout")
config.add_route("offer_page", "/o/{offer_id}")
# Public user profile. Lives under /profile/{name} (not /u/{name}) so it
# can never shadow the many specific /u/... routes. The email address is
# only revealed to a shop owner/editor viewing in that shop's context
# (?shop={shop_id}) when the profile user has actually transacted there.
config.add_route("user_profile", "/profile/{user_name}")

View file

@ -1684,6 +1684,64 @@ div.edit-page > section.edit-card-full {
}
.settings-form-actions .mps-submit { grid-column: 2; }
/* Public user profile (profile.j2). Grid only. */
.profile-page { display: grid; gap: var(--space-4, 16px); }
.profile-card-header {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4, 16px);
align-items: center;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: var(--radius-md, 8px);
background: var(--surface-dim, #f9f9fa);
}
.profile-identity h1 { margin: 0; }
.profile-handle,
.profile-meta {
margin: var(--space-1, 4px) 0 0;
color: var(--text-muted, #777);
font-size: var(--text-sm, 0.875rem);
}
.profile-email-reveal { margin-top: var(--space-3, 12px); }
.profile-email-reveal summary {
cursor: pointer;
font-weight: 600;
}
.profile-email { margin: var(--space-2, 8px) 0 0; }
.profile-shop-list {
margin: var(--space-2, 8px) 0 0;
padding-left: var(--space-4, 16px);
display: grid;
gap: var(--space-1, 4px);
}
/* Shop offers inbox (shop_offers.j2). */
.shop-offers-table {
width: 100%;
border-collapse: collapse;
}
.shop-offers-table th,
.shop-offers-table td {
text-align: left;
padding: var(--space-2, 8px) var(--space-3, 12px);
border-bottom: 1px solid var(--input-border, #e0e0e0);
vertical-align: middle;
}
.shop-offers-table tr.offer-row-needs-action {
background: var(--alert-info-bg, #dce8ff);
}
.shop-offers-table tr.offer-row-terminal { opacity: 0.7; }
.offer-row-flag {
margin-left: var(--space-2, 8px);
font-size: var(--text-xs, 0.75rem);
font-weight: bold;
text-transform: uppercase;
color: var(--color-primary, #5871ad);
}
/* Render order on the edit page (CSS order property reorders without
changing HTML source order):
1. Edit Title, Description, or Visibility (full width, top)
@ -1967,21 +2025,22 @@ section.checkout-page .well {
background-image: url("/static/img/trans-green.png");
}
/* Action columns - true 50/50 equal columns */
/* Action hub (actions_view.j2): one flat, balanced grid of buttons that
wraps responsively. Track min-width (15rem) is comfortably wider than
the widest button label so nowrap buttons never overflow the well.
Grid only no flex. */
.action-columns {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
max-width: 1100px;
margin: 0 auto;
padding: var(--space-5, 20px);
}
@media (min-width: 960px) {
.action-columns {
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.action-button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--space-3, 12px);
align-content: start;
}

View file

@ -2,66 +2,36 @@
{% block content -%}
{% if request.shop %}
<section class="action-columns well">
<div class="action-button-grid">
<div class="actions-left">
{% if request.shop %}
<a href="/s/{{ request.shop.id }}/{{ request.shop.slug }}" class="mps-button product-edit-button">&nbsp View Shop</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/products" class="mps-button product-edit-button">&nbsp View Products</a>
<a href="/s/{{ request.shop.id }}/{{ request.shop.slug }}" class="mps-button product-edit-button">View Shop</a>
<a href="/s/{{ request.shop.id }}/products" class="mps-button product-edit-button">View Products</a>
{% if request.user in request.shop.owners %}
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">&nbsp View Coupons</a>
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">View Coupons</a>
{% if request.shop.gift_card_enabled %}
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">&nbsp Gift Cards</a>
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">🎁&nbsp; Gift Cards</a>
{% endif %}
{% if request.shop.offer_enabled %}
<a href="/s/{{ request.shop.id }}/offers" class="mps-button product-edit-button">🤝&nbsp; Offers</a>
{% endif %}
<a href="/s/{{ request.shop.id }}/users" class="mps-button product-edit-button">👤&nbsp; Shop Users</a>
<a href="/s/{{ request.shop.id }}/sales" class="mps-button product-edit-button">💰&nbsp; Shop Sales</a>
<a href="/s/{{ request.shop.id }}/comments" class="mps-button product-edit-button">💬&nbsp; Shop Comments</a>
<a href="/s/{{ request.shop.id }}/analytics" class="mps-button product-edit-button">📊&nbsp; Shop Analytics</a>
<a href="/s/{{ request.shop.id }}/settings" class="mps-button product-edit-button">⚙&nbsp; Shop Settings</a>
{% endif %}
<a href="/s/locations" class="mps-button product-edit-button">🏠&nbsp; Shop Locations</a>
{% if request.is_saas_domain %}
<br/>
<br/>
<a href="/u/shops" class="mps-button product-edit-button">&#9974; &nbsp; Switch Shop</a>
<a href="/u/shops" class="mps-button product-edit-button">⛭&nbsp; Switch Shop</a>
{% endif %}
{% endif %}
</div>
<div class="actions-right">
{% if request.shop and request.user in request.shop.owners %}
<a href="/s/{{ request.shop.id }}/users" class="mps-button product-edit-button">👤 &nbsp Shop Users</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/sales" class="mps-button product-edit-button">💰 &nbsp Shop Sales</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/comments" class="mps-button product-edit-button">💬 &nbsp Shop Comments</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/analytics" class="mps-button product-edit-button">📊 &nbsp Shop Analytics</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/settings" class="mps-button product-edit-button">⚙ &nbsp Shop Settings</a>
<br/>
<br/>
{% endif %}
{% if request.shop %}
<a href="/s/locations" class="mps-button product-edit-button">🏠 &nbsp Shop Locations</a>
{% endif %}
</div>
</section>
{% endif %}
{%- endblock -%}

View file

@ -17,11 +17,12 @@
&middot;
Round {{ round_count }}
</p>
<p><strong>Buyer:</strong> <a href="/profile/{{ buyer_handle }}{% if shop_id %}?shop={{ shop_id }}{% endif %}">{{ buyer_name }}</a></p>
{% if buyer_message %}
<p><em>Buyer:</em> {{ buyer_message }}</p>
<p><em>Buyer note:</em> {{ buyer_message }}</p>
{% endif %}
{% if seller_message %}
<p><em>Seller:</em> {{ seller_message }}</p>
<p><em>Seller note:</em> {{ seller_message }}</p>
{% endif %}
</div>
@ -105,7 +106,7 @@
{% for e in events %}
<li class="offer-event">
<strong>{{ e.event_human }}</strong>
{% if e.actor_email %} by {{ e.actor_email }}{% else %} (system){% endif %}
{% if e.actor_id %} by <a href="/profile/{{ e.actor_handle }}{% if shop_id %}?shop={{ shop_id }}{% endif %}">{{ e.actor_name }}</a>{% else %} <span class="offer-event-system">(system)</span>{% endif %}
{% if e.amount is not none %} &middot; ${{ "%.2f"|format(e.amount) }}{% endif %}
{% if e.message %} &mdash; <em>{{ e.message }}</em>{% endif %}
</li>

View file

@ -0,0 +1,58 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>{{ display_name }} &mdash; profile</title>
{%- endblock %}
{% block content %}
<section class="one-column profile-page">
<div class="profile-card well">
<div class="profile-card-header">
<img class="profile-avatar" src="{{ gravatar_url }}" alt="" width="80" height="80" loading="lazy" />
<div class="profile-identity">
<h1 class="type-title">{{ display_name }}</h1>
<p class="profile-handle">@{{ handle }}</p>
{% if member_since_human %}<p class="profile-meta">Member since {{ member_since_human }}</p>{% endif %}
<p class="profile-meta">{{ shop_count }} shop{{ '' if shop_count == 1 else 's' }}</p>
</div>
</div>
{% if email %}
{# Email is never shown by default — the viewer must opt to reveal it.
<details> works with no JS. The server only sends `email` when the
viewer is allowed to see it (self, or a shop operator viewing in
that shop's context with prior transaction history). #}
<details class="profile-email-reveal">
<summary>Show email address</summary>
<p class="profile-email"><a href="mailto:{{ email }}">{{ email }}</a></p>
{% if not is_self and shop_context %}
<p class="profile-meta">Visible to you as an operator of {{ shop_context.name }}.</p>
{% endif %}
</details>
{% elif shop_context and viewer_is_shop_editor and not profile_has_shop_history %}
<p class="profile-meta">This person hasn't transacted with {{ shop_context.name }} &mdash; their email isn't available here.</p>
{% endif %}
{% if shop_context and viewer_is_shop_editor and profile_has_shop_history %}
<p class="profile-meta">
With {{ shop_context.name }}:
{{ shop_offer_count }} offer{{ '' if shop_offer_count == 1 else 's' }},
{{ shop_invoice_count }} purchase{{ '' if shop_invoice_count == 1 else 's' }}.
</p>
{% endif %}
</div>
{% if public_shops %}
<div class="profile-shops well">
<h2 class="type-title">Shops</h2>
<ul class="profile-shop-list">
{% for s in public_shops %}
<li><a href="/s/{{ s.id }}/{{ s.slug }}">{{ s.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>Offers &mdash; {{ shop.name }}</title>
{%- endblock %}
{% block content %}
<section class="one-column shop-offers-page">
<div class="well">
<h1 class="type-title">Offers for {{ shop.name }}</h1>
<p class="profile-meta">
{% if open_count %}{{ open_count }} open{% if open_count != offers|length %} of {{ offers|length }} total{% endif %}.{% else %}No open offers.{% endif %}
</p>
</div>
{% if offers %}
<div class="well">
<table class="shop-offers-table">
<thead>
<tr>
<th>Product</th>
<th>Buyer</th>
<th>Amount</th>
<th>Round</th>
<th>State</th>
<th>Last action</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in offers %}
<tr class="{% if o.waiting_on_seller %}offer-row-needs-action{% elif o.is_terminal %}offer-row-terminal{% endif %}">
<td>{{ o.product_title }}</td>
<td>{% if o.buyer_handle %}<a href="/profile/{{ o.buyer_handle }}?shop={{ shop.id }}">{{ o.buyer_name }}</a>{% else %}{{ o.buyer_name }}{% endif %}</td>
<td>${{ "%.2f"|format(o.current_amount) }}</td>
<td>{{ o.round_count }}</td>
<td>
<span class="offer-state-badge offer-state-{{ o.state }}">{{ o.state_human }}</span>
{% if o.waiting_on_seller %}<span class="offer-row-flag">your turn</span>{% endif %}
</td>
<td>{{ o.last_action_human }}</td>
<td><a href="/o/{{ o.id }}" class="mps-button-small">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="well">
<p>No one has made an offer on a product in this shop yet. When they do, the negotiation shows up here &mdash; and you get an email.</p>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -814,6 +814,59 @@ Dark mode overrides via --notice-*-bg and --notice-*-border tokens.</div>
<div class="sg-code">.serp — auto-fit grid, minmax(160px, 1fr)
.serp-item — hover: brightness change, transition 700ms
.serp-thumbnail — border-radius: 4px, width: 100%</div>
<div class="sg-subsection">
<div class="sg-label">Profile card (profile.j2)</div>
<section class="profile-page" style="max-width: 480px;">
<div class="profile-card well">
<div class="profile-card-header">
<img class="profile-avatar" src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=identicon&s=160&f=y" alt="" width="80" height="80" />
<div class="profile-identity">
<h1 class="type-title">jane-doe</h1>
<p class="profile-handle">@jane-doe</p>
<p class="profile-meta">Member since May 2026</p>
<p class="profile-meta">2 shops</p>
</div>
</div>
<details class="profile-email-reveal">
<summary>Show email address</summary>
<p class="profile-email"><a href="#">jane@example.com</a></p>
<p class="profile-meta">Visible to you as an operator of Example Shop.</p>
</details>
</div>
<div class="profile-shops well">
<h2 class="type-title">Shops</h2>
<ul class="profile-shop-list"><li><a href="#">Example Shop</a></li><li><a href="#">Side Project</a></li></ul>
</div>
</section>
<div class="sg-code">.profile-page — grid, gap var(--space-4)
.profile-card-header — auto 1fr; avatar + identity
.profile-avatar — 80×80, var(--radius-md)
.profile-handle / .profile-meta — var(--text-muted), var(--text-sm)
.profile-email-reveal — &lt;details&gt;; email never shown by default (works no-JS)
.profile-shop-list — grid list, var(--space-1) gap
Email is server-gated: only sent when viewer is self, or a shop operator
viewing in that shop's context with prior transaction history.</div>
</div>
<div class="sg-subsection">
<div class="sg-label">Action button grid (actions_view.j2)</div>
<section class="action-columns well" style="max-width: 720px;">
<div class="action-button-grid">
<a href="#" class="mps-button product-edit-button">View Shop</a>
<a href="#" class="mps-button product-edit-button">View Products</a>
<a href="#" class="mps-button product-edit-button">🤝&nbsp; Offers</a>
<a href="#" class="mps-button product-edit-button">💰&nbsp; Shop Sales</a>
<a href="#" class="mps-button product-edit-button">💬&nbsp; Shop Comments</a>
<a href="#" class="mps-button product-edit-button">⚙&nbsp; Shop Settings</a>
</div>
</section>
<div class="sg-code">.action-columns — well wrapper, max-width 1100px, centered, padded
.action-button-grid — grid, repeat(auto-fit, minmax(15rem, 1fr)), gap var(--space-3)
One flat grid of .mps-button links — balances and wraps responsively.
15rem track is wider than the widest label so nowrap buttons never
overflow the well. Grid only — no flex, no &lt;br&gt; spacers.</div>
</div>
</div>

View file

@ -6055,6 +6055,157 @@ class TestSettingsFormStyleguide(_AuthenticatedBase):
self.assertNotIn("silently rejected", body)
class TestUserProfile(_AuthenticatedBase):
"""Public /profile/{handle} page + email-reveal gating."""
AJAX = {"X-Requested-With": "XMLHttpRequest"}
def _make_offer_on_user1_shop(self, list_price=10000, amount="70.00"):
"""user2 makes a pending offer on a product in user1's shop.
Returns (shop_id, product_id, offer_id). Leaves user2 logged in."""
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="Negotiable", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
shop_id = shop.uuid_str
transaction.commit()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer", {"amount": amount},
headers=self.AJAX, status=200,
)
return shop_id, product_id, res.json["offer_id"]
def test_profile_page_renders(self):
handle = self.user2.name
body = self.testapp.get(f"/profile/{handle}", status=200).body.decode()
self.assertIn(handle, body)
self.assertIn("profile-card-header", body)
# No email exposed to an anonymous viewer.
self.assertNotIn("profile-email-reveal", body)
self.assertNotIn("test2@example.com", body)
def test_profile_page_404_unknown(self):
self.testapp.get("/profile/no-such-user-xyz", status=404)
def test_profile_email_shown_to_self(self):
self.log_in_user(self.user2_creds)
body = self.testapp.get(
f"/profile/{self.user2.name}", status=200
).body.decode()
self.assertIn("profile-email-reveal", body)
self.assertIn("test2@example.com", body)
def test_profile_email_shown_to_shop_operator_with_history(self):
handle2 = self.user2.name
shop_id, _pid, _oid = self._make_offer_on_user1_shop()
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
body = self.testapp.get(
f"/profile/{handle2}?shop={shop_id}", status=200
).body.decode()
self.assertIn("test2@example.com", body)
# ...but not without the shop context.
body2 = self.testapp.get(
f"/profile/{handle2}", status=200
).body.decode()
self.assertNotIn("test2@example.com", body2)
def test_profile_email_hidden_from_operator_without_history(self):
handle2 = self.user2.name
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop_id = shop.uuid_str
transaction.commit()
body = self.testapp.get(
f"/profile/{handle2}?shop={shop_id}", status=200
).body.decode()
self.assertNotIn("test2@example.com", body)
self.assertIn("hasn't transacted", body)
def test_offer_page_links_buyer_to_profile_not_email(self):
handle2 = self.user2.name
_shop_id, _pid, offer_id = self._make_offer_on_user1_shop()
body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode()
self.assertIn(f"/profile/{handle2}", body)
self.assertNotIn("test2@example.com", body)
class TestShopOffersInbox(_AuthenticatedBase):
"""MPS-21: /s/{shop_id}/offers operator inbox + actions-page button."""
AJAX = {"X-Requested-With": "XMLHttpRequest"}
def _shop_with_offer(self, make_offer=True):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="Inbox Item", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
shop_id = shop.uuid_str
product_id = product.uuid_str
transaction.commit()
offer_id = None
if make_offer:
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer", {"amount": "70.00"},
headers=self.AJAX, status=200,
)
offer_id = res.json["offer_id"]
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
return shop_id, offer_id
def test_offers_inbox_empty(self):
shop_id, _ = self._shop_with_offer(make_offer=False)
body = self.testapp.get(f"/s/{shop_id}/offers", status=200).body.decode()
self.assertIn("No one has made an offer", body)
def test_offers_inbox_lists_offer(self):
handle2 = self.user2.name
shop_id, offer_id = self._shop_with_offer()
body = self.testapp.get(f"/s/{shop_id}/offers", status=200).body.decode()
self.assertIn("Inbox Item", body)
self.assertIn(handle2, body)
self.assertIn(f"/o/{offer_id}", body)
def test_offers_inbox_requires_editor(self):
shop_id, _ = self._shop_with_offer(make_offer=False)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds) # not an editor of user1's shop
res = self.testapp.get(f"/s/{shop_id}/offers", expect_errors=True)
self.assertIn(res.status_int, (302, 303, 401, 403, 404))
def test_actions_page_has_offers_button(self):
shop_id, _ = self._shop_with_offer(make_offer=False)
body = self.testapp.get("/actions/view", status=200).body.decode()
self.assertIn("action-button-grid", body)
self.assertIn(f"/s/{shop_id}/offers", body)
class TestProfileStyleguide(_AuthenticatedBase):
def test_styleguide_has_profile_card_and_action_grid(self):
body = self.testapp.get("/styleguide", status=200).body.decode()
self.assertIn("profile-card-header", body)
self.assertIn("action-button-grid", body)
class TestOfferNoJsFallback(_AuthenticatedBase):
"""MPS-21 capability-driven presentation: every offer action works
as a plain POST 302 redirect with no JS / no X-Requested-With."""

View file

@ -33,6 +33,7 @@ from ..lib.mail import (
)
from ..lib.currency import cents_to_dollars
from ..models.offer import (
MpsOffer,
OFFER_PARTY_BUYER,
OFFER_PARTY_SELLER,
OFFER_STATE_DECLINED,
@ -41,7 +42,7 @@ from ..models.offer import (
get_offer_by_id,
)
from ..models.product import get_product_by_id
from ..views import user_required
from ..views import user_required, shop_editor_required
def _is_ajax(request):
@ -83,7 +84,8 @@ def _serialize_offer(offer):
"product_title": offer.product.title,
"shop_id": offer.shop.uuid_str,
"buyer_id": offer.buyer.uuid_str,
"buyer_email": offer.buyer.email,
"buyer_name": offer.buyer.display_name,
"buyer_handle": offer.buyer.name,
"state": offer.state,
"state_human": offer.state_human,
"is_open": offer.is_open,
@ -109,7 +111,9 @@ def _serialize_offer(offer):
"event_human": OFFER_EVENT_INT_TO_HUMAN.get(
e.event_type, "Unknown"
),
"actor_email": e.actor.email if e.actor else None,
"actor_id": e.actor.uuid_str if e.actor else None,
"actor_name": e.actor.display_name if e.actor else None,
"actor_handle": e.actor.name if e.actor else None,
"amount_in_cents": e.amount_in_cents,
"amount": (
cents_to_dollars(e.amount_in_cents)
@ -364,3 +368,54 @@ def offer_checkout(request):
request.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
request.dbsession.flush()
return HTTPFound("/cart")
@view_config(route_name="shop_offers", renderer="shop_offers.j2")
@shop_editor_required()
def shop_offers(request):
"""Operator inbox of all make-an-offer negotiations for this shop.
Open offers (pending / countered) sort to the top, most-recently
active first; terminal offers follow. Each row links to /o/{id}.
"""
shop = request.shop
offers = (
request.dbsession.query(MpsOffer)
.filter(MpsOffer.shop_id == shop.id)
.all()
)
offers.sort(key=lambda o: (o.is_terminal, -(o.last_action_timestamp or 0)))
from datetime import datetime, timezone
def _human(ts):
if not ts:
return ""
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
rows = []
for o in offers:
rows.append({
"id": o.uuid_str,
"product_title": o.product.title if o.product else "(removed product)",
"buyer_name": o.buyer.display_name if o.buyer else "(unknown)",
"buyer_handle": o.buyer.name if o.buyer else None,
"state": o.state,
"state_human": o.state_human,
"is_open": o.is_open,
"is_terminal": o.is_terminal,
"current_amount": o.current_amount,
"round_count": o.round_count,
"last_action_human": _human(o.last_action_timestamp),
"waiting_on_seller": (
o.is_open and o.current_party == OFFER_PARTY_SELLER
),
})
return {
"the_title": "Shop Offers",
"shop": shop,
"offers": rows,
"open_count": sum(1 for r in rows if not r["is_terminal"]),
}

View file

@ -1,12 +1,18 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from . import user_required, shop_is_ready_required
from ..models.user import is_user_name_available, is_user_name_valid
from ..models.user import (
is_user_name_available,
is_user_name_valid,
get_user_by_name,
)
from ..models.invoice import Invoice
from ..models.offer import MpsOffer
from ..models.shop import get_shop_by_id
@view_config(route_name="user_shops", renderer="shops.j2")
@ -194,3 +200,100 @@ def user_address_activate(request):
request.session.flash(msg)
return HTTPFound("/u/addresses")
@view_config(route_name="user_profile", renderer="profile.j2")
def user_profile(request):
"""Public profile page for a user, addressed by @handle.
Shows display name, member-since, gravatar, and the public shops the
user owns or edits. The email address is intentionally NOT public: it
is revealed only when (a) you are looking at your own profile, or
(b) you are an owner/editor of the shop passed in ?shop= and the
profile user has actually transacted with that shop (an offer or an
invoice). This keeps the offer-history "by <name>" link useful to a
shop operator without leaking customer emails to the world.
"""
from datetime import datetime, timezone
profile_user = get_user_by_name(
request.dbsession, request.matchdict["user_name"]
)
if profile_user is None or profile_user.disabled:
raise HTTPNotFound()
viewer = request.user
is_self = viewer is not None and viewer == profile_user
shop = None
shop_id = (request.params.get("shop") or "").strip()
if shop_id:
shop = get_shop_by_id(request.dbsession, shop_id)
viewer_is_shop_editor = bool(
shop is not None and viewer is not None and viewer.can_edit_shop(shop)
)
shop_offer_count = 0
shop_invoice_count = 0
if shop is not None:
shop_offer_count = (
request.dbsession.query(MpsOffer)
.filter(
MpsOffer.shop_id == shop.id,
MpsOffer.buyer_user_id == profile_user.id,
)
.count()
)
shop_invoice_count = (
request.dbsession.query(Invoice)
.filter(
Invoice.shop_id == shop.id,
Invoice.user_id == profile_user.id,
)
.count()
)
profile_has_shop_history = bool(shop_offer_count or shop_invoice_count)
can_see_email = is_self or (
viewer_is_shop_editor and profile_has_shop_history
)
# Public shops this user owns or edits, hiding non-production shops.
public_shops = sorted(
{
us.shop
for us in profile_user.user_shops
if us.role_id in (0, 1)
and us.shop is not None
and not us.shop.is_non_production
},
key=lambda s: (s.name or "").lower(),
)
member_since_human = ""
if profile_user.created_timestamp:
member_since_human = datetime.fromtimestamp(
profile_user.created_timestamp / 1000, tz=timezone.utc
).strftime("%B %Y")
return {
"the_title": profile_user.display_name,
"profile_user": profile_user,
"display_name": profile_user.display_name,
"handle": profile_user.name,
"member_since": profile_user.created_timestamp,
"member_since_human": member_since_human,
"gravatar_url": profile_user.gravatar_url(160),
"public_shops": public_shops,
"shop_count": len(public_shops),
"can_see_email": can_see_email,
"email": profile_user.email if can_see_email else None,
"is_self": is_self,
"shop_context": shop,
"shop_context_id": shop.uuid_str if shop is not None else None,
"viewer_is_shop_editor": viewer_is_shop_editor,
"profile_has_shop_history": profile_has_shop_history,
"shop_offer_count": shop_offer_count,
"shop_invoice_count": shop_invoice_count,
}