make_post_sell/make_post_sell/models/shop.py
russell@unturf.com 5f735ee1d3
fix: drop redundant blue state-notice on offer page; default 48h → 72h
Two cleanups:

1. The orphaned blue "Waiting on the other party to respond" stripe
   (alert-info-bg) appeared between the offer header and the
   "Waiting on the other party · They have in X to respond" well
   below — identical copy, twice. Same pattern as the previously
   removed green "Offer accepted" banner. Dropping both the can_act
   ("It's your turn — accept, counter, or decline below") and
   is_open branches of the state-notice. The "Your turn" section
   heading and the waiting well right below already carry the
   message, with the live countdown that the alert lacked.

   Kept: DECLINED, WITHDRAWN, EXPIRED, PAID — those have no
   follow-on action block, so the alert is the only signal.

2. DEFAULT_OFFER_EXPIRATION_HOURS 48 → 72 (3 days). Fox: 48h still
   too tight for sellers checking shop mail intermittently. 7 days
   was too generous, 48 hours was on the strict side. 72 hours
   covers a long weekend.

Per-shop overrides are unchanged — operators tweak
offer_expiration_hours via offer-settings on /s/<shop>/settings.
2026-05-14 16:26:23 -04:00

1097 lines
38 KiB
Python

import json
import logging
import threading
import time
import uuid
from collections import Counter
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func
from sqlalchemy import and_
from sqlalchemy.orm import Session as SASession
log = logging.getLogger(__name__)
from .meta import (
Base,
RBase,
UUIDType,
now_timestamp,
foreign_key,
get_object_by_id,
get_objects_by_ids,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship, object_session
from .user_shop import UserShop
from .stripe_user_shop import StripeUserShop
from slugify import slugify
from ..lib.hex_color import color_scale
from ..lib.render import markdown_to_html
try:
unicode("")
except:
from six import u as unicode
class Shop(RBase, Base):
"""This class represents a shop."""
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(64), unique=True, nullable=False)
domain_name = Column(Unicode(256), unique=True)
# todo: rename to description_raw.
description = Column(UnicodeText, nullable=True)
description_html = Column(UnicodeText, nullable=True)
terms_of_service_raw = Column(UnicodeText, nullable=True)
terms_of_service_html = Column(UnicodeText, nullable=True)
privacy_policy_raw = Column(UnicodeText, nullable=True)
privacy_policy_html = Column(UnicodeText, nullable=True)
billing_address = Column(UnicodeText, nullable=False)
phone_number = Column(Unicode(32), nullable=False)
# The text at the very top of the shop's pages.
ribbon_text = Column(UnicodeText, nullable=True, default="")
ribbon_text_color = Column(Unicode(32), nullable=True, default="")
ribbon_color_1 = Column(Unicode(32), nullable=True, default="")
ribbon_color_2 = Column(Unicode(32), nullable=True, default="")
# CSS Grid Lanes (masonry layout) - enabled by default
grid_lanes_enabled = Column(Boolean, default=True)
# example: "UA-XX31XX60-3".
google_analytics_id = Column(Unicode(32), nullable=True, default="")
plausible_domain_name = Column(Unicode(256), nullable=True, default="")
# Google Search Console site verification code
google_site_verification = Column(Unicode(128), nullable=True, default="")
# example: "cus_12345678AbCdEF" but may be null.
# this is how the shop pays for make_post_sell.
stripe_id = Column(Unicode(32), unique=True, nullable=True)
# these keys allow make_post_sell to charge
# cards on behalf of the shop's stripe account.
# stripe_secret_api_key = Column(Unicode(64), nullable=True)
# stripe_public_api_key = Column(Unicode(64), nullable=True)
stripe_secret_api_key = Column(Unicode(128), nullable=True)
stripe_public_api_key = Column(Unicode(128), nullable=True)
stripe_enabled = Column(Boolean, default=True)
# PayPal API credentials for accepting payments
paypal_client_id = Column(Unicode(128), nullable=True)
paypal_secret = Column(Unicode(128), nullable=True)
paypal_enabled = Column(Boolean, default=True)
# Adyen API credentials for accepting payments
adyen_api_key = Column(Unicode(128), nullable=True)
adyen_merchant_account = Column(Unicode(128), nullable=True)
adyen_client_key = Column(Unicode(128), nullable=True)
adyen_hmac_key = Column(Unicode(128), nullable=True)
adyen_enabled = Column(Boolean, default=True)
# Unsandbox API credentials for karaoke vocal isolation
unsandbox_public_key = Column(Unicode(128), nullable=True)
unsandbox_secret_key = Column(Unicode(128), nullable=True)
# S3-compatible bucket for mirroring all shop uploads
mirror_s3_endpoint = Column(Unicode(256), nullable=True)
mirror_s3_region = Column(Unicode(64), nullable=True)
mirror_s3_bucket = Column(Unicode(128), nullable=True)
mirror_s3_access_key = Column(Unicode(128), nullable=True)
mirror_s3_secret_key = Column(Unicode(128), nullable=True)
mirror_s3_enabled = Column(Boolean, default=False)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
disabled = Column(Boolean, default=False)
maint_mode = Column(Boolean, default=False)
favicon = Column(Boolean, default=False)
logo_banner = Column(Boolean, default=False)
# Comment/Review system settings
comments_enabled = Column(Boolean, default=True)
comments_require_purchase = Column(Boolean, default=False)
comments_require_approval = Column(Boolean, default=False)
# Payment risk thresholds (in cents)
payment_risk_threshold_mid_cents = Column(
BigInteger, nullable=False, default=1000
) # Risk threshold between petty and mid tier (default $10)
payment_risk_threshold_high_cents = Column(
BigInteger, nullable=False, default=10000
) # Risk threshold between mid and high tier (default $100)
# Cryptocurrency quote expiry time in seconds
crypto_quote_expiry_seconds = Column(
BigInteger, nullable=False, default=3600
) # Default 60 minutes
# Default theme for shop visitors (0=dark, 1=light)
default_theme = Column(BigInteger, nullable=False, default=1) # Default light mode
# Show created/updated dates on product and content pages
show_dates = Column(Boolean, default=True)
# Watch mode: sticky video, autoplay, related content sidebar
watch_mode_enabled = Column(Boolean, default=False)
# Grayscale mode: render entire site in black and white
grayscale_mode = Column(Boolean, default=False)
# Public sales stats: show sales counts and revenue on the shop page (off by default)
public_sales_stats = Column(Boolean, default=False)
# Color filter: 0=off, 1=grayscale, 2=red, 3=green, 4=blue,
# 5=red+green, 6=red+blue, 7=green+blue
color_filter = Column(BigInteger, default=0)
# Email digest subscriptions
subscriptions_enabled = Column(Boolean, default=True)
# Sandbox mode: floating creative filter toolbar for visitors
sandbox_mode = Column(Boolean, default=False)
# Gift card settings
gift_card_enabled = Column(Boolean, default=False)
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
# Shop environment: 0=production, 1=staging, 2=development
environment = Column(BigInteger, nullable=False, default=0)
# Trial and billing
trial_started_timestamp = Column(BigInteger, nullable=True)
trial_ended = Column(Boolean, default=False)
plan_active = Column(Boolean, default=False)
# Primary S3 bucket (Bring Your Own Bucket)
primary_s3_endpoint = Column(Unicode(256), nullable=True)
primary_s3_region = Column(Unicode(64), nullable=True)
primary_s3_bucket = Column(Unicode(128), nullable=True)
primary_s3_access_key = Column(Unicode(128), nullable=True)
primary_s3_secret_key = Column(Unicode(128), nullable=True)
primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
primary_s3_enabled = Column(Boolean, default=False)
# Torrent / magnet link distribution (off by default)
torrent_enabled = Column(Boolean, default=False)
# MPS-21: make-an-offer settings (all off / sane defaults)
offer_enabled = Column(Boolean, default=False)
offer_min_in_cents = Column(BigInteger, nullable=True)
offer_auto_accept_threshold_pct = Column(
BigInteger, nullable=False, default=95, server_default="95"
)
offer_auto_decline_threshold_pct = Column(
BigInteger, nullable=False, default=50, server_default="50"
)
offer_expiration_hours = Column(
BigInteger, nullable=False, default=72, server_default="72"
)
offer_max_rounds = Column(
BigInteger, nullable=False, default=3, server_default="3"
)
offer_acceptance_payment_hours = Column(
BigInteger, nullable=False, default=24, server_default="24"
)
offer_min_buyer_account_age_hours = Column(
BigInteger, nullable=False, default=0, server_default="0"
)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
carts = relationship(argument="Cart", lazy="dynamic", back_populates="shop")
# many to many uses association_proxy.
stripe_users = association_proxy(
"stripe_users", "user", creator=lambda u: StripeUserShop(user=u)
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
products = relationship(argument="Product", lazy="dynamic", back_populates="shop")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
coupons = relationship(
argument="Coupon",
back_populates="shop",
lazy="dynamic",
order_by="desc(Coupon.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
invoices = relationship("Invoice", back_populates="shop", lazy="dynamic")
# lazy='dynamic' returns a query object instead of collection.
gift_cards = relationship(
argument="GiftCard",
back_populates="shop",
lazy="dynamic",
order_by="desc(GiftCard.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
shop_locations = relationship(
argument="ShopLocation", lazy="dynamic", back_populates="shop"
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
search_requests = relationship(
argument="ShopSearchRequest", lazy="dynamic", back_populates="shop"
)
api_keys = relationship(
argument="MpsApiKey", lazy="dynamic", back_populates="shop"
)
def __init__(self, name, phone_number, billing_address, description):
self.id = uuid.uuid1()
self.name = name
self.phone_number = phone_number
self.billing_address = billing_address
self.description = description
self.updated_timestamp = now_timestamp()
self.created_timestamp = now_timestamp()
def get_usershop_for_user(self, user):
"""Given a User object, return the UserShop object or None."""
if self.dbsession is not None:
return (
self.dbsession.query(UserShop)
.filter(
UserShop.shop == self,
UserShop.user == user,
)
.one_or_none()
)
def add_user_to_shop(self, user, role_id=0):
"""Add User object to UserShop."""
role_id = int(role_id)
us = self.get_usershop_for_user(user)
if us is None:
us = UserShop(user=user, shop=self, role_id=role_id)
self.shop_users.append(us)
else:
us.role_id = role_id
return us
# --- Environment properties (MPS-14) ---
@property
def is_production(self):
return self.environment == 0
@property
def is_staging(self):
return self.environment == 1
@property
def is_development(self):
return self.environment == 2
@property
def is_non_production(self):
return self.environment != 0
@property
def environment_label(self):
return {0: "Production", 1: "Staging", 2: "Development"}.get(
self.environment, "Production"
)
# --- Trial properties (MPS-15) ---
TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days
@property
def trial_expiry_timestamp(self):
if self.trial_started_timestamp is None:
return None
return self.trial_started_timestamp + self.TRIAL_DURATION_MS
@property
def is_trial_active(self):
if self.plan_active:
return False
if self.trial_started_timestamp is None:
return False
if self.trial_ended:
return False
import time
now = int(time.time() * 1000)
return now < self.trial_expiry_timestamp
@property
def is_trial_expired(self):
if self.plan_active or self.trial_started_timestamp is None:
return False
if self.trial_ended:
return True
import time
now = int(time.time() * 1000)
return now >= self.trial_expiry_timestamp
@property
def trial_days_remaining(self):
if not self.is_trial_active:
return 0
import time
remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
return max(0, remaining_ms // (24 * 60 * 60 * 1000))
@property
def is_active(self):
"""Shop can operate: either paid plan, active trial, or pre-trial (existing)."""
if self.plan_active:
return True
if self.trial_started_timestamp is None:
return True # grandfathered pre-trial shop
return self.is_trial_active
# --- BYOB properties (MPS-16) ---
@property
def has_primary_s3(self):
return bool(
self.primary_s3_enabled
and self.primary_s3_endpoint
and self.primary_s3_region
and self.primary_s3_bucket
and self.primary_s3_access_key
and self.primary_s3_secret_key
and self.primary_s3_cdn_endpoint
)
@property
def media_cdn_endpoint(self):
if self.has_primary_s3:
return self.primary_s3_cdn_endpoint
return None
@property
def has_s3_mirror(self):
"""Return True if shop has S3 mirror bucket configured and enabled."""
return bool(
self.mirror_s3_enabled
and self.mirror_s3_endpoint
and self.mirror_s3_bucket
and self.mirror_s3_access_key
and self.mirror_s3_secret_key
)
@property
def owners(self):
"""Returns a list of user objects who have the owner role on this shop."""
# us is a user_shop object.
return [us.user for us in self.shop_users if us.is_owner]
@property
def editors(self):
"""Returns a list of user objects who have the editor role on this shop."""
return self.owners + [us.user for us in self.shop_users if us.is_editor]
@property
def members(self):
"""Returns a list of user objects who have the member role on this shop."""
return self.editors + [us.user for us in self.shop_users if us.is_member]
def is_owner(self, user):
"""Check if the given user is an owner of this shop."""
if not user:
return False
return user in self.owners
def is_editor(self, user):
"""Check if the given user is an editor of this shop."""
if not user:
return False
return user in self.editors
def is_member(self, user):
"""Check if the given user is a member of this shop."""
if not user:
return False
return user in self.members
@property
def slug(self):
"""return slug from name"""
return slugify(self.name)
@property
def is_stripe_ready(self):
"""Check if shop has Stripe API keys configured."""
if self.stripe_secret_api_key and self.stripe_public_api_key:
return True
return False
@property
def is_stripe_not_ready(self):
return not self.is_stripe_ready
@property
def is_paypal_ready(self):
"""Check if shop has PayPal API credentials configured."""
if self.paypal_client_id and self.paypal_secret:
return True
return False
@property
def is_paypal_not_ready(self):
return not self.is_paypal_ready
@property
def is_adyen_ready(self):
"""Check if shop has Adyen API credentials configured."""
if self.adyen_api_key and self.adyen_merchant_account:
return True
return False
@property
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
)
@property
def has_offer_products(self):
"""MPS-21: True when this shop has at least one product that
accepts offers, accounting for the per-product allow_offers
override. A product accepts offers when:
- product.allow_offers IS TRUE (explicit opt-in), OR
- product.allow_offers IS NULL and shop.offer_enabled is True
(default inheritance).
Used to gate the buyer-side "My Offers" button — a shop with
offer_enabled=False can still have offer-capable products via
the per-product override.
"""
from sqlalchemy import or_
from .product import Product
q = (
object_session(self)
.query(Product.id)
.filter(Product.shop_id == self.id)
)
if self.offer_enabled:
q = q.filter(
or_(Product.allow_offers == True, Product.allow_offers.is_(None))
)
else:
q = q.filter(Product.allow_offers == True)
return q.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
if request.stripe_enabled:
if self.is_stripe_ready:
return True
# If PayPal is enabled, shop needs PayPal API credentials
if request.paypal_enabled:
if self.is_paypal_ready:
return True
# If Adyen is enabled, shop needs Adyen API credentials
if getattr(request, "adyen_enabled", False):
if self.is_adyen_ready:
return True
# If Monero is enabled, check if shop has configured processor and RPC is available
if request.monero_enabled and request.monero_rpc_available:
from .crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == self.id,
CryptoProcessor.coin_type == "XMR",
CryptoProcessor.enabled == True,
)
.first()
)
if processor:
return True
# If Dogecoin is enabled, check if shop has configured processor and RPC is available
if request.dogecoin_enabled and request.dogecoin_rpc_available:
from .crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == self.id,
CryptoProcessor.coin_type == "DOGE",
CryptoProcessor.enabled == True,
)
.first()
)
if processor:
return True
# No payment methods available or properly configured
return False
@property
def stripe(self):
"""Return a stripe object using this shop's secret api key."""
if hasattr(self, "_stripe") == False:
if self.stripe_secret_api_key:
import stripe
stripe.api_key = self.stripe_secret_api_key
self._stripe = stripe
else:
self._stripe = None
return self._stripe
def stripe_user_shop(self, user):
"""Return the stripe_user_shop object from our database for this user, or None."""
return (
self.dbsession.query(StripeUserShop)
.filter(
StripeUserShop.user == user,
StripeUserShop.shop == self,
)
.one_or_none()
)
def stripe_customer_id(self, user):
"""Return the stripe_customer id from our database for this user, or None."""
stripe_user_shop = self.stripe_user_shop(user)
if stripe_user_shop:
return stripe_user_shop.cus_id
def stripe_customer(self, user, create=False):
"""
Return the stripe customer object for this shop for a given user.
If missing, create one and return, if the `create` parameter is True
else return None.
"""
stripe_customer_id = self.stripe_customer_id(user)
if stripe_customer_id is None:
if create == False:
return None
# create a new stripe customer.
customer = self.stripe.Customer.create(email=user.email, expand=["sources"])
# create a new many-to-many user to shop stripe customer.
stripe_user_shop = StripeUserShop(user, self)
stripe_user_shop.cus_id = customer.id
stripe_customer_id = customer.id
# flush relationship to database.
self.dbsession.add(stripe_user_shop)
self.dbsession.flush()
return customer
return self.stripe.Customer.retrieve(stripe_customer_id, expand=["sources"])
def list_stripe_charges(self, stripe_customer=None):
"""
Return a list of all charges for this shop.
Optionally pass a stripe Customer object to filter.
"""
if stripe_customer is not None:
return self.stripe.Charge.list(customer=stripe_customer)
return self.stripe.Charge.list()
@property
def paypal(self):
"""Return a PayPal SDK API instance using this shop's credentials."""
if hasattr(self, "_paypal") == False:
if self.paypal_client_id and self.paypal_secret:
import paypalrestsdk
# Get sandbox mode from request/config if available
# Default to sandbox for safety
mode = "sandbox"
if hasattr(self, "dbsession") and self.dbsession:
try:
from pyramid.threadlocal import get_current_request
request = get_current_request()
if request and hasattr(request, "app"):
sandbox_mode = request.app.get("paypal.sandbox_mode", True)
if isinstance(sandbox_mode, str):
sandbox_mode = sandbox_mode.strip().lower() in ("1", "true", "yes", "on")
if not sandbox_mode:
mode = "live"
except:
pass
api = paypalrestsdk.Api({
'mode': mode,
'client_id': self.paypal_client_id,
'client_secret': self.paypal_secret
})
self._paypal = api
else:
self._paypal = None
return self._paypal
def paypal_user_shop(self, user):
"""Return the paypal_user_shop object from our database for this user, or None."""
from .paypal_user_shop import PayPalUserShop
return (
self.dbsession.query(PayPalUserShop)
.filter(
PayPalUserShop.user == user,
PayPalUserShop.shop == self,
)
.one_or_none()
)
@property
def adyen(self):
"""Return an Adyen SDK instance using this shop's credentials."""
if hasattr(self, "_adyen") == False:
if self.adyen_api_key and self.adyen_merchant_account:
import Adyen
adyen = Adyen.Adyen()
adyen.client.xapikey = self.adyen_api_key
# Get test/live mode from request/config if available
# Default to test for safety
platform = "test"
if hasattr(self, "dbsession") and self.dbsession:
try:
from pyramid.threadlocal import get_current_request
request = get_current_request()
if request and hasattr(request, "app"):
test_mode = request.app.get("adyen.test_mode", True)
if isinstance(test_mode, str):
test_mode = test_mode.strip().lower() in ("1", "true", "yes", "on")
if not test_mode:
platform = "live"
except:
pass
adyen.client.platform = platform
self._adyen = adyen
else:
self._adyen = None
return self._adyen
@property
def theme_base_color(self):
# return the user defined base for shop or default.
return self.ribbon_color_1 or "#5871ad"
@property
def theme_link_color(self):
return color_scale(self.theme_base_color, 0.75)
def absolute_url(self, request, slug=True):
"""
The absolute URI to the shop's page.
For example:
https://my.makepostsell.com/s/<shop-uuid>
"""
if slug:
return f"{request.host_url}/s/{self.id}/{self.slug}"
return f"{request.host_url}/s/{self.id}"
def absolute_settings_url(self, request):
"""
The absolute URI to the shop's settings page.
For example:
https://my.makepostsell.com/s/<shop-uuid>/settings
"""
return f"{self.absolute_url(request, slug=False)}/settings"
def absolute_about_url(self, request):
"""
The absolute URI to the shop's about page.
For example:
https://my.makepostsell.com/s/<shop-uuid>/about
"""
# why ure slug for this URL, is it for SEO?
return f"{self.absolute_url(request)}/about"
def absolute_terms_url(self, request):
return f"{self.absolute_url(request, slug=False)}/terms"
def absolute_privacy_policy_url(self, request):
return f"{self.absolute_url(request, slug=False)}/privacy-policy"
def absolute_sales_url(self, request):
return f"{self.absolute_url(request, slug=False)}/sales"
def set_description(self, new_description):
self.description = new_description
self.description_html = markdown_to_html(self.description, self)
@property
def privacy_policy(self):
return self.privacy_policy_html
@privacy_policy.setter
def privacy_policy(self, new_privacy_policy_raw):
self.privacy_policy_raw = new_privacy_policy_raw
self.privacy_policy_html = markdown_to_html(self.privacy_policy_raw, self)
@property
def terms_of_service(self):
return self.terms_of_service_html
@terms_of_service.setter
def terms_of_service(self, new_terms_of_service_raw):
self.terms_of_service_raw = new_terms_of_service_raw
self.terms_of_service_html = markdown_to_html(self.terms_of_service_raw, self)
def get_carts_for_user(self, user):
"""return all of this Shop's Cart objects for the given User."""
from .cart import Cart
return self.carts.filter(and_(Cart.shop == self, Cart.user == user))
def make_cart_active_for_user(self, user, cart):
for c in self.get_carts_for_user(user):
if cart != c:
c.active = False
else:
c.active = True
self.dbsession.add(c)
self.dbsession.flush()
def get_active_cart_for_user(self, user):
carts = self.get_carts_for_user(user)
for cart in carts:
if cart.active == True:
return cart
def create_new_cart_for_user(self, user):
from .cart import Cart
cart = Cart()
cart.user = user
cart.shop = self
self.dbsession.add(cart)
self.dbsession.flush()
self.make_cart_active_for_user(user, cart)
return cart
def stamp_updated_timestamp(self):
self.updated_timestamp = now_timestamp()
@property
def discovery_ring(self):
"""Return the discovery ring as a list of product ID strings, or []."""
if not hasattr(self, "_discovery_ring"):
if self.json_discovery_ring:
self._discovery_ring = json.loads(self.json_discovery_ring)
else:
self._discovery_ring = []
return self._discovery_ring
@discovery_ring.setter
def discovery_ring(self, ring_list):
"""Set the discovery ring from a list of product ID strings."""
self._discovery_ring = ring_list
self.json_discovery_ring = json.dumps(ring_list)
def compute_discovery_ring(shop):
"""Compute a circular ordering of all public products using greedy nearest-neighbor.
Algorithm:
1. Start from the newest public product
2. Accumulate its stems into a running set
3. Find unvisited product with highest Jaccard similarity to accumulated stems
4. Add to ring, merge stems, repeat until all public products are ordered
Returns a list of product ID strings.
"""
from .product import tokenize_and_stem
# Gather all public products, sorted newest-first for deterministic seed
candidates = []
for p in shop.products:
if p.visibility == 1:
candidates.append(p)
if not candidates:
return []
# Sort newest-first; tiebreak by ID for determinism
candidates.sort(key=lambda p: (-p.created_timestamp, str(p.id)))
if len(candidates) == 1:
return [str(candidates[0].id)]
# Pre-compute stems for all candidates
stems_map = {}
for p in candidates:
stems_map[p.id] = tokenize_and_stem(
(p.title or '') + ' ' + (p.description or '')
)
# Greedy walk
ring = []
visited = set()
accumulated_stems = set()
# Start with newest product
current = candidates[0]
ring.append(str(current.id))
visited.add(current.id)
accumulated_stems |= stems_map[current.id]
while len(ring) < len(candidates):
best_score = -1
best_candidate = None
for p in candidates:
if p.id in visited:
continue
p_stems = stems_map[p.id]
union = accumulated_stems | p_stems
if union:
score = len(accumulated_stems & p_stems) / len(union)
else:
score = 0
# Yield GIL so uwsgi workers can serve requests
time.sleep(0)
# Tiebreak: highest score, then newest, then ID
if (score > best_score or
(score == best_score and best_candidate is not None and
(p.created_timestamp > best_candidate.created_timestamp or
(p.created_timestamp == best_candidate.created_timestamp and
str(p.id) < str(best_candidate.id))))):
best_score = score
best_candidate = p
if best_candidate is None:
break
ring.append(str(best_candidate.id))
visited.add(best_candidate.id)
accumulated_stems |= stems_map[best_candidate.id]
return ring
def reforge_discovery_ring(shop):
"""Compute and store the discovery ring on the shop (synchronous).
Non-production shops (MPS-14) get an empty ring — they are excluded
from public discovery.
"""
if shop.environment is not None and shop.is_non_production:
shop.discovery_ring = []
return []
ring = compute_discovery_ring(shop)
shop.discovery_ring = ring
return ring
def validate_discovery_ring(shop):
"""Check ring topology health against the shop's public products.
A healthy ring: one entry per visibility==1 product, no duplicates,
no stale IDs, no orphaned products. Any violation surfaces as a
populated list in the returned dict.
Returns:
{
"valid": bool,
"ring_length": int,
"public_count": int,
"duplicates": list[str], # IDs appearing more than once
"orphans": list[str], # public product IDs missing from ring
"stale": list[str], # ring IDs no longer public/present
"length_mismatch": bool, # ring_length != public_count
}
"""
ring = shop.discovery_ring or []
public_ids = {str(p.id) for p in shop.products if p.visibility == 1}
ring_ids = set(ring)
counts = Counter(ring)
duplicates = sorted(pid for pid, c in counts.items() if c > 1)
stale = sorted(ring_ids - public_ids)
orphans = sorted(public_ids - ring_ids)
length_mismatch = len(ring) != len(public_ids)
return {
"valid": not (duplicates or stale or orphans or length_mismatch),
"ring_length": len(ring),
"public_count": len(public_ids),
"duplicates": duplicates,
"orphans": orphans,
"stale": stale,
"length_mismatch": length_mismatch,
}
# Tracks which shops have a background reforge in progress and whether
# another reforge was requested while one was already running (dirty bit).
_reforge_running = {} # shop_id_str -> True while a thread is active
_reforge_dirty = {} # shop_id_str -> True if another trigger arrived mid-run
_reforge_guard = threading.Lock()
def reforge_discovery_ring_async(shop_id, session_factory):
"""Reforge the discovery ring in a background thread.
Uses the old ring until the new one is ready. If a reforge is already
running for this shop, sets a dirty bit so the thread runs once more
after it finishes (picking up the latest product state). Never queues
more than one pending reforge.
"""
shop_id_str = str(shop_id)
with _reforge_guard:
if shop_id_str in _reforge_running:
_reforge_dirty[shop_id_str] = True
return
_reforge_running[shop_id_str] = True
def _reforge():
try:
session = SASession(bind=session_factory().get_bind())
try:
while True:
shop = session.get(Shop, shop_id)
if shop is None:
return
if shop.environment is not None and shop.is_non_production:
shop.discovery_ring = []
session.commit()
log.info("Ring cleared for non-production shop %s", shop.name)
return
ring = compute_discovery_ring(shop)
shop.discovery_ring = ring
session.commit()
log.info("Ring reforged for shop %s (%d products)", shop.name, len(ring))
health = validate_discovery_ring(shop)
if not health["valid"]:
log.warning(
"Ring topology anomaly for shop %s: "
"duplicates=%d orphans=%d stale=%d length_mismatch=%s",
shop.name,
len(health["duplicates"]),
len(health["orphans"]),
len(health["stale"]),
health["length_mismatch"],
)
with _reforge_guard:
if _reforge_dirty.pop(shop_id_str, False):
session.expire_all()
continue # dirty bit set, run again
break
except Exception:
session.rollback()
log.exception("Background ring reforge failed for shop %s", shop_id_str)
finally:
session.close()
finally:
with _reforge_guard:
_reforge_running.pop(shop_id_str, None)
_reforge_dirty.pop(shop_id_str, None)
t = threading.Thread(target=_reforge, daemon=True)
t.start()
return t
def is_shop_name_available(dbsession, name):
return not _shop_by_name_query(dbsession, unicode(name)).count()
def is_shop_name_valid(name):
"""Test if shop name meets our criteria for validity."""
# Choices here are subject to change.
# allow dashes.
name = name.replace("-", "")
# allow period.
name = name.replace(".", "")
# allow spaces.
name = name.replace(" ", "")
# allow ' single quote.
name = name.replace("'", "")
return name.isalnum()
def _shop_by_name_query(dbsession, name):
"""query Shop by case insensitive name."""
return dbsession.query(Shop).filter(func.lower(Shop.name) == unicode(name.lower()))
def _shop_by_domain_name_query(dbsession, domain_name):
"""query Shop by case insensitive domain name."""
return dbsession.query(Shop).filter(
func.lower(Shop.domain_name) == unicode(domain_name.lower())
)
def get_shop_by_name(dbsession, name):
"""Try to get Shop object by name or return None"""
if name:
return _shop_by_name_query(dbsession, name).one_or_none()
def get_shop_by_domain_name(dbsession, domain_name):
"""Try to get Shop object by name or return None"""
if domain_name:
return _shop_by_domain_name_query(dbsession, domain_name).one_or_none()
def get_shop_by_id(dbsession, shop_id):
"""Try to get Shop object by id or return None."""
return get_object_by_id(dbsession, shop_id, Shop)
def get_shops_by_ids(dbsession, shop_ids):
"""Try to get Shop objects by ids or return None."""
return get_objects_by_ids(dbsession, shop_ids, Shop)