Handling selection and cost now seem to work.
only thing next is blocking if there is a need for handling options and user needs address. modified: make_post_sell/models/cart.py modified: make_post_sell/models/inventory.py modified: make_post_sell/models/invoice.py modified: make_post_sell/routes.py new file: make_post_sell/scripts/alembic/versions/1b3ecdde9e65_handling_options.py modified: make_post_sell/templates/cart.j2 modified: make_post_sell/views/cart.py
This commit is contained in:
parent
e5c49e5347
commit
60b495b36a
7 changed files with 394 additions and 129 deletions
|
|
@ -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):
|
||||
|
|
@ -161,7 +168,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 +177,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 +199,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 +215,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 +250,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 +273,66 @@ 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 (
|
||||
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 +343,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 +392,65 @@ 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 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):
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ 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)
|
||||
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)
|
||||
|
|
@ -29,3 +32,19 @@ class Inventory(RBase, Base):
|
|||
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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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
|
||||
|
||||
|
|
@ -8,6 +8,8 @@ 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,28 @@ 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_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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ 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", "/u/cart/{cart_id}/handling-option")
|
||||
|
||||
# coupon routes.
|
||||
config.add_route("coupon_new1", "/coupon/new")
|
||||
config.add_route("coupon_new2", "/s/{shop_id}/coupon/new")
|
||||
|
|
@ -74,7 +76,9 @@ def includeme(config):
|
|||
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')
|
||||
config.add_route(
|
||||
"shop_location_switch", "/s/{shop_id}/location/{location_id}/switch"
|
||||
)
|
||||
|
||||
# shop routes.
|
||||
config.add_route("shop_new", "/s/new")
|
||||
|
|
@ -109,7 +113,7 @@ 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_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(
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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" 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" 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" 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" 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/>
|
||||
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ def cart_remove_product(request):
|
|||
):
|
||||
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()
|
||||
|
||||
|
|
@ -336,6 +337,41 @@ def cart_quantity_product(request):
|
|||
return HTTPFound(get_referer_or_home(request))
|
||||
|
||||
|
||||
@view_config(route_name="cart_handling_option", request_method="POST")
|
||||
@user_required()
|
||||
def cart_handling_option(request):
|
||||
cart = get_cart_from_matchdict(request)
|
||||
handling_option = request.params.get("handling_option")
|
||||
|
||||
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
|
||||
|
||||
if handling_option == "local_pickup":
|
||||
cart.handling_cost_in_cents = 0
|
||||
elif handling_option == "local_delivery":
|
||||
cart.handling_cost_in_cents = request.shop_location.local_delivery_rate_in_cents
|
||||
elif handling_option == "local_shipping":
|
||||
cart.handling_cost_in_cents = request.shop_location.local_shipping_rate_in_cents
|
||||
elif handling_option == "international_shipping":
|
||||
cart.handling_cost_in_cents = (
|
||||
request.shop_location.international_shipping_rate_in_cents
|
||||
)
|
||||
|
||||
request.dbsession.add(cart)
|
||||
request.dbsession.flush()
|
||||
|
||||
request.session.flash(("Handling option set successfully.", "success"))
|
||||
return HTTPFound("/u/cart/{}".format(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(
|
||||
|
|
@ -379,6 +415,13 @@ def cart_checkout(request):
|
|||
request.session.flash((error_message, "error"))
|
||||
return HTTPFound(get_referer_or_home(request))
|
||||
|
||||
# check inventory for 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))
|
||||
|
||||
msg = ("Please confirm your order.", "info")
|
||||
request.session.flash(msg)
|
||||
return {
|
||||
|
|
@ -419,61 +462,67 @@ def cart_complete_checkout(request):
|
|||
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:
|
||||
stripe_user_shop = request.shop.stripe_user_shop(request.user)
|
||||
for shop_id, product_quantity_tuple in cart.shop_product_dict.items():
|
||||
shop = cart.shops[shop_id]
|
||||
invoice = Invoice(user=request.user)
|
||||
invoice.shop_id = shop.id
|
||||
invoice.handling_option = cart.handling_option
|
||||
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
|
||||
|
||||
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,
|
||||
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()
|
||||
|
||||
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
|
||||
for coupon in cart.coupons:
|
||||
invoice.new_coupon_redemption(coupon)
|
||||
|
||||
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:
|
||||
# flush changes to database because charge was successful!
|
||||
|
|
@ -481,11 +530,20 @@ def cart_complete_checkout(request):
|
|||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue