diff --git a/CLAUDE.md b/CLAUDE.md index a8d884f..b989218 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` spacers, no fixed two-column split. Add new operator +shortcuts as another `` 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 diff --git a/docs/design-system.md b/docs/design-system.md index 63be926..92387a0 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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 | diff --git a/docs/make-offer.md b/docs/make-offer.md index 326ba24..5144f86 100644 --- a/docs/make-offer.md +++ b/docs/make-offer.md @@ -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 diff --git a/make_post_sell/models/user.py b/make_post_sell/models/user.py index c0d9019..da8ed40 100644 --- a/make_post_sell/models/user.py +++ b/make_post_sell/models/user.py @@ -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) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index c34a634..44a9a04 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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}") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 15e98f6..98c70ad 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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; } diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2 index eb134d2..43132f9 100644 --- a/make_post_sell/templates/actions_view.j2 +++ b/make_post_sell/templates/actions_view.j2 @@ -2,66 +2,36 @@ {% block content -%} +{% if request.shop %}
+
-
- {% if request.shop %} - -   View Shop - -
-
-   View Products + View Shop + View Products {% if request.user in request.shop.owners %} -
-
-   View Coupons - + View Coupons {% if request.shop.gift_card_enabled %} -
-
-   Gift Cards + 🎁  Gift Cards {% endif %} + {% if request.shop.offer_enabled %} + 🤝  Offers {% endif %} + 👤  Shop Users + 💰  Shop Sales + 💬  Shop Comments + 📊  Shop Analytics + ⚙  Shop Settings + {% endif %} + + 🏠  Shop Locations {% if request.is_saas_domain %} -
-
- ⛶   Switch Shop + ⛭  Switch Shop {% endif %} - {% endif %}
- -
- {% if request.shop and request.user in request.shop.owners %} - 👤   Shop Users - -
-
- 💰   Shop Sales - -
-
- 💬   Shop Comments - -
-
- 📊   Shop Analytics - -
-
- ⚙   Shop Settings - -
-
- {% endif %} - {% if request.shop %} - 🏠   Shop Locations - {% endif %} -
-
+{% endif %} {%- endblock -%} diff --git a/make_post_sell/templates/offer.j2 b/make_post_sell/templates/offer.j2 index 5d212ba..92a7b49 100644 --- a/make_post_sell/templates/offer.j2 +++ b/make_post_sell/templates/offer.j2 @@ -17,11 +17,12 @@ · Round {{ round_count }}

+

Buyer: {{ buyer_name }}

{% if buyer_message %} -

Buyer: {{ buyer_message }}

+

Buyer note: {{ buyer_message }}

{% endif %} {% if seller_message %} -

Seller: {{ seller_message }}

+

Seller note: {{ seller_message }}

{% endif %} @@ -105,7 +106,7 @@ {% for e in events %}
  • {{ e.event_human }} - {% if e.actor_email %} by {{ e.actor_email }}{% else %} (system){% endif %} + {% if e.actor_id %} by {{ e.actor_name }}{% else %} (system){% endif %} {% if e.amount is not none %} · ${{ "%.2f"|format(e.amount) }}{% endif %} {% if e.message %} — {{ e.message }}{% endif %}
  • diff --git a/make_post_sell/templates/profile.j2 b/make_post_sell/templates/profile.j2 new file mode 100644 index 0000000..fc236d8 --- /dev/null +++ b/make_post_sell/templates/profile.j2 @@ -0,0 +1,58 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + {{ display_name }} — profile +{%- endblock %} + +{% block content %} +
    + +
    +
    + +
    +

    {{ display_name }}

    +

    @{{ handle }}

    + {% if member_since_human %}

    Member since {{ member_since_human }}

    {% endif %} +

    {{ shop_count }} shop{{ '' if shop_count == 1 else 's' }}

    +
    +
    + + {% if email %} + {# Email is never shown by default — the viewer must opt to reveal it. +
    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). #} +
    + Show email address +

    {{ email }}

    + {% if not is_self and shop_context %} +

    Visible to you as an operator of {{ shop_context.name }}.

    + {% endif %} +
    + {% elif shop_context and viewer_is_shop_editor and not profile_has_shop_history %} +

    This person hasn't transacted with {{ shop_context.name }} — their email isn't available here.

    + {% endif %} + + {% if shop_context and viewer_is_shop_editor and profile_has_shop_history %} +

    + 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' }}. +

    + {% endif %} +
    + + {% if public_shops %} +
    +

    Shops

    + +
    + {% endif %} + +
    +{% endblock %} diff --git a/make_post_sell/templates/shop_offers.j2 b/make_post_sell/templates/shop_offers.j2 new file mode 100644 index 0000000..aadc9cd --- /dev/null +++ b/make_post_sell/templates/shop_offers.j2 @@ -0,0 +1,56 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + Offers — {{ shop.name }} +{%- endblock %} + +{% block content %} +
    + +
    +

    Offers for {{ shop.name }}

    +

    + {% if open_count %}{{ open_count }} open{% if open_count != offers|length %} of {{ offers|length }} total{% endif %}.{% else %}No open offers.{% endif %} +

    +
    + + {% if offers %} +
    + + + + + + + + + + + + + + {% for o in offers %} + + + + + + + + + + {% endfor %} + +
    ProductBuyerAmountRoundStateLast action
    {{ o.product_title }}{% if o.buyer_handle %}{{ o.buyer_name }}{% else %}{{ o.buyer_name }}{% endif %}${{ "%.2f"|format(o.current_amount) }}{{ o.round_count }} + {{ o.state_human }} + {% if o.waiting_on_seller %}your turn{% endif %} + {{ o.last_action_human }}View
    +
    + {% else %} +
    +

    No one has made an offer on a product in this shop yet. When they do, the negotiation shows up here — and you get an email.

    +
    + {% endif %} + +
    +{% endblock %} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index 2f41ac7..11c7dae 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -814,6 +814,59 @@ Dark mode overrides via --notice-*-bg and --notice-*-border tokens.
    .serp — auto-fit grid, minmax(160px, 1fr) .serp-item — hover: brightness change, transition 700ms .serp-thumbnail — border-radius: 4px, width: 100%
    + +
    +
    Profile card (profile.j2)
    +
    +
    +
    + +
    +

    jane-doe

    +

    @jane-doe

    +

    Member since May 2026

    +

    2 shops

    +
    +
    +
    + Show email address +

    jane@example.com

    +

    Visible to you as an operator of Example Shop.

    +
    +
    + +
    +
    .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 — <details>; 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.
    +
    + +
    +
    Action button grid (actions_view.j2)
    +
    + +
    +
    .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 <br> spacers.
    +
    diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index d1cc8da..c70391d 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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.""" diff --git a/make_post_sell/views/offer.py b/make_post_sell/views/offer.py index ca78f5d..65b7a7e 100644 --- a/make_post_sell/views/offer.py +++ b/make_post_sell/views/offer.py @@ -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"]), + } diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py index 085fbce..30c2d52 100644 --- a/make_post_sell/views/user.py +++ b/make_post_sell/views/user.py @@ -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 " 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, + }