Merge branch 'shop-locations-with-inventory' into 'master'

stub in new shop_location and inventory models testing CodeRabbit

See merge request engineering/make-post-sell/make_post_sell!10
This commit is contained in:
Russell Ballestrini 2024-06-19 16:39:08 +00:00
commit 29bcc12ea5
36 changed files with 1691 additions and 373 deletions

View file

@ -7,6 +7,7 @@ import zope.sqlalchemy
# Base.metadata prior to any initialization routines.
from .product import *
from .shop import *
from .shop_location import *
from .user import *
from .user_shop import *
from .user_product import *
@ -18,6 +19,7 @@ from .market import *
from .coupon import *
from .coupon_redemption import *
from .invoice import *
from .inventory import *
from .stripe_user_shop import *

View file

@ -6,22 +6,16 @@ from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy import (
Column,
Boolean,
BigInteger,
Unicode,
UnicodeText
)
from sqlalchemy import Column, Boolean, BigInteger, Unicode, UnicodeText
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
bucket_objects_by_id,
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
bucket_objects_by_id,
)
from .product import get_products_by_ids
@ -29,6 +23,8 @@ from .shop import get_shops_by_ids
from .cart_coupon import CartCoupon
from .inventory import get_inventory_by_product_and_shop_location
from ..lib.currency import cents_to_dollars
try:
@ -40,16 +36,20 @@ except:
def timestamp_to_datetime(timestamp):
"""Accepts a milliseconds timestamp integer and returns a datetime"""
from datetime import datetime
return datetime.fromtimestamp(timestamp / 1000.0)
def timestamp_to_ago_string(timestamp):
"""Accepts a timestamp and returns a human readable string"""
from ago import human
return human(timestamp_to_datetime(timestamp), 2, abbreviate=True)
class Cart(RBase, Base):
"""This class tracks a User's shopping carts."""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=True)
@ -57,13 +57,18 @@ class Cart(RBase, Base):
public = Column(Boolean, default=False)
json_cart = Column(UnicodeText, default=unicode("{}"))
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
active = Column(Boolean, default=False)
# many to many uses association_proxy.
coupons = association_proxy("cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c))
coupons = association_proxy(
"cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c)
)
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
@ -85,6 +90,8 @@ class Cart(RBase, Base):
del self._line_totals_in_cents
if hasattr(self, "_products"):
del self._products
if hasattr(self, "_physical_products"):
del self._physical_products
if hasattr(self, "_shops"):
del self._shops
if hasattr(self, "_shop_product_dict"):
@ -122,13 +129,13 @@ class Cart(RBase, Base):
def add_product(self, product):
quantity = 1
if str(product.id) in self.cart:
quantity = self.cart[str(product.id)] + 1
if product.uuid_str in self.cart:
quantity = self.cart[product.uuid_str] + 1
self.set_product_quantity(product, quantity)
def remove_product(self, product):
tmp_cart = self.cart
del tmp_cart[str(product.id)]
del tmp_cart[product.uuid_str]
self.cart = tmp_cart
def set_product_quantity(self, product, quantity):
@ -136,7 +143,7 @@ class Cart(RBase, Base):
if quantity >= 999:
quantity = 999
tmp_cart = self.cart
tmp_cart[str(product.id)] = quantity
tmp_cart[product.uuid_str] = quantity
self.cart = tmp_cart
def get_product_quantity(self, product):
@ -149,6 +156,15 @@ class Cart(RBase, Base):
tmp_cart[product_id] += count
else:
tmp_cart[product_id] = count
self.handling_option = other.handling_option
self.handling_cost_in_cents = self.handling_cost_in_cents
for coupon in other.coupons:
if coupon not in self.coupons:
self.coupons.append(coupon)
# this busts memoization.
self.cart = tmp_cart
@property
@ -161,7 +177,7 @@ class Cart(RBase, Base):
def products(self):
"""
Return a dictionary of unique Product objects in cart.
The key is the product UUID, and the value is the Product object.
"""
if hasattr(self, "_products") == False:
@ -170,6 +186,20 @@ class Cart(RBase, Base):
self._products = bucket_objects_by_id(products)
return self._products
@property
def physical_products(self):
"""
Return a dictionary of physical Product objects in cart.
The key is the product UUID, and the value is the Product object.
"""
if not hasattr(self, "_physical_products"):
self._physical_products = {}
for product_id, product in self.products.items():
if product.is_physical:
self._physical_products[product_id] = product
return self._physical_products
@property
def shops(self):
"""
@ -178,7 +208,6 @@ class Cart(RBase, Base):
The key is the Shop UUID, and the value is the Shop object.
"""
if hasattr(self, "_shops") == False:
self._shops = {}
# loop over Products in cart, aggregate a unique set of shop_ids.
@ -195,18 +224,17 @@ class Cart(RBase, Base):
@property
def shop_product_dict(self):
"""
Return a dict where keys are shop_ids and values are a list of product_ids.
Return a dict where keys are shop_ids (as strings) and values are a list of tuples (product, quantity).
"""
if hasattr(self, "_shop_product_dict") == False:
if not hasattr(self, "_shop_product_dict"):
self._shop_product_dict = {}
for shop_id in self.shops.keys():
self._shop_product_dict[shop_id] = []
for product in self.products.values():
shop_id = product.id_to_uuid_str(product.shop_id)
self._shop_product_dict[shop_id].append(product.uuid_str)
for product_id, quantity in self.cart.items():
product = self.products[product_id]
shop_id = product.shop.uuid_str
if shop_id not in self._shop_product_dict:
self._shop_product_dict[shop_id] = []
self._shop_product_dict[shop_id].append((product, quantity))
return self._shop_product_dict
@ -231,12 +259,14 @@ class Cart(RBase, Base):
@property
def shop_totals_in_cents(self):
if hasattr(self, "_shop_totals_in_cents") == False:
if not hasattr(self, "_shop_totals_in_cents"):
self._shop_totals_in_cents = {}
for shop_id, product_ids in self.shop_product_dict.items():
for shop_id, product_quantity_tuple in self.shop_product_dict.items():
self._shop_totals_in_cents[shop_id] = 0
for product_id in product_ids:
self._shop_totals_in_cents[shop_id] += self.line_totals_in_cents[product_id]
for product, _ in product_quantity_tuple:
self._shop_totals_in_cents[shop_id] += self.line_totals_in_cents[
product.uuid_str
]
return self._shop_totals_in_cents
@property
@ -252,38 +282,65 @@ class Cart(RBase, Base):
"""Discounted shop totals in cents after applying coupons."""
if hasattr(self, "_discounted_shop_totals_in_cents") == False:
from copy import deepcopy
self._discounted_shop_totals_in_cents = deepcopy(self.shop_totals_in_cents)
if len(self.coupons) > 0:
for coupon in sorted(self.coupons):
for shop_uuid, shop_total_in_cents in self.shop_totals_in_cents.items():
for coupon in sorted(self.coupons, key=lambda c: c.id):
for (
shop_uuid,
shop_total_in_cents,
) in self.shop_totals_in_cents.items():
if coupon.shop_uuid_str == shop_uuid:
self._discounted_shop_totals_in_cents[shop_uuid] = coupon.compute_discount(shop_total_in_cents)
self._discounted_shop_totals_in_cents[
shop_uuid
] = coupon.compute_discount(shop_total_in_cents)
return self._discounted_shop_totals_in_cents
@property
def discounted_shop_totals(self):
if hasattr(self, "_discounted_shop_totals") == False:
self._discounted_shop_totals = {}
for shop_id, discounted_shop_total_in_cents in self.discounted_shop_totals_in_cents.items():
self._discounted_shop_totals[shop_id] = cents_to_dollars(discounted_shop_total_in_cents)
for (
shop_id,
discounted_shop_total_in_cents,
) in self.discounted_shop_totals_in_cents.items():
self._discounted_shop_totals[shop_id] = cents_to_dollars(
discounted_shop_total_in_cents
)
return self._discounted_shop_totals
@property
def total_price_in_cents(self):
# TODO: memoize?
return sum(self.line_totals_in_cents.values())
"""
Calculate the total price in cents, including handling cost if applicable.
"""
total = sum(self.line_totals_in_cents.values())
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@property
def total_price(self):
"""
Total price in dollars, including handling cost if applicable.
"""
return cents_to_dollars(self.total_price_in_cents)
@property
def total_discounted_price_in_cents(self):
# TODO: memoize?
return sum(self.discounted_shop_totals_in_cents.values())
"""
Calculate the total discounted price in cents, including handling cost if applicable.
"""
total = sum(self.discounted_shop_totals_in_cents.values())
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@property
def total_discounted_price(self):
"""
Total discounted price in dollars, including handling cost if applicable.
"""
return cents_to_dollars(self.total_discounted_price_in_cents)
@property
@ -294,14 +351,18 @@ class Cart(RBase, Base):
@property
def total(self):
"""The total (after discounts) to be charged to user via an Invoice."""
"""
The total in dollars (after discounts) to be charged to user via an Invoice, including handling cost if applicable.
"""
if self.is_discounted:
return self.total_discounted_price
return self.total_price
@property
def total_in_cents(self):
"""The total in cents (after discounts)."""
"""
The total in cents (after discounts) to be charged to user via an Invoice, including handling cost if applicable.
"""
if self.is_discounted:
return self.total_discounted_price_in_cents
return self.total_price_in_cents
@ -339,25 +400,99 @@ class Cart(RBase, Base):
for coupon in self.coupons:
# test if the shop's cart total qualifies for this coupon.
if coupon.is_not_valid:
error_messages.append("Sorry that coupon is not valid (expired or disabled).")
elif self.shop_totals_in_cents[coupon.shop_uuid_str] < coupon.cart_qualifier:
error_messages.append("Please review coupon terms: shop total not met.")
elif coupon.max_redemptions and coupon.redemptions.count() >= coupon.max_redemptions:
error_messages.append("That coupon was used too many times. Please remove it.")
elif coupon.max_redemptions_per_user and coupon.redemptions.filter_by(user_id=self.user_id).count() >= coupon.max_redemptions_per_user:
error_messages.append("You have already used this coupon. Please remove it.")
error_messages.append(
"Sorry that coupon is not valid (expired or disabled)."
)
elif (
self.shop_totals_in_cents[coupon.shop_uuid_str]
< coupon.cart_qualifier
):
error_messages.append(
"Please review coupon terms: shop total not met."
)
elif (
coupon.max_redemptions
and coupon.redemptions.count() >= coupon.max_redemptions
):
error_messages.append(
"That coupon was used too many times. Please remove it."
)
elif (
coupon.max_redemptions_per_user
and coupon.redemptions.filter_by(user_id=self.user_id).count()
>= coupon.max_redemptions_per_user
):
error_messages.append(
"You have already used this coupon. Please remove it."
)
return error_messages
def check_inventory(self, shop_location):
"""
Check if the shop location has enough quantity for each physical product in the cart.
Returns a list of error messages if any product is out of stock.
"""
error_messages = []
for product_id, product in self.physical_products.items():
quantity_needed = self.cart[product_id]
inventory = get_inventory_by_product_and_shop_location(
self.dbsession, product_id, shop_location.uuid_str
)
if inventory is None or inventory.quantity < quantity_needed:
error_messages.append(
f"Not enough stock for {product.title}. Switch Shop Location and try again. Needed: {quantity_needed}, Available: {inventory.quantity if inventory else 0}"
)
return error_messages
def remove_handling_if_no_physical_products(self):
"""
Remove the handling option and cost if there are no physical products in the cart.
"""
if not self.physical_products:
self.handling_option = None
self.handling_cost_in_cents = 0
def update_handling_cost(self, shop_location):
"""
Update the handling cost based on the provided shop location.
"""
if self.handling_option:
if self.handling_option == "local_pickup":
self.handling_cost_in_cents = 0
elif self.handling_option == "local_delivery":
self.handling_cost_in_cents = shop_location.local_delivery_rate_in_cents
elif self.handling_option == "local_shipping":
self.handling_cost_in_cents = shop_location.local_shipping_rate_in_cents
elif self.handling_option == "international_shipping":
self.handling_cost_in_cents = (
shop_location.international_shipping_rate_in_cents
)
def update_inventory(self, shop_location):
"""
Update the inventory quantity for physical products in the cart.
"""
changed = False
for product_id, product in self.physical_products.items():
quantity_needed = self.cart[product_id]
inventory = get_inventory_by_product_and_shop_location(
self.dbsession, product_id, shop_location.uuid_str
)
if inventory:
inventory.quantity -= quantity_needed
self.dbsession.add(inventory)
changed = True
if changed:
self.dbsession.flush()
def get_all_carts(dbsession):
"""
Return all Cart objects in descending order by
created timestamp (newest first).
"""
return (
dbsession.query(Cart)
.order_by(Cart.created_timestamp.desc())
)
return dbsession.query(Cart).order_by(Cart.created_timestamp.desc())
def get_cart_by_id(dbsession, cart_id):

View file

@ -0,0 +1,50 @@
import uuid
from sqlalchemy import Column, Integer, BigInteger
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
from sqlalchemy.orm import relationship
class Inventory(RBase, Base):
"""This class represents the inventory of a product at a specific location."""
id = Column(UUIDType, primary_key=True, index=True)
shop_location_id = Column(
UUIDType, foreign_key("ShopLocation", "id"), nullable=False
)
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
quantity = Column(Integer, nullable=False, default=0)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
shop_location = relationship("ShopLocation", uselist=False)
product = relationship("Product", uselist=False)
def __init__(self, shop_location, product, quantity=0):
if not product.is_physical:
raise ValueError("Cannot create inventory for a digital product")
self.id = uuid.uuid1()
self.shop_location = shop_location
self.product = product
self.quantity = quantity
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
def update_quantity(self, quantity):
self.quantity = quantity
self.updated_timestamp = now_timestamp()
def get_inventory_by_product_and_shop_location(dbsession, product_id, shop_location_id):
"""
Query the Inventory table by product_id and shop_location_id.
:param dbsession: The SQLAlchemy session object.
:param product_id: The UUID of the product.
:param shop_location_id: The UUID of the shop location.
:return: The Inventory object if found, else None.
"""
return (
dbsession.query(Inventory)
.filter_by(product_id=product_id, shop_location_id=shop_location_id)
.first()
)

View file

@ -1,13 +1,15 @@
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Integer, UnicodeText
from sqlalchemy import Column, BigInteger, Boolean, Integer, UnicodeText, Unicode
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp, get_object_by_id
from sqlalchemy.orm import relationship, backref
from .coupon_redemption import CouponRedemption
from ..lib.currency import cents_to_dollars
class InvoiceLineItem(RBase, Base):
"""
@ -15,6 +17,7 @@ class InvoiceLineItem(RBase, Base):
We should do our best to treat these line items as immutable.
This will let us rely on them as a proof of transactional record.
"""
id = Column(UUIDType, primary_key=True, index=True)
# a way to group multiple transactions into a single reference.
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
@ -57,9 +60,8 @@ class InvoiceLineItem(RBase, Base):
shop=None,
market=None,
quantity=1,
credit=False
credit=False,
):
if price is None:
price = product.current_price
if shop is None:
@ -81,6 +83,7 @@ class Invoice(RBase, Base):
A user may have zero or many invoices
which relate to a purchase transaction.
"""
id = Column(UUIDType, primary_key=True, index=True)
# the user making the purchase.
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
@ -93,6 +96,10 @@ class Invoice(RBase, Base):
# the timestamp when the invoice was created.
created_timestamp = Column(BigInteger, nullable=False)
# optional physical product handling.
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
# the user's address to ship the item to.
delivery_address = Column(UnicodeText, nullable=True)
@ -109,18 +116,20 @@ class Invoice(RBase, Base):
# returns all the InvoiceLineItems.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
line_items = relationship(argument="InvoiceLineItem", lazy="dynamic", back_populates="invoice")
line_items = relationship(
argument="InvoiceLineItem", lazy="dynamic", back_populates="invoice"
)
# one to many.
# returns all the CouponRedemptions for this Invoice.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
coupon_redemptions = relationship(argument="CouponRedemption", lazy="dynamic", back_populates="invoice")
# TODO: We need a method that returns InvoiceLineItem grouped by shop.
# we will need this when we start supporting market places.
#line_items_grouped_by_shop
coupon_redemptions = relationship(
argument="CouponRedemption", lazy="dynamic", back_populates="invoice"
)
# maybe not needed anymore, an invoice is only good for one shop.
# so multiple shops in a cart will mean multiple invoices.
# TODO: We need a way to relate none or many CouponRedemption
# to an Invoice. This will allow us to keep track of where the
# coupons are utilized and how they affect the invoice total.
@ -186,5 +195,38 @@ class Invoice(RBase, Base):
invoice=self,
shop=coupon.shop,
user=self.user,
)
)
)
@property
def total_in_cents(self):
"""Calculate the total amount in cents for the invoice, including handling fee."""
line_items_total = sum(
item.price.price_in_cents * item.quantity for item in self.line_items
)
return line_items_total + (self.handling_cost_in_cents or 0)
@property
def total(self):
"""Total in dollars."""
return cents_to_dollars(self.total_in_cents)
@property
def requires_payment(self):
"""Return True if we should charge a card, else False."""
# Only charge a credit card if invoice total is greater than $0.64!
# Credit card "gas" payment costs $0.30 + 2.9%
# We gift the customer up to $0.32 when we bypass credit card gas.
if self.total_in_cents > 64:
return True
return False
@property
def human_created_timestamp(self):
"""Return the created timestamp in a human-readable format."""
from datetime import datetime
return datetime.fromtimestamp(self.created_timestamp / 1000).strftime('%Y-%m-%d %H:%M:%S')
def get_invoice_by_id(dbsession, invoice_id):
"""Try to get Invoice object by id or return None."""
return get_object_by_id(dbsession, invoice_id, Invoice)

View file

@ -16,8 +16,9 @@ UUIDType = TempUUIDType(binary=False)
CLASS_TO_TABLE = {
"Address": "mps_address",
"Shop": "mps_shop",
"ShopLocation": "mps_shop_location",
"Inventory": "mps_inventory",
"Product": "mps_product",
"Coupon": "mps_coupon",
"CouponRedemption": "mps_coupon_redemption",

View file

@ -104,6 +104,12 @@ class Product(RBase, Base):
# this value should be treated as immutable after object creation.
is_sellable = Column(Boolean, default=False, nullable=False)
# this value should be treated as immutable after object creation.
is_physical = Column(Boolean, default=False, nullable=False)
# Add relationship to inventories
inventories = relationship("Inventory", lazy="joined", back_populates="product")
# lazy="joined" performs a left join to reduce queries, it's magic.
# set uselist=False creates one-to-one relationship.
shop = relationship(argument="Shop", uselist=False, lazy="joined")
@ -121,13 +127,6 @@ class Product(RBase, Base):
"product_users", "user", creator=lambda u: UserProduct(user=u)
)
"""
_users = relationship(UserProduct, order_by=[UserProduct.created_timestamp.desc()])
users = association_proxy(
"_users", "user", creator=lambda u: UserProduct(user=u)
)
"""
file_keys = [
"product",
"preview",
@ -203,13 +202,15 @@ class Product(RBase, Base):
@property
def is_ready(self):
"""internal safety checks prior to allowing the sale of this product."""
"""Internal safety checks prior to allowing the sale of this product."""
if self.is_sellable:
if self.is_bundle:
return True if len(self.bundle_metadata["product-ids"]) >= 1 else False
if self.is_physical:
return "thumbnail1" in self.extensions
return self.has_product_file
# content is always ready, for example a blog post only needs a title & description.
# Content is always ready, for example a blog post only needs a title & description.
return True
@property
@ -241,6 +242,12 @@ class Product(RBase, Base):
"""return slug from title"""
return slugify(self.title)
@property
def total_inventory_quantity(self):
if not self.is_physical:
return None
return sum(inventory.quantity for inventory in self.inventories)
@property
def s3_path(self):
"""return the S3 path shop.id/product.id"""
@ -340,7 +347,7 @@ class Product(RBase, Base):
file_prefix = "" if file_key == "product" else "{}-".format(file_key)
extension = self.extensions.get(file_key, "")
filename = "{}{}.{}".format(file_prefix, self.slug, extension)
content_disposition = "attachment; filename={}".format(filename)
content_disposition = "attachment; filename={}".format(filename)
if file_key in self.file_thumbnail_keys:
content_disposition = "inline; filename={}".format(filename)
@ -502,7 +509,6 @@ def get_products_from_a_shop(shop, visibility=1):
def get_products_by_keywords(dbsession, keywords, shop=None):
scores = {}
hits = {}
@ -512,7 +518,6 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
product_query = dbsession.query(Product)
for keyword in keywords:
keyword_filter = Product.title.ilike("%{}%".format(keyword))
# the product _must_ be public (1).
products = (
@ -520,7 +525,6 @@ def get_products_by_keywords(dbsession, keywords, shop=None):
)
for product in products:
if product.id not in scores:
scores[product.id] = 0
scores[product.id] += 1

View file

@ -99,6 +99,10 @@ class Shop(RBase, Base):
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
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(

View file

@ -0,0 +1,75 @@
import uuid
from sqlalchemy import Column, Unicode, BigInteger, Boolean
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id
from sqlalchemy.orm import relationship
from ..lib.currency import cents_to_dollars, dollars_to_cents
class ShopLocation(RBase, Base):
"""This class represents a physical location of a shop."""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
name = Column(Unicode(64), nullable=False)
address = Column(Unicode(256), nullable=False)
city = Column(Unicode(64), nullable=False)
state = Column(Unicode(64), nullable=False)
country = Column(Unicode(64), nullable=False)
postal_code = Column(Unicode(16), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
# Delivery options
local_pickup = Column(Boolean, default=False, nullable=False)
local_delivery = Column(Boolean, default=False, nullable=False)
local_shipping = Column(Boolean, default=False, nullable=False)
international_shipping = Column(Boolean, default=False, nullable=False)
# Delivery rates in cents
local_delivery_rate_in_cents = Column(BigInteger, nullable=True)
local_shipping_rate_in_cents = Column(BigInteger, nullable=True)
international_shipping_rate_in_cents = Column(BigInteger, nullable=True)
# Operating hours
days_open = Column(Unicode(64), nullable=True) # e.g., "Mon-Fri"
hours_open = Column(Unicode(64), nullable=True) # e.g., "9am-5pm"
shop = relationship("Shop", uselist=False)
def __init__(self, shop, name, address, city, state, country, postal_code):
self.id = uuid.uuid1()
self.shop = shop
self.name = name
self.address = address
self.city = city
self.state = state
self.country = country
self.postal_code = postal_code
self.created_timestamp = now_timestamp()
@property
def local_delivery_rate(self):
return cents_to_dollars(self.local_delivery_rate_in_cents) if self.local_delivery_rate_in_cents else None
@local_delivery_rate.setter
def local_delivery_rate(self, value):
self.local_delivery_rate_in_cents = dollars_to_cents(value)
@property
def local_shipping_rate(self):
return cents_to_dollars(self.local_shipping_rate_in_cents) if self.local_shipping_rate_in_cents else None
@local_shipping_rate.setter
def local_shipping_rate(self, value):
self.local_shipping_rate_in_cents = dollars_to_cents(value)
@property
def international_shipping_rate(self):
return cents_to_dollars(self.international_shipping_rate_in_cents) if self.international_shipping_rate_in_cents else None
@international_shipping_rate.setter
def international_shipping_rate(self, value):
self.international_shipping_rate_in_cents = dollars_to_cents(value)
def get_shop_location_by_id(dbsession, shop_location_id):
"""Try to get ShopLocation object by id or return None."""
return get_object_by_id(dbsession, shop_location_id, ShopLocation)

View file

@ -3,6 +3,7 @@ from .models.cart import get_cart_by_id
from .models.user import get_user_by_id
from .models.shop import get_shop_by_id
from .models.shop import get_shop_by_domain_name
from .models.shop_location import get_shop_location_by_id
from .models.product import get_product_by_id
from . import get_children_settings
@ -30,7 +31,6 @@ def includeme(config):
return user
def add_product(request):
"""Return Product object or None from route."""
product_id = request.matchdict.get("product_id")
@ -55,7 +55,6 @@ def includeme(config):
return get_shop_by_domain_name(request.dbsession, request.domain)
def add_active_cart(request):
"""
Load active cart for user for shop or
create cart and add it to the user as active.
@ -85,6 +84,12 @@ def includeme(config):
request.dbsession.flush()
return cart
def add_shop_location(request):
"""Return ShopLocation from session or None."""
shop_location_id = request.session.get("shop_location_id")
if shop_location_id is not None:
return get_shop_location_by_id(request.dbsession, shop_location_id)
def add_market(request):
"""Return Market object or None."""
return None
@ -155,6 +160,8 @@ def includeme(config):
config.add_request_method(add_shop, "shop", reify=True)
config.add_request_method(add_shop_location, "shop_location", reify=True)
config.add_request_method(add_market, "market", reify=True)
config.add_request_method(add_spam, "spam", reify=True)

View file

@ -50,6 +50,7 @@ def includeme(config):
config.add_route("user_cart_public", "/u/cart/{cart_id}/public")
config.add_route("user_cart_unpublic", "/u/cart/{cart_id}/unpublic")
# cart routes.
config.add_route("cart", "/cart")
config.add_route("cart_checkout", "/cart/checkout")
@ -59,6 +60,10 @@ def includeme(config):
config.add_route("cart_remove_product", "/cart/{cart_id}/remove")
config.add_route("cart_quantity_product", "/cart/{cart_id}/quantity")
config.add_route("cart_handling_option", "/cart/{cart_id}/handling-option")
config.add_route("view_invoice", "/invoices/{invoice_id}")
# coupon routes.
config.add_route("coupon_new1", "/coupon/new")
config.add_route("coupon_new2", "/s/{shop_id}/coupon/new")
@ -70,6 +75,14 @@ def includeme(config):
config.add_route("coupon1", "/s/{shop_id}/coupon/{coupon_id}")
config.add_route("coupon2", "/s/{shop_id}/coupon/{coupon_id}/{slug:.*}")
# shop_location routes.
config.add_route("shop_location_new", "/s/{shop_id}/locations/new")
config.add_route("shop_location_edit", "/s/{shop_id}/locations/{location_id}/edit")
config.add_route("shop_locations", "/s/{shop_id}/locations")
config.add_route(
"shop_location_switch", "/s/{shop_id}/location/{location_id}/switch"
)
# shop routes.
config.add_route("shop_new", "/s/new")
@ -103,9 +116,14 @@ def includeme(config):
config.add_route("product_new", "/p/new")
config.add_route("product", "/p/{product_id}")
config.add_route("product_description_edit", "/p/{product_id}/description/edit")
config.add_route("product_inventory_edit", "/p/{product_id}/inventory/edit")
config.add_route("product_bundle_edit", "/p/{product_id}/bundle/edit")
config.add_route("product_bundle_edit2", "/p/{product_id}/{slug:.*}/bundle/edit")
config.add_route("product_remove_bundled_product", "/p/{product_id}/bundle/remove/{bundled_product_id}")
config.add_route(
"product_remove_bundled_product",
"/p/{product_id}/bundle/remove/{bundled_product_id}",
)
config.add_route("product_edit", "/p/{product_id}/edit")
config.add_route("product_edit2", "/p/{product_id}/{slug:.*}/edit")
config.add_route("product_slug", "/p/{product_id}/{slug:.*}")

View file

@ -0,0 +1,36 @@
"""handling options
Revision ID: 1b3ecdde9e65
Revises: ddfbd6d96915
Create Date: 2024-06-16 14:30:57.649239
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b3ecdde9e65'
down_revision = 'ddfbd6d96915'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_cart', sa.Column('handling_option', sa.Unicode(length=64), nullable=True))
op.add_column('mps_cart', sa.Column('handling_cost_in_cents', sa.BigInteger(), nullable=True))
op.add_column('mps_invoice', sa.Column('handling_option', sa.Unicode(length=64), nullable=True))
op.add_column('mps_invoice', sa.Column('handling_cost_in_cents', sa.BigInteger(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_invoice', 'handling_cost_in_cents')
op.drop_column('mps_invoice', 'handling_option')
op.drop_column('mps_cart', 'handling_cost_in_cents')
op.drop_column('mps_cart', 'handling_option')
# ### end Alembic commands ###

View file

@ -0,0 +1,61 @@
"""physical products shop_locations and inventory
Revision ID: cc6211ec360e
Revises: 7217f39a20a8
Create Date: 2024-06-08 09:40:24.200194
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cc6211ec360e'
down_revision = '7217f39a20a8'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mps_shop_location',
sa.Column('id', UUIDType, nullable=False),
sa.Column('shop_id', UUIDType, nullable=False),
sa.Column('name', sa.Unicode(length=64), nullable=False),
sa.Column('address', sa.Unicode(length=256), nullable=False),
sa.Column('city', sa.Unicode(length=64), nullable=False),
sa.Column('state', sa.Unicode(length=64), nullable=False),
sa.Column('country', sa.Unicode(length=64), nullable=False),
sa.Column('postal_code', sa.Unicode(length=16), nullable=False),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['shop_id'], ['mps_shop.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mps_shop_location_id'), 'mps_shop_location', ['id'], unique=False)
op.create_table('mps_inventory',
sa.Column('id', UUIDType, nullable=False),
sa.Column('shop_location_id', UUIDType, nullable=False),
sa.Column('product_id', UUIDType, nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.Column('updated_timestamp', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['mps_product.id'], ),
sa.ForeignKeyConstraint(['shop_location_id'], ['mps_shop_location.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mps_inventory_id'), 'mps_inventory', ['id'], unique=False)
op.add_column('mps_product', sa.Column('is_physical', sa.Boolean(), nullable=False, server_default=sa.false()))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_product', 'is_physical')
op.drop_index(op.f('ix_mps_inventory_id'), table_name='mps_inventory')
op.drop_table('mps_inventory')
op.drop_index(op.f('ix_mps_shop_location_id'), table_name='mps_shop_location')
op.drop_table('mps_shop_location')
# ### end Alembic commands ###

View file

@ -0,0 +1,46 @@
"""shop_location rates and hours
Revision ID: ddfbd6d96915
Revises: cc6211ec360e
Create Date: 2024-06-08 11:38:38.812584
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ddfbd6d96915'
down_revision = 'cc6211ec360e'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_shop_location', sa.Column('local_pickup', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('mps_shop_location', sa.Column('local_delivery', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('mps_shop_location', sa.Column('local_shipping', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('mps_shop_location', sa.Column('international_shipping', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('mps_shop_location', sa.Column('local_delivery_rate_in_cents', sa.BigInteger(), nullable=True))
op.add_column('mps_shop_location', sa.Column('local_shipping_rate_in_cents', sa.BigInteger(), nullable=True))
op.add_column('mps_shop_location', sa.Column('international_shipping_rate_in_cents', sa.BigInteger(), nullable=True))
op.add_column('mps_shop_location', sa.Column('days_open', sa.Unicode(length=64), nullable=True))
op.add_column('mps_shop_location', sa.Column('hours_open', sa.Unicode(length=64), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_shop_location', 'hours_open')
op.drop_column('mps_shop_location', 'days_open')
op.drop_column('mps_shop_location', 'international_shipping_rate_in_cents')
op.drop_column('mps_shop_location', 'local_shipping_rate_in_cents')
op.drop_column('mps_shop_location', 'local_delivery_rate_in_cents')
op.drop_column('mps_shop_location', 'international_shipping')
op.drop_column('mps_shop_location', 'local_shipping')
op.drop_column('mps_shop_location', 'local_delivery')
op.drop_column('mps_shop_location', 'local_pickup')
# ### end Alembic commands ###

View file

@ -28,6 +28,10 @@
<br/>
<a href="/s/{{ request.shop.id }}/settings" class="product-edit-button mps-button">&#9881; &nbsp Shop Settings</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/locations" class="product-edit-button mps-button">&#127968; &nbsp Shop Locations</a>
{% if request.is_saas_domain %}
<br/>
<br/>

View file

@ -56,7 +56,11 @@
</section>
<section class="log-in">
{% if request.shop and request.shop.shop_locations.all()|length > 1 %}
<a class="cart-and-count" href="{{ request.route_url('shop_locations', shop_id=request.shop.id, _query={'next': request.url}) }}">Shop Locations</a>
{% endif %}
{% if request.user and request.user.authenticated %}
{% if request.user.full_name %}
@ -67,7 +71,7 @@
{% else %}
<a href="/join-or-log-in">My Account</a>
<a href="{{ request.route_url('join-or-log-in', _query={'next': request.url}) }}">My Account</a>
{% endif %}
<a href="/cart" class="cart-and-count">&#128722; Cart ${{ '%0.2f' % request.active_cart.total }} ({{ request.active_cart.count }})</a>

View file

@ -80,7 +80,7 @@
{% else %}
{% for shop_id, product_ids in shop_product_dict.items() %}
{% for shop_id, product_quantity_tuples in shop_product_dict.items() %}
{% set shop = shops[shop_id] %}
@ -90,11 +90,10 @@
<b style="font-size: 1.5em;">{{ shop.name }}</b>
</div>
{% for product_id in product_ids %}
{% for product, quantity in product_quantity_tuples %}
{% set product = products[product_id] %}
{% set product_quantity = cart.cart[product_id] %}
{% set line_total = cart.line_totals[product_id] %}
{% set product_quantity = quantity %}
{% set line_total = cart.line_totals[product.uuid_str] %}
<div class="cart-item">
{% if "thumbnail1" in product.extensions %}
@ -131,6 +130,31 @@
{% endfor %}
<!-- Handling Options -->
{% if cart.physical_products %}
<h3>Physical Handling Options</h3>
<form action="{{ request.route_url('cart_handling_option', cart_id=cart.id) }}" method="POST">
{% if request.shop_location.local_pickup %}
<input type="radio" id="local_pickup" name="handling_option" value="local_pickup" {% if cart.handling_option == "local_pickup" %}checked{% endif %} required>
<label for="local_pickup" style="display: inline;">Local Pickup (Free)</label><br>
{% endif %}
{% if request.shop_location.local_delivery %}
<input type="radio" id="local_delivery" name="handling_option" value="local_delivery" {% if cart.handling_option == "local_delivery" %}checked{% endif %} required>
<label for="local_delivery" style="display: inline;">Local Delivery (${{ '%0.2f' % request.shop_location.local_delivery_rate }})</label><br>
{% endif %}
{% if request.shop_location.local_shipping %}
<input type="radio" id="local_shipping" name="handling_option" value="local_shipping" {% if cart.handling_option == "local_shipping" %}checked{% endif %} required>
<label for="local_shipping" style="display: inline;">Local Shipping (${{ '%0.2f' % request.shop_location.local_shipping_rate }})</label><br>
{% endif %}
{% if request.shop_location.international_shipping %}
<input type="radio" id="international_shipping" name="handling_option" value="international_shipping" {% if cart.handling_option == "international_shipping" %}checked{% endif %} required>
<label for="international_shipping" style="display: inline;">International Shipping (${{ '%0.2f' % request.shop_location.international_shipping_rate }})</label><br>
{% endif %}
<input type="submit" value="Set Handling Option">
</form>
{% endif %}
<div style="text-align: right; grid-column: span 3">
{% if cart.is_discounted %}
@ -148,7 +172,6 @@
{% endfor %}
<br/>
<br/>
@ -173,7 +196,7 @@
<h2>Total: ${{ '%0.2f' % cart.total }}</h2>
{% endif %}
{% if not cart.is_empty %}
{% if not cart.is_empty and request.user == cart.user %}
<a href="/u/cart/{{ cart.id }}/checkout" class="cart-checkout-button mps-button">Checkout</a>
@ -193,9 +216,9 @@
<a href="/u/cart/save" class="cart-save-button mps-button">Save Cart</a>
<br/>
{% elif request.user and request.active_cart != cart %}
{% elif request.active_cart != cart %}
{% if request.user == cart.user %}
{% if request.user and request.user == cart.user %}
<a href="/u/cart/{{ cart.id }}/activate" class="cart-activate-button mps-button">Make Cart Active</a>
<br/>
{% else %}

View file

@ -0,0 +1,40 @@
{% extends "base.j2" -%}
{% block content -%}
<h1>Invoice Details</h1>
<section class="invoice-details">
<h2>Invoice ID: {{ invoice.id }}</h2>
<p><strong>User:</strong> {{ invoice.user.name }}</p>
<p><strong>Shop:</strong> {{ invoice.shop.name }}</p>
<p><strong>Created:</strong> {{ invoice.human_created_timestamp }}</p>
<p><strong>Delivery Address:</strong> {{ invoice.delivery_address }}</p>
<p><strong>Handling Option:</strong> {{ invoice.handling_option }}</p>
<p><strong>Handling Cost:</strong> ${{ '%0.2f' % (invoice.handling_cost_in_cents / 100) }}</p>
<p><strong>Total:</strong> ${{ '%0.2f' % invoice.total }}</p>
<h3>Line Items</h3>
<table>
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for item in line_items %}
<tr>
<td>{{ item.product.title }}</td>
<td>{{ item.quantity }}</td>
<td>${{ '%0.2f' % (item.price.price_in_cents / 100) }}</td>
<td>${{ '%0.2f' % ((item.price.price_in_cents * item.quantity) / 100) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -27,6 +27,11 @@
required
autofocus />
<input
name = "next"
type = "hidden"
value = "{{ next }}" />
<br/>
<br/>

View file

@ -58,24 +58,41 @@
<h1>${{ '%0.2f' % product.price }}</h1>
{% if product.is_ready %}
{% if signed_get_object_url is not none %}
<a href="{{ signed_get_object_url }}" class="product-download-button mps-button" download>&#11123 Download</a>
<br/>
<br/>
<hr/>
<br/>
{% if not product.is_physical %}
{% if signed_get_object_url is not none %}
<a href="{{ signed_get_object_url }}" class="product-download-button mps-button" download>&#11123 Download</a>
<br/>
<br/>
<hr/>
<br/>
{% endif %}
{% endif %}
<a href="/cart/add?product_id={{ product.id }}" class="product-download-button mps-button">Add To Cart</a>
<br/>
{% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %}
<br/>
Already purchased? <a href="/join-or-log-in">log in</a>
<br/>
<br/>
<br/>
{% if product.is_physical %}
{% set inventory = product.inventories | selectattr('shop_location_id', 'equalto', request.shop_location.id) | first %}
{% if inventory and inventory.quantity > 0 %}
<a href="/cart/add?product_id={{ product.id }}" class="product-download-button mps-button">Add To Cart</a>
{% else %}
<button class="product-download-button mps-button" style="background-color: red; cursor: not-allowed;" disabled>Sold Out</button>
{% if request.shop.shop_locations.all()|length > 1 %}
<p>This item is sold out at this location.
<br> You can <a href="{{ request.route_url('shop_locations', shop_id=request.shop.id, _query={'next': request.url}) }}">switch shop locations</a> to check availability.</p>
{% endif %}
{% endif %}
{% else %}
<a href="/cart/add?product_id={{ product.id }}" class="product-download-button mps-button">Add To Cart</a>
{% endif %}
<br/>
{% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %}
<br/>
Already purchased? <a href="/join-or-log-in">log in</a>
<br/>
<br/>
<br/>
{% endif %}
{% if "preview" in product.extensions %}
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview?ts={{ product.updated_timestamp }}" class="product-preview-button mps-button" download>View Preview</a>

View file

@ -33,8 +33,7 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
<li>
<form method="post" action="/p/{{ product.id }}/bundle/remove/{{ p.id }}" onsubmit="submit.disabled = true; return true;">
{{p.title}}
<input type="submit" name="submit" class="mps-submit" value="remove" style
="width: 80px;"/>
<input type="submit" name="submit" class="mps-submit" value="remove" style="width: 80px;"/>
</form>
</li>
<br>
@ -42,6 +41,38 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
</ul>
{% endif %}
{% elif product.is_physical %}
<h3>Inventory Management</h3>
<form method="post" action="/p/{{ product.id }}/inventory/edit" onsubmit="submit.disabled = true; return true;">
<table>
<thead>
<tr>
<th>Location</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{% for location in locations %}
<tr>
<td>{{ location.name }}</td>
<td>
<input
type="number"
name="inventory_{{ location.id }}"
value="{{ inventories[location.id] if location.id in inventories else 0 }}"
min="0"
required
/>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="submit" name="submit" class="mps-submit" value="Update Inventory" />
</form>
{% else %}
<h3>Upload or Replace Product File</h3>
@ -63,8 +94,6 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
</form>
{% endif %}
{% if product.is_sellable %}
<hr/>
@ -88,12 +117,11 @@ permanent link: <a href="{{ product.absolute_url(request) }}">{{ product.absolut
<input type="submit" name="submit" class="mps-upload" value="Upload Preview" />
</form>
{% endif %}
{% endif %}
</section>
<section class="upload-thumbnails well2">
<h3>Upload or Replace Thumbnail Files</h3>
@ -103,7 +131,6 @@ Your product cover (<code>thumbnail1</code>) will show up on search pages.
<br />
</section>
{% set s3_key_thumbnails = product.s3_key_thumbnails %}
{% for thumbnail_key in product.file_thumbnail_keys %}
@ -130,7 +157,6 @@ Your product cover (<code>thumbnail1</code>) will show up on search pages.
</div>
{% endfor %}
</section>

View file

@ -38,7 +38,7 @@
id = "description_input"
class = "mps-product-description"
placeholder = "Product Description"
required />{{ description }}</textarea>
required>{{ description }}</textarea>
<br />
<br />
@ -57,16 +57,47 @@
<br />
<label for="is_bundle_input">Product Bundle?</label>
<input type="checkbox" name="is_bundle" id="is_bundle_input"> If checked, this product will bundle other existing products.
<input type="checkbox" name="is_bundle" id="is_bundle_input" {% if is_bundle %}checked{% endif %}> If checked, this product will bundle other existing products.
<br>(playlist, gallery, album, collection, series, etc.)
<br />
<br />
<label>Product Type</label>
<div class="radio-group">
<input type="radio" id="digital" name="is_physical" value="false" {% if not is_physical %}checked{% endif %}>
<label for="digital">Digital</label>
{% if shop_has_locations %}
<input type="radio" id="physical" name="is_physical" value="true" {% if is_physical %}checked{% endif %}>
<label for="physical">Physical</label>
{% else %}
<input type="radio" id="physical" name="is_physical" value="true" disabled>
<label for="physical">Physical (Add a <a href="{{ create_location_url }}">location</a> first)</label>
{% endif %}
</div>
<br />
<br />
<input type="hidden" name="is_sellable" id="is_sellable_input" value="on" required />
<input type="submit" name="submit" class="mps-submit" value="Continue" />
</form>
</section>
</section>
<style>
.radio-group {
display: flex;
align-items: center;
}
.radio-group input[type="radio"] {
margin-right: 5px;
}
.radio-group label {
margin-right: 15px;
}
</style>
{%- endblock -%}

View file

@ -0,0 +1,200 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="shop-name-and-description well">
<h3>{{ "Edit" if location_id else "New" }} Shop Location</h3>
<form method="post" action="{{ action_url }}" onsubmit="submit.disabled = true; return true;">
<label for="name_input">Location Name</label>
<input
name = "name"
type = "text"
id = "name_input"
class = "mps-location-name"
value = "{{ name }}"
placeholder = "Location Name"
required />
<br />
<br />
<label for="address_input">Address</label>
<textarea
name = "address"
id = "address_input"
class = "mps-address"
placeholder = "Address"
required />{{ address }}</textarea>
<br />
<br />
<label for="city_input">City</label>
<input
name = "city"
type = "text"
id = "city_input"
class = "mps-city"
value = "{{ city }}"
placeholder = "City"
required />
<br />
<br />
<label for="state_input">State</label>
<input
name = "state"
type = "text"
id = "state_input"
class = "mps-state"
value = "{{ state }}"
placeholder = "State"
required />
<br />
<br />
<label for="country_input">Country</label>
<input
name = "country"
type = "text"
id = "country_input"
class = "mps-country"
value = "{{ country }}"
placeholder = "Country"
required />
<br />
<br />
<label for="postal_code_input">Postal Code</label>
<input
name = "postal_code"
type = "text"
id = "postal_code_input"
class = "mps-postal-code"
value = "{{ postal_code }}"
placeholder = "Postal Code"
required />
<br />
<br />
<label for="days_open_input">Days Open</label>
<input
name = "days_open"
type = "text"
id = "days_open_input"
class = "mps-days-open"
value = "{{ days_open }}"
placeholder = "Mon-Fri"
required />
<br />
<br />
<label for="hours_open_input">Hours Open</label>
<input
name = "hours_open"
type = "text"
id = "hours_open_input"
class = "mps-hours-open"
value = "{{ hours_open }}"
placeholder = "9am-5pm"
required />
<br />
<br />
<input
name = "local_pickup"
type = "checkbox"
id = "local_pickup_input"
class = "mps-local-pickup"
{% if local_pickup %}checked{% endif %} />
<label for="local_pickup_input" style="display: inline;">Local Pickup</label>
<br />
<br />
<input
name = "local_delivery"
type = "checkbox"
id = "local_delivery_input"
class = "mps-local-delivery"
{% if local_delivery %}checked{% endif %} />
<label for="local_delivery_input" style="display: inline;">Local Delivery</label>
<br />
<br />
<label for="local_delivery_rate_input">Local Delivery Rate</label>
<input
name = "local_delivery_rate"
type = "text"
id = "local_delivery_rate_input"
class = "mps-local-delivery-rate"
value = "{{ local_delivery_rate }}"
placeholder = "0.00" />
<br />
<br />
<input
name = "local_shipping"
type = "checkbox"
id = "local_shipping_input"
class = "mps-local-shipping"
{% if local_shipping %}checked{% endif %} />
<label for="local_shipping_input" style="display: inline;">Local Shipping</label>
<br />
<br />
<label for="local_shipping_rate_input">Local Shipping Rate</label>
<input
name = "local_shipping_rate"
type = "text"
id = "local_shipping_rate_input"
class = "mps-local-shipping-rate"
value = "{{ local_shipping_rate }}"
placeholder = "0.00" />
<br />
<br />
<input
name = "international_shipping"
type = "checkbox"
id = "international_shipping_input"
class = "mps-international-shipping"
{% if international_shipping %}checked{% endif %} />
<label for="international_shipping_input" style="display: inline;">International Shipping</label>
<br />
<br />
<label for="international_shipping_rate_input">International Shipping Rate</label>
<input
name = "international_shipping_rate"
type = "text"
id = "international_shipping_rate_input"
class = "mps-international-shipping-rate"
value = "{{ international_shipping_rate }}"
placeholder = "0.00" />
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="{{ "Update Location" if location_id else "Create Location" }}" />
</form>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,27 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="shop-name-and-description well">
<h3>Shop Locations</h3>
<ul>
{% for location in locations %}
<li>
<a href="{{ request.route_url('shop_location_switch', shop_id=shop.id, location_id=location.id, _query={'next': request.params.get('next', request.url)}) }}">{{ location.name }}</a>
{% if request.user and request.user.can_edit_shop(request.shop) %}
<a href="{{ request.route_url('shop_location_edit', shop_id=shop.id, location_id=location.id) }}" class="button">&#9881; edit</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% if request.user and request.user.can_edit_shop(request.shop) %}
<a href="{{ request.route_url('shop_location_new', shop_id=shop.id) }}" class="shop-new-button mps-button">Add New Location</a>
{% endif %}
</section>
</section>
{%- endblock -%}

View file

@ -10,7 +10,7 @@ tr { text-align: left;}
<table style="width=100%; max-width: 100%; table-layout : fixed;">
<thead>
<tr> <th> Sellable <th> Price <th> Visibility <th> Bundle <th> Ready <th> Title <th> Size
<tr> <th> Sellable <th> Price <th> Visibility <th> Bundle <th> Physical <th> Ready <th> Title <th> Size
<tbody style="width=100%; max-width: 100%;">
{% for product in products %}
@ -20,6 +20,7 @@ tr { text-align: left;}
<td><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '%0.2f' % product.price }}</a></td>
<td>{{ product.human_visibility }}</td>
<td>{{ product.is_bundle }}</td>
<td>{{ product.is_physical }}</td>
<td>{{ product.is_ready }}</td>
<td><b><a href="{{ product.absolute_url(request) }}">{{ product.title }}</a></b></td>
<td>{{ product.human_total_file_bytes }}</td>

View file

@ -26,6 +26,11 @@
required
autofocus />
<input
name = "next"
type = "hidden"
value = "{{ next }}" />
<br/>
<br/>

View file

@ -5,6 +5,7 @@ def get_referer_or_home(request):
"""return referer or or '/'"""
return request.referer if request.referer is not None else "/"
# view decorator.
def user_required(
flash_msg="You must log in to access that area.",
@ -14,16 +15,19 @@ def user_required(
"""This view requires that the request has a user."""
def wrapped(fn):
def inner(request):
if request.user and request.user.authenticated:
return fn(request)
request.session.flash((flash_msg, flash_level))
next_url = request.url
if redirect_to_route_name:
return HTTPFound(request.route_url(redirect_to_route_name))
return HTTPFound(
request.route_url(redirect_to_route_name, _query={"next": next_url})
)
return HTTPFound(get_referer_or_home(request))
return inner
return wrapped
@ -35,7 +39,6 @@ def shop_is_ready_required(
"""This view requires that the request has a shop and that show is_ready."""
def wrapped(fn):
def inner(request):
if request.shop and request.shop.is_ready:
return fn(request)
@ -65,11 +68,11 @@ def shop_owner_required(
):
return fn(request)
request.session.flash(
(flash_msg, flash_level)
)
request.session.flash((flash_msg, flash_level))
return HTTPFound(get_referer_or_home(request))
return inner
return wrapped
@ -91,9 +94,9 @@ def shop_editor_required(
):
return fn(request)
request.session.flash(
(flash_msg, flash_level)
)
request.session.flash((flash_msg, flash_level))
return HTTPFound(get_referer_or_home(request))
return inner
return wrapped

View file

@ -58,7 +58,9 @@ def join_or_log_in(request):
if user.throttle_password():
msg = (
"Check email for a 6 digit verification code to log in. {}".format(user.email),
"Check email for a 6 digit verification code to log in. {}".format(
user.email
),
"info",
)
@ -72,23 +74,27 @@ def join_or_log_in(request):
send_verification_digits_to_email(request, user.email, raw_otp)
msg = (
"Check email for a 6 digit verification code to log in. {}".format(user.email),
"Check email for a 6 digit verification code to log in. {}".format(
user.email
),
"info",
)
request.session.flash(msg)
email_encoded = urlencode({"email": email})
email_encoded = urlencode(
{"email": email, "next": request.params.get("next", "")}
)
return HTTPFound("/verification-challenge?{}".format(email_encoded))
return {
"title": "join or log in",
"next": request.params.get("next", request.route_url("home")),
}
@view_config(route_name="verification-challenge", renderer="verification-challenge.j2")
def verification_challenge(request):
# get the raw OTP (one-time-password) from posted parameters.
raw_otp = request.params.get("raw-otp", "")
@ -124,9 +130,14 @@ def verification_challenge(request):
# save user and user's active cart.
request.dbsession.add(user)
request.dbsession.add(request.active_cart)
request.dbsession.flush()
return HTTPFound("/")
if request.active_cart.count > 0:
next_url = "/cart"
else:
next_url = request.params.get("next", request.route_url("home"))
return HTTPFound(next_url)
else:
msg = ("Invalid Verification Code", "error")
@ -136,4 +147,5 @@ def verification_challenge(request):
"title": "Please Enter Verification Code",
"email": email,
"raw_otp": raw_otp,
"next": request.params.get("next", request.route_url("home")),
}

View file

@ -3,8 +3,8 @@ from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound, HTTPBadRequest
from . import (
user_required,
shop_is_ready_required,
user_required,
shop_is_ready_required,
)
@ -12,7 +12,7 @@ from . import (
@user_required(
flash_msg="Verify your email to access Payment Preferences.",
flash_level="info",
redirect_to_route_name="join-or-log-in"
redirect_to_route_name="join-or-log-in",
)
@shop_is_ready_required()
def billing(request):
@ -31,9 +31,8 @@ def billing(request):
if card.id == active_card.id:
del cards[i]
setup_intent = request.shop.stripe.SetupIntent.create(
payment_method_types=["card"],
customer=stripe_user_shop.cus_id
setup_intent = request.shop.stripe.SetupIntent.create(
payment_method_types=["card"], customer=stripe_user_shop.cus_id
)
return {
"client_secret": setup_intent.client_secret,
@ -41,18 +40,19 @@ def billing(request):
"active_card": active_card,
}
@view_config(route_name="add-card")
@user_required()
@shop_is_ready_required()
def add_card(request):
stripe_setup_intent = request.params.get("setup_intent")
stripe_setup_intent_client_secret = request.params.get("setup_intent_client_secret")
stripe_user_shop = request.shop.stripe_user_shop(request.user)
if stripe_setup_intent:
if stripe_setup_intent:
setup_intent = request.shop.stripe.SetupIntent.retrieve(
stripe_setup_intent,
stripe_setup_intent,
)
card_ids = stripe_user_shop.stripe_card_ids
@ -84,6 +84,7 @@ def confirm_update_card(request):
"the_title": action_human.title(),
}
@view_config(route_name="update-card")
@user_required()
@shop_is_ready_required()

View file

@ -48,7 +48,7 @@ def save_cart(request):
@user_required(
flash_msg="To <b>save</b> your cart, please verify your email address below.",
flash_level="info",
redirect_to_route_name="join-or-log-in"
redirect_to_route_name="join-or-log-in",
)
def cart_save(request):
save_cart(request)
@ -67,7 +67,10 @@ def cart_public(request):
msg = ("You do not own that cart.", "error")
else:
msg = ("You made that cart public, you may now share the cart's link with others.", "success")
msg = (
"You made that cart public, you may now share the cart's link with others.",
"success",
)
cart.public = True
request.dbsession.add(cart)
request.dbsession.flush()
@ -98,7 +101,6 @@ def cart_unpublic(request):
@view_config(route_name="user_cart_activate")
@user_required()
def cart_activate(request):
cart = get_cart_from_matchdict(request)
@ -116,7 +118,7 @@ def cart_activate(request):
msg = ("This cart is already your active cart.", "success")
else:
if request.user.owns_cart(cart):
if request.user and request.user.owns_cart(cart):
msg = ("You activated that cart.", "success")
request.shop.make_cart_active_for_user(request.user, cart)
else:
@ -125,6 +127,7 @@ def cart_activate(request):
new_cart.merge_in_cart(cart)
request.dbsession.add(new_cart)
request.session.flash(msg)
request.session["active_cart_id"] = new_cart.uuid_str
return HTTPFound("/cart/{}".format(new_cart.id))
request.session.flash(msg)
@ -164,21 +167,28 @@ def cart(request):
@view_config(route_name="cart_by_id", renderer="cart.j2")
def cart_by_id(request):
cart = get_cart_from_matchdict(request)
if cart is None:
msg = ("That cart_id does not exist.", "error")
elif cart.active or cart.public or (request.user and request.user.authenticated and request.user.owns_cart(cart)):
elif (
cart.user is None
or cart.public
or (
request.user and request.user.authenticated and request.user.owns_cart(cart)
)
):
# remove invalid coupons from cart, if any.
for coupon in cart.coupons:
if coupon.is_not_valid:
cart.coupons.remove(coupon)
request.dbsession.add(cart)
request.dbsession.flush()
msg = ("Invalid coupon ({}) removed from cart.".format(coupon.code), "info")
msg = (
"Invalid coupon ({}) removed from cart.".format(coupon.code),
"info",
)
request.session.flash(msg)
return {
@ -191,7 +201,10 @@ def cart_by_id(request):
"total_discounted_price": cart.total_discounted_price,
}
else:
msg = ("You may not view that cart. It is not public and you do not own it.", "error")
msg = (
"You may not view that cart. It is not public and you do not own it.",
"error",
)
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
@ -199,7 +212,6 @@ def cart_by_id(request):
@view_config(route_name="cart_add_product")
def cart_add_product(request):
product_id = request.params.get("product_id", None)
if product_id is None:
@ -209,32 +221,32 @@ def cart_add_product(request):
product = get_product_by_id(request.dbsession, product_id)
if product is None:
if request.user and request.user.does_not_own_cart(request.active_cart):
msg = ("You do not own this cart.", "error")
elif product is None:
msg = ("invalid product_id.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
elif product.is_not_ready:
msg = ("that product is not ready for purchase.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
elif product.is_not_sellable:
msg = ("that content is not for sale or purchase.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
msg = ('You added "{}" to your cart.'.format(product.title), "success")
request.active_cart.add_product(product)
request.dbsession.add(request.active_cart)
request.dbsession.flush()
else:
msg = ('You added "{}" to your cart.'.format(product.title), "success")
request.active_cart.add_product(product)
request.dbsession.add(request.active_cart)
request.dbsession.flush()
request.session.flash(msg)
return HTTPFound("/cart")
request.session.flash(msg)
return HTTPFound("/cart")
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="cart_remove_product")
def cart_remove_product(request):
product_id = request.params.get("product_id", None)
if product_id is None:
@ -248,25 +260,31 @@ def cart_remove_product(request):
if cart is None:
msg = ("That cart_id does not exist.", "error")
elif request.user and request.user.does_not_own_cart(cart):
msg = ("You do not own this cart.", "error")
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
elif product is None:
msg = ("That product_id does not exist.", "error")
elif cart.active or (request.user and request.user.authenticated and request.user.owns_cart(cart)):
msg = ("You removed \"{}\" from the cart.".format(product.title), "success")
else:
msg = ('You removed "{}" from the cart.'.format(product.title), "success")
cart.remove_product(product)
cart.remove_handling_if_no_physical_products()
request.dbsession.add(cart)
request.dbsession.flush()
else:
msg = ("You do not own that cart.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="cart_quantity_product")
def cart_quantity_product(request):
product_id = request.params.get("product_id", None)
quantity = request.params.get("quantity", None)
@ -278,7 +296,6 @@ def cart_quantity_product(request):
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
if product_id is None or quantity is None or quantity == "":
msg = ("missing product_id or quantity.", "error")
request.session.flash(msg)
@ -294,6 +311,16 @@ def cart_quantity_product(request):
if cart is None:
msg = ("That cart_id does not exist.", "error")
elif request.user and request.user.does_not_own_cart(cart):
msg = ("You do not own this cart.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
elif product is None:
msg = ("That product_id does not exist.", "error")
request.session.flash(msg)
@ -302,32 +329,64 @@ def cart_quantity_product(request):
# then refuse to add a product of another shop.
# we could keep a simple allow list of marketplace domains.
elif not request.is_saas_domain and request.shop.uuid_str != product.shop_uuid_str:
request.session.flash(("Refusing to add a product from another shop to cart.", "error"))
request.session.flash(
("Refusing to add a product from another shop to cart.", "error")
)
return HTTPFound("/cart")
elif cart.active or (request.user and request.user.authenticated and request.user.owns_cart(cart)):
msg = ("You updated the quantity of \"{}\" in the cart.".format(product.title), "success")
else:
msg = (
'You updated the quantity of "{}" in the cart.'.format(product.title),
"success",
)
cart.set_product_quantity(product, quantity)
request.dbsession.add(cart)
request.dbsession.flush()
else:
msg = ("You do not own that cart.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="cart_handling_option", request_method="POST")
def cart_handling_option(request):
cart = get_cart_from_matchdict(request)
handling_option = request.params.get("handling_option")
if request.user and request.user.does_not_own_cart(cart):
request.session.flash(("You do not own this cart.", "error"))
return HTTPFound(get_referer_or_home(request))
if request.user is None and cart.user is not None:
request.session.flash(("You do not own this cart.", "error"))
return HTTPFound(get_referer_or_home(request))
if handling_option not in [
"local_pickup",
"local_delivery",
"local_shipping",
"international_shipping",
]:
request.session.flash(("Invalid handling option selected.", "error"))
return HTTPFound(get_referer_or_home(request))
cart.handling_option = handling_option
cart.update_handling_cost(request.shop_location)
request.dbsession.add(cart)
request.dbsession.flush()
request.session.flash(("Handling option set successfully.", "success"))
return HTTPFound(f"/cart/{cart.id}")
@view_config(route_name="cart_checkout", renderer="cart_checkout.j2")
@view_config(route_name="user_cart_checkout", renderer="cart_checkout.j2")
@user_required(
flash_msg="To <b>checkout</b> your cart, please verify your email address below.",
flash_level="info",
redirect_to_route_name="join-or-log-in"
redirect_to_route_name="join-or-log-in",
)
@shop_is_ready_required()
def cart_checkout(request):
stripe_user_shop = request.shop.stripe_user_shop(request.user)
if "cart_id" not in request.matchdict:
@ -344,30 +403,49 @@ def cart_checkout(request):
elif cart.is_empty:
msg = ("That cart is empty, you cannot checkout.", "error")
elif cart.requires_payment and stripe_user_shop is None:
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
elif stripe_user_shop.active_card is None:
msg = ("Please make a payment method active.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
else:
# make sure all coupon terms are met before proceeding.
# Validate coupons first
error_messages = cart.validate_attached_coupons()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# Check handling options, inventory, and address for physical products
if cart.physical_products:
inventory_errors = cart.check_inventory(request.shop_location)
if inventory_errors:
for error_message in inventory_errors:
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
if not cart.handling_option:
msg = ("Please select a handling option for your physical items.", "error")
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
if not request.user.active_address:
msg = ("Please add a shipping address to proceed with checkout.", "info")
request.session.flash(msg)
return HTTPFound("/u/addresses")
# Check for payment information
if cart.requires_payment and stripe_user_shop is None:
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
if stripe_user_shop.active_card is None:
msg = ("Please make a payment method active.", "info")
request.session.flash(msg)
return HTTPFound("/billing")
msg = ("Please confirm your order.", "info")
request.session.flash(msg)
return {
"cart": cart,
"products": cart.products,
"active_card": stripe_user_shop.active_card
"active_card": stripe_user_shop.active_card,
}
request.session.flash(msg)
@ -399,74 +477,99 @@ def cart_complete_checkout(request):
error_messages = cart.validate_attached_coupons()
if error_messages:
for error_message in error_messages:
request.session.flash((error_message, "error"))
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# TODO: currently this routine is naive to "market" invoices, an Invoice
# which has InvoiceLineItems for Products derived from more than one Shop.
invoice = Invoice(user=request.user)
invoice.shop = request.shop
invoice.market = request.market
for product_id, quantity in cart.cart.items():
product = cart.products[product_id]
product.unlock_for_user(request.user)
request.dbsession.add(product)
invoice.new_line_item(
product=product,
quantity=quantity,
market=invoice.market,
)
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
request.dbsession.add(invoice)
invoices = []
successful_checkout = True
# only create a real charge if the cart has a positive price.
# this conditional allows us to support freebies.
if cart.requires_payment:
for shop_id, product_quantity_tuple in cart.shop_product_dict.items():
shop = cart.shops[shop_id]
invoice = Invoice(user=request.user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
stripe_user_shop = request.shop.stripe_user_shop(request.user)
try:
request.shop.stripe.PaymentIntent.create(
amount=cart.total_in_cents,
currency='usd',
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
# Copy the user's active address to the invoice
if cart.physical_products:
invoice.delivery_address = request.user.active_address.data
for product, quantity in product_quantity_tuple:
product.unlock_for_user(request.user)
request.dbsession.add(product)
invoice.new_line_item(
product=product,
quantity=quantity,
)
except request.shop.stripe.error.CardError as e:
# rollback changes to database because charge was NOT successful!
request.dbsession.rollback()
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
err = e.error
# Error code will be authentication_required if authentication is needed
payment_intent_id = err.payment_intent['id']
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
request.flash("Payment Error: {} {} - {}".format(
payment_intent.last_payment_error.code,
payment_intent.last_payment_error.decline_code,
payment_intent.last_payment_error.message,
), "error")
successful_checkout = False
request.dbsession.add(invoice)
invoices.append(invoice)
# Loop over each invoice to handle payments
for invoice in invoices:
shop = invoice.shop
# only create a real charge if the invoice has a positive price.
if invoice.requires_payment:
stripe_user_shop = shop.stripe_user_shop(request.user)
try:
shop.stripe.PaymentIntent.create(
amount=invoice.total_in_cents,
currency="usd",
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
)
except shop.stripe.error.CardError as e:
# rollback changes to database because charge was NOT successful!
request.dbsession.rollback()
err = e.error
# Error code will be authentication_required if authentication is needed
payment_intent_id = err.payment_intent["id"]
payment_intent = shop.stripe.PaymentIntent.retrieve(
payment_intent_id
)
request.flash(
"Payment Error: {} {} - {}".format(
payment_intent.last_payment_error.code,
payment_intent.last_payment_error.decline_code,
payment_intent.last_payment_error.message,
),
"error",
)
successful_checkout = False
break
if successful_checkout:
# Update inventory for physical products
cart.update_inventory(request.shop_location)
# flush changes to database because charge was successful!
request.dbsession.flush()
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
# email the authenticated user regarding their purchase.
send_purchase_email(request, request.user.email, cart.products.values(), cart.total)
# email the shop owners regarding their sale.
send_sale_email(request, request.shop, cart.products.values(), cart.total)
for invoice in invoices:
send_purchase_email(
request,
request.user.email,
[item.product for item in invoice.line_items],
invoice.total,
)
# email the shop owners regarding their sale.
send_sale_email(
request,
invoice.shop,
[item.product for item in invoice.line_items],
invoice.total,
)
# save the cart, so that we end up with a new empty cart.
save_cart(request)
return HTTPFound("/u/purchases")

View file

@ -8,7 +8,6 @@ from pyramid.httpexceptions import HTTPFound
@view_config(route_name="content", renderer="content.j2")
@view_config(route_name="content_slug", renderer="content.j2")
def content(request):
product = request.product
if not request.is_saas_domain and request.domain != request.shop.domain_name:
@ -31,8 +30,11 @@ def content(request):
bucket_name = request.app["bucket.secure_uploads"]
if request.user and request.user.authenticated and request.user.can_download_product(product):
if (
request.user
and request.user.authenticated
and request.user.can_download_product(product)
):
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding,
# ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId,
@ -58,10 +60,10 @@ def content(request):
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
ClientMethod='get_object',
ClientMethod="get_object",
Params=params,
# 15 minutes.
ExpiresIn=900
ExpiresIn=900,
)
product_size = ""

View file

@ -27,18 +27,14 @@ def get_coupon_from_matchdict(request):
@view_config(route_name="coupons", renderer="coupons.j2")
@shop_owner_required()
def coupons(request):
return {
"coupons": request.shop.coupons.all()
}
return {"coupons": request.shop.coupons.all()}
@view_config(route_name="coupon1", renderer="coupon.j2")
@view_config(route_name="coupon2", renderer="coupon.j2")
def coupon(request):
coupon = get_coupon_from_matchdict(request)
return {
"coupon": coupon
}
return {"coupon": coupon}
@view_config(route_name="coupon_new1", renderer="coupon_new.j2")
@ -55,26 +51,25 @@ def coupon_new(request):
cart_qualifier = request.params.get("cart_qualifier")
if request.params:
if not code or not description or not action_type or not action_type:
request.session.flash(("Please submit all required fields.", "error"))
else:
request.session.flash(("You created a new coupon!", "success"))
coupon = Coupon(
shop = request.shop,
code = code,
description = description,
action_type = action_type,
action_value = action_value,
max_redemptions = max_redemptions,
max_redemptions_per_user = max_redemptions_per_user,
expiration_date = expiration_date,
cart_qualifier = cart_qualifier,
shop=request.shop,
code=code,
description=description,
action_type=action_type,
action_value=action_value,
max_redemptions=max_redemptions,
max_redemptions_per_user=max_redemptions_per_user,
expiration_date=expiration_date,
cart_qualifier=cart_qualifier,
)
request.dbsession.add(coupon)
request.dbsession.flush()
return HTTPFound("/s/{}/coupons".format(request.shop.id))
return request.params
@ -102,7 +97,6 @@ def coupon_apply_to_cart(request):
@view_config(route_name="coupon_remove_from_cart", request_method="POST")
@user_required()
def coupon_remove_from_cart(request):
cart_id = request.params.get("cart_id")
coupon_id = request.params.get("coupon_id")
@ -116,9 +110,12 @@ def coupon_remove_from_cart(request):
elif coupon is None:
msg = ("That coupon_id does not exist.", "error")
elif request.user.does_not_own_cart(cart):
elif request.user and request.user.does_not_own_cart(cart):
msg = ("You do not own that cart.", "error")
elif request.user is None and cart.user is not None:
msg = ("You do not own this cart.", "error")
else:
if coupon in cart.coupons:
msg = ("The coupon was removed from your cart.", "success")

View file

@ -0,0 +1,29 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from ..models.invoice import get_invoice_by_id
from . import (
user_required,
get_referer_or_home,
)
@view_config(route_name="view_invoice", renderer="invoice.j2")
@user_required()
def view_invoice(request):
invoice_id = request.matchdict.get("invoice_id")
invoice = get_invoice_by_id(request.dbsession, invoice_id)
if invoice is None:
request.session.flash(("Invoice not found.", "error"))
return HTTPFound(get_referer_or_home(request))
# Check if the user is the owner of the invoice or a shop editor
if request.user != invoice.user and not request.user.can_edit_shop(invoice.shop):
request.session.flash(("You do not have permission to view this invoice.", "error"))
return HTTPFound(get_referer_or_home(request))
return {
"invoice": invoice,
"line_items": invoice.line_items.all(),
}

View file

@ -9,6 +9,8 @@ from . import (
)
from ..models.product import Product
from ..models.shop_location import ShopLocation
from ..models.inventory import Inventory
from ..lib.currency import dollars_to_cents
@ -20,7 +22,6 @@ def checkbox_to_bool(checkbox):
@view_config(route_name="product", renderer="product.j2")
@view_config(route_name="product_slug", renderer="product.j2")
def product(request):
product = request.product
if not request.is_saas_domain and request.domain != request.shop.domain_name:
@ -43,8 +44,11 @@ def product(request):
bucket_name = request.app["bucket.secure_uploads"]
if request.user and request.user.authenticated and request.user.can_download_product(product):
if (
request.user
and request.user.authenticated
and request.user.can_download_product(product)
):
# Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince,
# Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseContentEncoding,
# ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId,
@ -70,10 +74,10 @@ def product(request):
params["ResponseContentType"] = content_type
signed_get_object_url = request.secure_uploads_client.generate_presigned_url(
ClientMethod='get_object',
ClientMethod="get_object",
Params=params,
# 15 minutes.
ExpiresIn=900
ExpiresIn=900,
)
product_size = ""
@ -92,7 +96,6 @@ def product(request):
@user_required()
@shop_editor_required()
def product_new(request):
title = request.params.get("title", "")
description = request.params.get("description", "")
price = request.params.get("price", "")
@ -103,26 +106,35 @@ def product_new(request):
is_sellable_checkbox = request.params.get("is_sellable", "off")
is_sellable = checkbox_to_bool(is_sellable_checkbox)
is_physical = bool(request.params.get("is_physical", "false") == "true")
if title and description:
product = Product(title, description)
product.shop = request.shop
product.is_bundle = is_bundle
product.is_sellable = is_sellable
if product.error_message:
request.session.flash((product.error_message, "error"))
else:
if is_sellable and price:
product_price = product.set_price(price)
request.dbsession.add(product_price)
request.dbsession.add(product)
request.dbsession.flush()
msg = ("Great, next you may upload files.", "success")
# prevent trying to create a bundle and also physical product.
if is_bundle and is_physical:
msg = (
"The bundle feature only works with digital goods. Please uncheck the bundle option or mark the product as digital.",
"error",
)
request.session.flash(msg)
return HTTPFound("/p/{}/edit".format(product.id))
else:
product = Product(title, description)
product.shop = request.shop
product.is_bundle = is_bundle
product.is_sellable = is_sellable
product.is_physical = is_physical
if product.error_message:
request.session.flash((product.error_message, "error"))
else:
if is_sellable and price:
product_price = product.set_price(price)
request.dbsession.add(product_price)
request.dbsession.add(product)
request.dbsession.flush()
msg = ("Great, next you may upload files.", "success")
request.session.flash(msg)
return HTTPFound("/p/{}/edit".format(product.id))
elif title or description or price:
msg = ("You must fill out all fields.", "error")
@ -132,6 +144,12 @@ def product_new(request):
"title": title,
"description": description,
"price": price,
"is_bundle": is_bundle,
"is_physical": is_physical,
"create_location_url": request.route_url(
"shop_location_new", shop_id=request.shop.id
),
"shop_has_locations": len(request.shop.shop_locations.all()) > 0,
}
@ -143,7 +161,7 @@ def product_edit_description(request):
raw_markup_data = request.params.get(
"markup-editor-textarea",
# TODO: rename this column to description_raw.
product.description
product.description,
)
if raw_markup_data and raw_markup_data != product.description:
@ -159,7 +177,7 @@ def product_edit_description(request):
"markup_raw": product.description,
"markup_form_path": "{}/description".format(
product.absolute_url(request, slug=False)
)
),
}
@ -167,7 +185,6 @@ def product_edit_description(request):
@view_config(route_name="product_edit2", renderer="product_edit.j2")
@shop_editor_required()
def product_edit(request):
product_modified = False
product = request.product
@ -177,11 +194,13 @@ def product_edit(request):
visibility = int(request.params.get("visibility", product.visibility))
uploaded_file_key = request.params.get("uploaded", "")
if uploaded_file_key:
if uploaded_file_key:
request.session.flash(
(
"<span class=\"avoid-wrap\">You successfully uploaded</span> <span class=\"avoid-wrap\">a new {} file!</span>".format(uploaded_file_key),
"success"
'<span class="avoid-wrap">You successfully uploaded</span> <span class="avoid-wrap">a new {} file!</span>'.format(
uploaded_file_key
),
"success",
)
)
@ -201,7 +220,7 @@ def product_edit(request):
product_modified = True
product.title = title
request.session.flash(("You updated the product's title.", "success"))
if description != product.description:
product_modified = True
product.set_description(description)
@ -224,7 +243,7 @@ def product_edit(request):
# Store the uploaded user's file_metadata in our database.
file_key = product.store_file_metadata(s3_webhook_key)
# This is how we protect our digital downloads while using the
# This is how we protect our digital downloads while using the
# same bucket for thumbnails and previews related to the product.
acl = "private"
if file_key in product.file_public_keys:
@ -238,7 +257,7 @@ def product_edit(request):
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
ACL = acl,
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
CopySource={
"Bucket": s3_webhook_bucket,
@ -256,11 +275,11 @@ def product_edit(request):
Bucket=s3_webhook_bucket,
Key=s3_webhook_key,
)
# get file size & store in our database.
# get file size & store in our database.
response = request.secure_uploads_client.head_object(
Bucket=request.app["bucket.secure_uploads"],
Key="{}/{}".format(product.s3_path, file_key)
Key="{}/{}".format(product.s3_path, file_key),
)
product_content_length = response["ContentLength"]
@ -275,7 +294,9 @@ def product_edit(request):
# redirect back to this page to clear
# the params posted by the s3 webhooks.
return HTTPFound("/p/{}/{}/edit?uploaded={}".format(product.id, product.slug, file_key))
return HTTPFound(
"/p/{}/{}/edit?uploaded={}".format(product.id, product.slug, file_key)
)
if product_modified and product.error_message is None:
request.dbsession.add(product)
@ -286,7 +307,7 @@ def product_edit(request):
for file_key in product.file_keys:
key_starts_with = "{}/{}.".format(product.s3_path, file_key)
conditions = [
{ "success_action_redirect": product.absolute_edit_url(request) },
{"success_action_redirect": product.absolute_edit_url(request)},
["starts-with", "$key", key_starts_with],
]
@ -310,6 +331,18 @@ def product_edit(request):
continue
signed_posts[file_key]["fields"].update(condition)
# Fetch locations and inventory quantities for physical products
locations = []
if product.is_physical:
locations = (
request.dbsession.query(ShopLocation)
.filter_by(shop_id=product.shop_id)
.all()
)
inventories = {
inv.shop_location.id: inv.quantity for inv in product.inventories
}
return {
"product": product,
"title": title,
@ -317,14 +350,54 @@ def product_edit(request):
"price": price,
"visibility": visibility,
"signed_posts": signed_posts,
"locations": locations,
"inventories": inventories if product.is_physical else {},
}
@view_config(route_name="product_inventory_edit")
@shop_editor_required()
def product_inventory_edit(request):
product = request.product
if not product.is_physical:
request.session.flash(("This product is not physical.", "error"))
return HTTPFound(get_referer_or_home(request))
# Get all shop locations for the product's shop
shop_locations = request.shop.shop_locations
updated = False
for location in shop_locations:
quantity = request.params.get(f"inventory_{location.id}", None)
if quantity is not None:
try:
quantity = int(quantity)
inventory = next((inv for inv in product.inventories if inv.shop_location_id == location.id), None)
if inventory:
if inventory.quantity != quantity:
inventory.update_quantity(quantity)
request.dbsession.add(inventory)
updated = True
else:
inventory = Inventory(shop_location=location, product=product, quantity=quantity)
request.dbsession.add(inventory)
updated = True
except ValueError:
request.session.flash((f"Invalid quantity for location {location.name}.", "error"))
if updated:
request.dbsession.flush()
request.session.flash(("Inventory updated successfully.", "success"))
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="product_bundle_edit")
@view_config(route_name="product_bundle_edit2")
@shop_editor_required()
def product_bundle_edit(request):
from ..models.product import get_products_by_ids
from ..lib.uuid_validation import UUID_ALL_PATTERN
@ -332,14 +405,15 @@ def product_bundle_edit(request):
product = request.product
if product.is_bundle == False:
request.session.flash(("You may only add products to a Product bundle.", "error"))
request.session.flash(
("You may only add products to a Product bundle.", "error")
)
bundle_data = request.params.get("bundle-data", None)
if bundle_data:
products = get_products_by_ids(
request.dbsession,
UUID_ALL_PATTERN.findall(bundle_data)
request.dbsession, UUID_ALL_PATTERN.findall(bundle_data)
)
for product_to_bundle in products:
if product.add_product_to_bundle(product_to_bundle) == False:
@ -353,7 +427,6 @@ def product_bundle_edit(request):
@view_config(route_name="product_remove_bundled_product")
@shop_editor_required()
def product_bundle_remove_product(request):
from ..models.product import get_product_by_id
product = request.product
@ -362,7 +435,9 @@ def product_bundle_remove_product(request):
bundled_product = get_product_by_id(request.dbsession, bundled_product_id)
if product.is_bundle == False:
request.session.flash(("You may only remove products from a Product bundle.", "error"))
request.session.flash(
("You may only remove products from a Product bundle.", "error")
)
if product.remove_product_from_bundle(bundled_product) == False:
request.session.flash((product.error_message, "error"))

View file

@ -27,7 +27,7 @@ from ..models.user import (
)
from ..models.shop_search_request import (
ShopSearchRequest,
ShopSearchRequest,
)
from ..lib.mail import send_invite_email
@ -37,10 +37,10 @@ from ..lib.phone_numbers import is_phone_number_valid
from pyramid.httpexceptions import HTTPFound
def checkbox_to_bool(checkbox):
return True if checkbox == "on" else False
def bool_to_checkbox(boolean):
return "on" if boolean == True else "off"
@ -60,7 +60,7 @@ def get_shop_from_matchdict(request, prefetched_shop=None):
# returns None if shop_id is an invalid uuid.
return get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
@view_config(route_name="home", renderer="home.j2")
def home(request):
products = None
@ -92,7 +92,7 @@ def shop_products(request):
if shop is None:
msg = ("That shop_id does not exist.", "error")
if msg:
if msg:
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
@ -118,9 +118,7 @@ def search(request):
return HTTPFound(get_referer_or_home(request))
products = get_products_by_keywords(
request.dbsession,
keywords.split(" "),
request.shop
request.dbsession, keywords.split(" "), request.shop
)
hit_count = len(products)
@ -129,8 +127,8 @@ def search(request):
search_request = ShopSearchRequest(
keywords,
hit_count,
shop = request.shop,
user = request.user,
shop=request.shop,
user=request.user,
)
request.dbsession.add(search_request)
request.dbsession.flush()
@ -148,7 +146,6 @@ def search(request):
@view_config(route_name="shop_new", renderer="shop_new.j2")
@user_required()
def shop_new(request):
name = request.params.get("name", "")
phone_number = request.params.get("phone_number", "")
billing_address = request.params.get("billing_address", "")
@ -156,7 +153,10 @@ def shop_new(request):
if name and description and billing_address and phone_number:
if is_shop_name_valid(name) == False:
msg = ("Invalid shop name, only use alpha numeric, spaces, dashes, or periods.", "error")
msg = (
"Invalid shop name, only use alpha numeric, spaces, dashes, or periods.",
"error",
)
request.session.flash(msg)
elif is_shop_name_available(request.dbsession, name) == False:
msg = ("That shop name is already in use. Please pick another.", "error")
@ -172,7 +172,10 @@ def shop_new(request):
request.dbsession.add(shop)
request.dbsession.add(request.user)
request.dbsession.flush()
msg = ("Great work, you created a shop! You may continue to setup your shop or start posting products!", "success")
msg = (
"Great work, you created a shop! You may continue to setup your shop or start posting products!",
"success",
)
request.session.flash(msg)
return HTTPFound("/s/{}/settings".format(shop.id))
@ -191,9 +194,7 @@ def shop_new(request):
@view_config(route_name="user_shop_activate")
@shop_editor_required()
def shop_activate(request):
shop = get_shop_from_matchdict(
request, prefetched_shop=request.user.active_shop
)
shop = get_shop_from_matchdict(request, prefetched_shop=request.user.active_shop)
if shop is None:
msg = ("That shop_id does not exist.", "error")
@ -237,8 +238,7 @@ def shop_edit_terms_of_service(request):
shop = request.shop
raw_markup_data = request.params.get(
"markup-editor-textarea",
shop.terms_of_service_raw
"markup-editor-textarea", shop.terms_of_service_raw
)
if raw_markup_data and raw_markup_data != shop.terms_of_service_raw:
@ -252,7 +252,7 @@ def shop_edit_terms_of_service(request):
"markup_subject": "Terms of Service",
"markup_rendered": shop.terms_of_service_html,
"markup_raw": shop.terms_of_service_raw,
"markup_form_path": shop.absolute_terms_url(request)
"markup_form_path": shop.absolute_terms_url(request),
}
@ -264,8 +264,7 @@ def shop_edit_privacy_policy(request):
shop = request.shop
raw_markup_data = request.params.get(
"markup-editor-textarea",
shop.privacy_policy_raw
"markup-editor-textarea", shop.privacy_policy_raw
)
if raw_markup_data and raw_markup_data != shop.privacy_policy_raw:
@ -279,14 +278,13 @@ def shop_edit_privacy_policy(request):
"markup_subject": "Privacy Policy",
"markup_rendered": shop.privacy_policy_html,
"markup_raw": shop.privacy_policy_raw,
"markup_form_path": shop.absolute_privacy_policy_url(request)
"markup_form_path": shop.absolute_privacy_policy_url(request),
}
@view_config(route_name="shop_users", renderer="shop_users.j2")
@shop_owner_required()
def shop_users(request):
email_to_invite = request.params.get("email-to-invite")
role_id = request.params.get("role-id")
@ -306,7 +304,6 @@ def shop_users(request):
msg = ("You invited {} to the shop.".format(email_to_invite), "success")
request.session.flash(msg)
return {
"owners": request.shop.owners,
"editors": request.shop.editors,
@ -322,7 +319,10 @@ def shop_user_remove(request):
user = get_user_by_id(request.dbsession, user_id)
if user:
if user == request.user:
msg = ("Sheepishly refusing to remove myself!".format(user.email), "error")
msg = (
f"Sheepishly refusing to remove myself! {user.email}",
"error",
)
request.session.flash(msg)
else:
user_shop = request.shop.get_usershop_for_user(user)
@ -335,7 +335,6 @@ def shop_user_remove(request):
@view_config(route_name="shop_settings", renderer="shop_settings.j2")
@shop_owner_required()
def shop_settings(request):
shop = request.shop
name = request.params.get("name", shop.name)
@ -350,13 +349,21 @@ def shop_settings(request):
ribbon_color_1 = request.params.get("ribbon_color_1", shop.ribbon_color_1)
ribbon_color_2 = request.params.get("ribbon_color_2", shop.ribbon_color_2)
google_analytics_id = request.params.get("google_analytics_id", shop.google_analytics_id)
plausible_domain_name = request.params.get("plausible_domain_name", shop.plausible_domain_name)
google_analytics_id = request.params.get(
"google_analytics_id", shop.google_analytics_id
)
plausible_domain_name = request.params.get(
"plausible_domain_name", shop.plausible_domain_name
)
stripe_public_api_key = request.params.get("stripe_public_api_key", shop.stripe_public_api_key)
stripe_secret_api_key = request.params.get("stripe_secret_api_key", shop.stripe_secret_api_key)
stripe_public_api_key = request.params.get(
"stripe_public_api_key", shop.stripe_public_api_key
)
stripe_secret_api_key = request.params.get(
"stripe_secret_api_key", shop.stripe_secret_api_key
)
#maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode))
# maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode))
maint_mode_checkbox = request.params.get("maint-mode-checkbox", "off")
maint_mode = checkbox_to_bool(maint_mode_checkbox)
@ -370,12 +377,11 @@ def shop_settings(request):
request.session.flash(
(
"You successfully uploaded a new {} file!".format(uploaded_file_key),
"success"
"success",
)
)
if request.method == "POST":
if name != shop.name:
shop.name = name
msg = ("You set the shop's name.", "success")
@ -416,12 +422,18 @@ def shop_settings(request):
if ribbon_color_1 != shop.ribbon_color_1:
shop.ribbon_color_1 = ribbon_color_1
msg = ("You set the shop's announcement ribbon background color 1.", "success")
msg = (
"You set the shop's announcement ribbon background color 1.",
"success",
)
request.session.flash(msg)
if ribbon_color_2 != shop.ribbon_color_2:
shop.ribbon_color_2 = ribbon_color_2
msg = ("You set the shop's announcement ribbon background color 2.", "success")
msg = (
"You set the shop's announcement ribbon background color 2.",
"success",
)
request.session.flash(msg)
if google_analytics_id != shop.google_analytics_id:
@ -434,36 +446,45 @@ def shop_settings(request):
msg = ("You set the shop's Plausible Analytics Domain Name.", "success")
request.session.flash(msg)
if stripe_public_api_key and stripe_public_api_key != shop.stripe_public_api_key:
if (
stripe_public_api_key
and stripe_public_api_key != shop.stripe_public_api_key
):
if stripe_public_api_key.startswith("pk_"):
shop.stripe_public_api_key = stripe_public_api_key
msg = ("You set the shop's stripe_public_api_key.", "success")
else:
msg = ("The shop's stripe_public_api_key must start with 'pk_'.", "error")
msg = (
"The shop's stripe_public_api_key must start with 'pk_'.",
"error",
)
request.session.flash(msg)
if stripe_secret_api_key and stripe_secret_api_key != shop.stripe_secret_api_key:
if (
stripe_secret_api_key
and stripe_secret_api_key != shop.stripe_secret_api_key
):
if stripe_secret_api_key.startswith("sk_"):
shop.stripe_secret_api_key = stripe_secret_api_key
msg = ("You set the shop's stripe_secret_api_key.", "success")
else:
msg = ("The shop's stripe_secret_api_key must start with 'sk_'.", "error")
msg = (
"The shop's stripe_secret_api_key must start with 'sk_'.",
"error",
)
request.session.flash(msg)
if shop.maint_mode != maint_mode:
shop.maint_mode = maint_mode
request.session.flash(
(
"You turned {} maint_mode".format(
maint_mode_checkbox
),
"You turned {} maint_mode".format(maint_mode_checkbox),
"success",
)
)
# TODO: Dry out this block, it's a copy pasta from views/product.py
if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag:
# get file_from S3 service posted params.
file_key = s3_webhook_key.split("/")[-1].split(".")[0]
@ -474,20 +495,20 @@ def shop_settings(request):
# determine content type from extension.
file_ext = s3_webhook_key.split(".")[-1]
#print(file_ext)
# print(file_ext)
content_type = mimetypes.types_map.get("." + file_ext)
#print(content_type)
# print(content_type)
# copy upload to our system defined s3 location.
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object
request.secure_uploads_client.copy_object(
ACL = acl,
ACL=acl,
Bucket=request.app["bucket.secure_uploads"],
CopySource={
"Bucket": s3_webhook_bucket,
"Key": s3_webhook_key,
},
ContentDisposition= "inline; filename={}".format(file_key),
ContentDisposition="inline; filename={}".format(file_key),
ContentType=content_type,
CacheControl=cache_control,
Key="{}/meta/{}".format(shop.id, file_key),
@ -522,7 +543,7 @@ def shop_settings(request):
# a shops namespace which is owned by a different user.
key_starts_with = "{}/meta/{}.{}".format(shop.id, file_key, "$filename")
conditions = [
{ "success_action_redirect": shop.absolute_settings_url(request) },
{"success_action_redirect": shop.absolute_settings_url(request)},
["starts-with", "$key", key_starts_with],
]

View file

@ -0,0 +1,206 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import shop_owner_required
from ..models.shop import get_shop_by_id
from ..models.shop_location import ShopLocation
def checkbox_to_bool(checkbox):
return True if checkbox == "on" else False
def bool_to_checkbox(boolean):
return "on" if boolean else "off"
@view_config(route_name="shop_location_new", renderer="shop_location_form.j2")
@shop_owner_required()
def shop_location_new(request):
shop = get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
if not shop:
request.session.flash(("Shop not found.", "error"))
return HTTPFound(location=request.route_url("home"))
if request.method == "POST":
name = request.params.get("name", "")
address = request.params.get("address", "")
city = request.params.get("city", "")
state = request.params.get("state", "")
country = request.params.get("country", "")
postal_code = request.params.get("postal_code", "")
days_open = request.params.get("days_open", "")
hours_open = request.params.get("hours_open", "")
local_pickup = checkbox_to_bool(request.params.get("local_pickup", "off"))
local_delivery = checkbox_to_bool(request.params.get("local_delivery", "off"))
local_delivery_rate = request.params.get("local_delivery_rate", "0.00")
local_shipping = checkbox_to_bool(request.params.get("local_shipping", "off"))
local_shipping_rate = request.params.get("local_shipping_rate", "0.00")
international_shipping = checkbox_to_bool(
request.params.get("international_shipping", "off")
)
international_shipping_rate = request.params.get(
"international_shipping_rate", "0.00"
)
shop_location = ShopLocation(
shop=shop,
name=name,
address=address,
city=city,
state=state,
country=country,
postal_code=postal_code,
)
shop_location.days_open = days_open
shop_location.hours_open = hours_open
shop_location.local_pickup = local_pickup
shop_location.local_delivery = local_delivery
shop_location.local_delivery_rate = local_delivery_rate
shop_location.local_shipping = local_shipping
shop_location.local_shipping_rate = local_shipping_rate
shop_location.international_shipping = international_shipping
shop_location.international_shipping_rate = international_shipping_rate
request.dbsession.add(shop_location)
request.dbsession.flush()
request.session.flash(("Shop location created successfully.", "success"))
return HTTPFound(location=request.route_url("shop_locations", shop_id=shop.id))
return {
"action_url": request.route_url("shop_location_new", shop_id=shop.id),
"name": "",
"address": "",
"city": "",
"state": "",
"country": "",
"postal_code": "",
"days_open": "",
"hours_open": "",
"local_pickup": False,
"local_delivery": False,
"local_delivery_rate": "0.00",
"local_shipping": False,
"local_shipping_rate": "0.00",
"international_shipping": False,
"international_shipping_rate": "0.00",
}
@view_config(route_name="shop_location_edit", renderer="shop_location_form.j2")
@shop_owner_required()
def shop_location_edit(request):
shop = get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
location_id = request.matchdict["location_id"]
shop_location = (
request.dbsession.query(ShopLocation)
.filter_by(id=location_id, shop_id=shop.id)
.one_or_none()
)
if not shop_location:
request.session.flash(("Shop location not found.", "error"))
return HTTPFound(location=request.route_url("shop_locations", shop_id=shop.id))
if request.method == "POST":
shop_location.name = request.params.get("name", "")
shop_location.address = request.params.get("address", "")
shop_location.city = request.params.get("city", "")
shop_location.state = request.params.get("state", "")
shop_location.country = request.params.get("country", "")
shop_location.postal_code = request.params.get("postal_code", "")
shop_location.days_open = request.params.get("days_open", "")
shop_location.hours_open = request.params.get("hours_open", "")
shop_location.local_pickup = checkbox_to_bool(
request.params.get("local_pickup", "off")
)
shop_location.local_delivery = checkbox_to_bool(
request.params.get("local_delivery", "off")
)
shop_location.local_delivery_rate = request.params.get(
"local_delivery_rate", "0.00"
)
shop_location.local_shipping = checkbox_to_bool(
request.params.get("local_shipping", "off")
)
shop_location.local_shipping_rate = request.params.get(
"local_shipping_rate", "0.00"
)
shop_location.international_shipping = checkbox_to_bool(
request.params.get("international_shipping", "off")
)
shop_location.international_shipping_rate = request.params.get(
"international_shipping_rate", "0.00"
)
request.dbsession.add(shop_location)
request.dbsession.flush()
request.session.flash(("Shop location updated successfully.", "success"))
return HTTPFound(location=request.route_url("shop_locations", shop_id=shop.id))
return {
"action_url": request.route_url(
"shop_location_edit", shop_id=shop.id, location_id=location_id
),
"name": shop_location.name,
"address": shop_location.address,
"city": shop_location.city,
"state": shop_location.state,
"country": shop_location.country,
"postal_code": shop_location.postal_code,
"days_open": shop_location.days_open,
"hours_open": shop_location.hours_open,
"local_pickup": shop_location.local_pickup,
"local_delivery": shop_location.local_delivery,
"local_delivery_rate": shop_location.local_delivery_rate,
"local_shipping": shop_location.local_shipping,
"local_shipping_rate": shop_location.local_shipping_rate,
"international_shipping": shop_location.international_shipping,
"international_shipping_rate": shop_location.international_shipping_rate,
}
@view_config(route_name="shop_locations", renderer="shop_locations.j2")
def shop_locations(request):
return {
"shop": request.shop,
"locations": request.shop.shop_locations,
}
@view_config(route_name="shop_location_switch")
def shop_location_switch(request):
shop_id = request.shop.id
location_id = request.matchdict.get("location_id")
next_url = request.params.get("next", request.route_url("shop", shop_id=shop_id))
# Fetch the location to ensure it exists and belongs to the shop
location = (
request.dbsession.query(ShopLocation)
.filter_by(id=location_id, shop_id=shop_id)
.first()
)
if not location:
request.session.flash(
("Location not found or does not belong to this shop.", "error")
)
return HTTPFound(request.route_url("shop_locations", shop_id=shop_id))
# Update the session with the selected location
request.session["shop_location_id"] = str(location.id)
request.session.flash(
(f"You are now viewing the {location.name} shop location", "success")
)
# Recalculate the handling cost based on the new location
cart = request.active_cart
cart.update_handling_cost(location)
request.dbsession.add(cart)
request.dbsession.flush()
# Redirect to the next URL or the shop home page
return HTTPFound(next_url)

View file

@ -3,13 +3,13 @@ from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import (
user_required,
shop_is_ready_required,
user_required,
shop_is_ready_required,
)
from ..models.user import (
is_user_name_available,
is_user_name_valid,
is_user_name_available,
is_user_name_valid,
)
@ -36,7 +36,6 @@ def user_products(request):
@view_config(route_name="user_settings", renderer="user_settings.j2")
@user_required()
def user_settings(request):
user = request.user
name = request.params.get("name", user.name)
@ -44,10 +43,16 @@ def user_settings(request):
if name and name != user.name:
if not is_user_name_valid(name):
msg = ("Sorry your display name is invalid. Please try again with only letters, numbers, spaces, dashes, and periods.", "error")
msg = (
"Sorry your display name is invalid. Please try again with only letters, numbers, spaces, dashes, and periods.",
"error",
)
name = user.name
elif not is_user_name_available(request.dbsession, name):
msg = ("Sorry that display name is already taken. Please try again.", "error")
msg = (
"Sorry that display name is already taken. Please try again.",
"error",
)
name = user.name
else:
user.name = name
@ -81,7 +86,7 @@ def user_address_save(request):
request.dbsession.flush()
msg = ("You saved that address.", "success")
request.session.flash(msg)
return HTTPFound("/u/addresses")