diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index c053352..f155831 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -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): diff --git a/make_post_sell/models/inventory.py b/make_post_sell/models/inventory.py index b20cea8..08694cb 100644 --- a/make_post_sell/models/inventory.py +++ b/make_post_sell/models/inventory.py @@ -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() + ) diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 7916d18..8e13efc 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -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 diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 4ccd12a..f768583 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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( diff --git a/make_post_sell/scripts/alembic/versions/1b3ecdde9e65_handling_options.py b/make_post_sell/scripts/alembic/versions/1b3ecdde9e65_handling_options.py new file mode 100644 index 0000000..018085a --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/1b3ecdde9e65_handling_options.py @@ -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 ### diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index f4ada08..67999db 100644 --- a/make_post_sell/templates/cart.j2 +++ b/make_post_sell/templates/cart.j2 @@ -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 @@ {{ shop.name }} - {% 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] %}