+
@@ -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 %}
+
+
+
+
+
+ {% 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
+
+ {% for s in public_shops %}
+ - {{ s.name }}
+ {% endfor %}
+
+
+ {% 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 %}
+
+
+
+
+ | Product |
+ Buyer |
+ Amount |
+ Round |
+ State |
+ Last action |
+ |
+
+
+
+ {% for o in offers %}
+
+ | {{ 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 |
+
+ {% endfor %}
+
+
+
+ {% 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)
+
+
+
+
+ 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,
+ }