diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index ff0ef0a..1b49bb7 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -23,7 +23,7 @@ from .meta import ( from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, object_session from .user_shop import UserShop @@ -471,6 +471,21 @@ class Shop(RBase, Base): def is_adyen_not_ready(self): return not self.is_adyen_ready + @property + def has_auction_products(self): + """MPS-20: True when this shop has at least one product in an + auction pricing_mode (1 or 2). Used to gate buyer-side bid UI — + auctions have no shop-level toggle, they're enabled per product. + """ + from .product import Product + return ( + object_session(self) + .query(Product.id) + .filter(Product.shop_id == self.id, Product.pricing_mode.in_([1, 2])) + .first() + is not None + ) + def is_ready_for_payment(self, request): """Check if shop is ready based on enabled payment methods.""" # If Stripe is enabled, shop needs Stripe API keys diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index da93ad0..0df291b 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -63,6 +63,8 @@ def includeme(config): config.add_route("user_storage_settings", "/u/settings/storage") config.add_route("user_sandbox_upload", "/u/sandbox/upload") config.add_route("user_purchases", "/u/purchases") + config.add_route("user_offers", "/u/offers") + config.add_route("user_bids", "/u/bids") config.add_route("user_addresses", "/u/addresses") config.add_route("user_address_save", "/u/addresses/save") config.add_route("user_address_delete", "/u/addresses/{address_id}/delete") diff --git a/make_post_sell/templates/user_bids.j2 b/make_post_sell/templates/user_bids.j2 new file mode 100644 index 0000000..c3084e7 --- /dev/null +++ b/make_post_sell/templates/user_bids.j2 @@ -0,0 +1,55 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + My Bids — {{ shop.name }} +{%- endblock %} + +{% block content %} +
+ +
+

My Bids at {{ shop.name }}

+

+ {% if active_count %}{{ active_count }} active auction{% if active_count != 1 %}s{% endif %}{% if active_count != bids|length %} of {{ bids|length }} total{% endif %}.{% else %}No active bids.{% endif %} +

+
+ + {% if bids %} +
+ + + + + + + + + + + + + {% for b in bids %} + + + + + + + + + {% endfor %} + +
ProductYour bidStateEndsBid placed
{{ b.product_title }}${{ "%.2f"|format(b.bid_amount) }} + {{ b.state_human }} + {% if b.is_winning and b.is_active %}winning{% elif b.is_winning %}won{% endif %} + {{ b.ends_human }}{{ b.bid_placed_human }}View
+
+ {% else %} +
+

You haven't placed any bids in this shop yet. Browse the shop for items on auction.

+

Browse {{ shop.name }}

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

My Offers at {{ 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 %} + +
ProductYour offerRoundStateLast action
{{ o.product_title }}${{ "%.2f"|format(o.current_amount) }}{{ o.round_count }} + {{ o.state_human }} + {% if o.waiting_on_buyer %}your turn{% endif %} + {{ o.last_action_human }}View
+
+ {% else %} +
+

You haven't made any offers in this shop yet. When you do, every negotiation shows up here.

+

Browse {{ shop.name }}

+
+ {% endif %} + +
+{% endblock %} diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2 index 5da3003..a314815 100644 --- a/make_post_sell/templates/user_settings.j2 +++ b/make_post_sell/templates/user_settings.j2 @@ -189,6 +189,16 @@ My Purchases

+ {% if request.shop.offer_enabled %} + My Offers +
+
+ {% endif %} + {% if request.shop.has_auction_products %} + My Bids +
+
+ {% endif %} Crypto Payment History

diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 1cfed3a..171bfa2 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -7094,6 +7094,91 @@ class TestBuyNowGating(_AuthenticatedBase): self.assertNotIn(b"sold-out-button", res.body) +class TestUserOffersBidsDashboards(_AuthenticatedBase): + """MPS-20 + MPS-21: buyer-side /u/offers and /u/bids pages plus the + button gating in /u/settings. Each dashboard scopes to request.shop + (mirrors /u/purchases) and 404s when the shop doesn't expose the + relevant feature.""" + + def _shop_with_offer_product(self, list_price=10000): + from ..models.product import Product + + shop = self._create_shop_helper(user_creds=self.user1_creds) + shop.offer_enabled = True + self.dbsession.add(shop) + + product = Product(title="Negotiable thing", description="...") + product.shop = shop + product.price_in_cents = list_price + product.is_physical = False + product.is_sellable = True + product.pricing_mode = 3 # offer mode + self.dbsession.add(product) + self.dbsession.flush() + pid = product.uuid_str + transaction.commit() + return pid + + def _shop_with_auction_product(self, list_price=10000): + from ..models.product import Product + + shop = self._create_shop_helper(user_creds=self.user1_creds) + + product = Product(title="Auctioned thing", description="...") + product.shop = shop + product.price_in_cents = list_price + product.is_physical = False + product.is_sellable = True + product.pricing_mode = 1 # auction + self.dbsession.add(product) + self.dbsession.flush() + pid = product.uuid_str + transaction.commit() + return pid + + def test_user_offers_404_when_offers_disabled(self): + """Shop has offers off → /u/offers returns 404.""" + self._create_shop_helper(user_creds=self.user1_creds) + # Default shop has offer_enabled=False. + self.testapp.get("/u/offers", status=404) + + def test_user_offers_page_loads_when_enabled(self): + self._shop_with_offer_product() + res = self.testapp.get("/u/offers", status=200) + self.assertIn(b"My Offers", res.body) + + def test_user_bids_404_when_no_auction_products(self): + """Shop has no auction-mode products → /u/bids returns 404.""" + self._create_shop_helper(user_creds=self.user1_creds) + self.testapp.get("/u/bids", status=404) + + def test_user_bids_page_loads_with_auction_product(self): + self._shop_with_auction_product() + res = self.testapp.get("/u/bids", status=200) + self.assertIn(b"My Bids", res.body) + + def test_settings_buttons_appear_for_offer_shop(self): + """My Offers button shown when shop.offer_enabled is True.""" + self._shop_with_offer_product() + res = self.testapp.get("/u/settings", status=200) + self.assertIn(b'href="/u/offers"', res.body) + self.assertNotIn(b'href="/u/bids"', res.body) + + def test_settings_buttons_appear_for_auction_shop(self): + """My Bids button shown when shop has auction-mode products.""" + self._shop_with_auction_product() + res = self.testapp.get("/u/settings", status=200) + self.assertIn(b'href="/u/bids"', res.body) + self.assertNotIn(b'href="/u/offers"', res.body) + + def test_settings_buttons_hidden_for_plain_shop(self): + """Neither button appears on a shop with no offer / auction config.""" + self._create_shop_helper(user_creds=self.user1_creds) + res = self.testapp.get("/u/settings", status=200) + self.assertNotIn(b'href="/u/offers"', res.body) + self.assertNotIn(b'href="/u/bids"', res.body) + + class TestAuctionConfigForm(_AuthenticatedBase): """MPS-20: owner sets quantity, start/end, reserve, buy-now, increment, soft-close on a draft auction via product edit form.""" diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 520eecb..f6acf99 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -4019,6 +4019,40 @@ class TestAuctionFoundation(DatabaseIntegrationTests): self.dbsession.flush() return shop, product + def test_shop_has_auction_products_property(self): + """MPS-20: Shop.has_auction_products gates buyer-side auction UI. + Auctions don't have a shop-level toggle — they're enabled by + flipping a product's pricing_mode to 1 (auction) or 2 (auction + + buy_now). The property must reflect that derivation. + """ + from ..models.product import Product + + shop, product = self._make_shop_and_product(pricing_mode=1) + self.assertTrue(shop.has_auction_products) + + # Flip to fixed price — no auction products left. + product.pricing_mode = 0 + self.dbsession.flush() + self.assertFalse(shop.has_auction_products) + + # pricing_mode 2 (auction + buy_now) also counts. + product.pricing_mode = 2 + self.dbsession.flush() + self.assertTrue(shop.has_auction_products) + + # Add a second fixed-price product alongside — still True because + # at least one auction-mode product exists. + product.pricing_mode = 1 + p2 = Product(title="Buy now item", description="fixed") + p2.shop = shop + p2.price_in_cents = 500 + p2.is_physical = False + p2.is_sellable = True + p2.pricing_mode = 0 + self.dbsession.add(p2) + self.dbsession.flush() + self.assertTrue(shop.has_auction_products) + def test_create_and_persist_auction(self): from ..models.auction import ( MpsAuction, AUCTION_STATE_DRAFT, AUCTION_STATE_ACTIVE, diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py index 30c2d52..84e44ea 100644 --- a/make_post_sell/views/user.py +++ b/make_post_sell/views/user.py @@ -12,6 +12,7 @@ from ..models.user import ( from ..models.invoice import Invoice from ..models.offer import MpsOffer +from ..models.auction import MpsAuction, MpsBid from ..models.shop import get_shop_by_id @@ -45,6 +46,129 @@ def user_purchases(request): } +@view_config(route_name="user_offers", renderer="user_offers.j2") +@user_required() +@shop_is_ready_required() +def user_offers(request): + """Buyer-side inbox: every offer this user has opened in this shop. + + Mirrors /u/purchases — scopes to request.shop. Open offers (pending / + countered) sort to the top by most-recent activity; terminal offers + follow. + """ + from ..models.offer import OFFER_PARTY_BUYER + + user = request.user + shop = request.shop + + if not shop.offer_enabled: + raise HTTPNotFound() + + offers = ( + request.dbsession.query(MpsOffer) + .filter(MpsOffer.shop_id == shop.id, MpsOffer.buyer_user_id == user.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)", + "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_buyer": ( + o.is_open and o.current_party == OFFER_PARTY_BUYER + ), + }) + return { + "the_title": "My Offers", + "shop": shop, + "offers": rows, + "open_count": sum(1 for r in rows if not r["is_terminal"]), + } + + +@view_config(route_name="user_bids", renderer="user_bids.j2") +@user_required() +@shop_is_ready_required() +def user_bids(request): + """Buyer-side dashboard: every bid this user has placed on an auction + in this shop. Latest bid per auction wins the row — older bids fold + into history on the auction page itself. + """ + user = request.user + shop = request.shop + + if not shop.has_auction_products: + raise HTTPNotFound() + + bids = ( + request.dbsession.query(MpsBid) + .join(MpsAuction, MpsBid.auction_id == MpsAuction.id) + .filter( + MpsBid.bidder_user_id == user.id, + MpsAuction.shop_id == shop.id, + ) + .order_by(MpsBid.created_timestamp.desc()) + .all() + ) + + seen_auctions = set() + latest_per_auction = [] + for b in bids: + if b.auction_id in seen_auctions: + continue + seen_auctions.add(b.auction_id) + latest_per_auction.append(b) + + 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 b in latest_per_auction: + a = b.auction + rows.append({ + "auction_id": a.uuid_str, + "product_title": a.product.title if a.product else "(removed product)", + "bid_amount": b.amount, + "is_winning": b.is_winning, + "state": a.state, + "state_human": a.state_human, + "is_active": a.is_active, + "is_ended": a.is_ended, + "ends_human": _human(a.end_timestamp), + "bid_placed_human": _human(b.created_timestamp), + }) + return { + "the_title": "My Bids", + "shop": shop, + "bids": rows, + "active_count": sum(1 for r in rows if r["is_active"]), + } + + @view_config(route_name="user_settings", renderer="user_settings.j2") @user_required( flash_msg="To view your settings, please verify your email address below.",