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.
1097 lines
38 KiB
Python
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)
|