diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index 4abf079..4e84bdb 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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 * diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index c053352..7e9c6c7 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): @@ -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): diff --git a/make_post_sell/models/inventory.py b/make_post_sell/models/inventory.py new file mode 100644 index 0000000..08694cb --- /dev/null +++ b/make_post_sell/models/inventory.py @@ -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() + ) diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 7916d18..53e1407 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -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) diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 209f0e0..7368a9e 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -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", diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 773de89..706054c 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -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 diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index 7c8a4fd..856f28b 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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( diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py new file mode 100644 index 0000000..2f4f209 --- /dev/null +++ b/make_post_sell/models/shop_location.py @@ -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) diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index 329eb35..82b2330 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -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) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 9cc7ea1..af32eab 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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:.*}") 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/scripts/alembic/versions/cc6211ec360e_physical_products_shop_locations_and_.py b/make_post_sell/scripts/alembic/versions/cc6211ec360e_physical_products_shop_locations_and_.py new file mode 100644 index 0000000..2ebe126 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/cc6211ec360e_physical_products_shop_locations_and_.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/ddfbd6d96915_shop_location_rates_and_hours.py b/make_post_sell/scripts/alembic/versions/ddfbd6d96915_shop_location_rates_and_hours.py new file mode 100644 index 0000000..d7627d7 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/ddfbd6d96915_shop_location_rates_and_hours.py @@ -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 ### diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2 index cfb1863..8028276 100644 --- a/make_post_sell/templates/actions_view.j2 +++ b/make_post_sell/templates/actions_view.j2 @@ -28,6 +28,10 @@
⚙   Shop Settings +
+
+ 🏠   Shop Locations + {% if request.is_saas_domain %}

diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index b5ee528..7a359c0 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -56,7 +56,11 @@
- + + {% if request.shop and request.shop.shop_locations.all()|length > 1 %} + Shop Locations + {% endif %} + {% if request.user and request.user.authenticated %} {% if request.user.full_name %} @@ -67,7 +71,7 @@ {% else %} - My Account + My Account {% endif %} 🛒 Cart ${{ '%0.2f' % request.active_cart.total }} ({{ request.active_cart.count }}) diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index f4ada08..3227271 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] %}
{% if "thumbnail1" in product.extensions %} @@ -131,6 +130,31 @@ {% endfor %} + + {% if cart.physical_products %} +

Physical Handling Options

+
+ {% if request.shop_location.local_pickup %} + +
+ {% endif %} + {% if request.shop_location.local_delivery %} + +
+ {% endif %} + {% if request.shop_location.local_shipping %} + +
+ {% endif %} + {% if request.shop_location.international_shipping %} + +
+ {% endif %} + +
+ {% endif %} + +
{% if cart.is_discounted %} @@ -148,7 +172,6 @@ {% endfor %} -

@@ -173,7 +196,7 @@

Total: ${{ '%0.2f' % cart.total }}

{% endif %} - {% if not cart.is_empty %} + {% if not cart.is_empty and request.user == cart.user %} Checkout @@ -193,9 +216,9 @@ Save Cart
- {% 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 %} Make Cart Active
{% else %} diff --git a/make_post_sell/templates/invoice.j2 b/make_post_sell/templates/invoice.j2 new file mode 100644 index 0000000..a1d743f --- /dev/null +++ b/make_post_sell/templates/invoice.j2 @@ -0,0 +1,40 @@ +{% extends "base.j2" -%} + +{% block content -%} + +

Invoice Details

+ +
+

Invoice ID: {{ invoice.id }}

+

User: {{ invoice.user.name }}

+

Shop: {{ invoice.shop.name }}

+

Created: {{ invoice.human_created_timestamp }}

+

Delivery Address: {{ invoice.delivery_address }}

+

Handling Option: {{ invoice.handling_option }}

+

Handling Cost: ${{ '%0.2f' % (invoice.handling_cost_in_cents / 100) }}

+

Total: ${{ '%0.2f' % invoice.total }}

+ +

Line Items

+ + + + + + + + + + + {% for item in line_items %} + + + + + + + {% endfor %} + +
ProductQuantityPriceTotal
{{ item.product.title }}{{ item.quantity }}${{ '%0.2f' % (item.price.price_in_cents / 100) }}${{ '%0.2f' % ((item.price.price_in_cents * item.quantity) / 100) }}
+
+ +{% endblock %} diff --git a/make_post_sell/templates/join-or-log-in.j2 b/make_post_sell/templates/join-or-log-in.j2 index 92aed0f..1c49749 100644 --- a/make_post_sell/templates/join-or-log-in.j2 +++ b/make_post_sell/templates/join-or-log-in.j2 @@ -27,6 +27,11 @@ required autofocus /> + +

diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 4f9d2be..4fa3d33 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -58,24 +58,41 @@

${{ '%0.2f' % product.price }}

{% if product.is_ready %} - - {% if signed_get_object_url is not none %} - ⭳ Download -
-
-
-
+ + {% if not product.is_physical %} + + {% if signed_get_object_url is not none %} + ⭳ Download +
+
+
+
+ {% endif %} {% endif %} - Add To Cart -
- {% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %} -
- Already purchased? log in -
-
-
+ {% if product.is_physical %} + {% set inventory = product.inventories | selectattr('shop_location_id', 'equalto', request.shop_location.id) | first %} + {% if inventory and inventory.quantity > 0 %} + Add To Cart + {% else %} + + {% if request.shop.shop_locations.all()|length > 1 %} +

This item is sold out at this location. +
You can switch shop locations to check availability.

+ {% endif %} {% endif %} + {% else %} + Add To Cart + {% endif %} + +
+ {% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %} +
+ Already purchased? log in +
+
+
+ {% endif %} {% if "preview" in product.extensions %} View Preview diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 index 6383e1d..9bf8194 100644 --- a/make_post_sell/templates/product_edit.j2 +++ b/make_post_sell/templates/product_edit.j2 @@ -33,8 +33,7 @@ permanent link: {{ product.absolut
  • {{p.title}} - +

  • @@ -42,6 +41,38 @@ permanent link:
    {{ product.absolut {% endif %} + {% elif product.is_physical %} + +

    Inventory Management

    + +
    + + + + + + + + + {% for location in locations %} + + + + + {% endfor %} + +
    LocationQuantity
    {{ location.name }} + +
    + +
    + {% else %}

    Upload or Replace Product File

    @@ -63,8 +94,6 @@ permanent link:
    {{ product.absolut - {% endif %} - {% if product.is_sellable %}
    @@ -88,12 +117,11 @@ permanent link:
    {{ product.absolut - + {% endif %} {% endif %}
    -

    Upload or Replace Thumbnail Files

    @@ -103,7 +131,6 @@ Your product cover (thumbnail1) will show up on search pages.
    - {% set s3_key_thumbnails = product.s3_key_thumbnails %} {% for thumbnail_key in product.file_thumbnail_keys %} @@ -130,7 +157,6 @@ Your product cover (thumbnail1) will show up on search pages. - {% endfor %} diff --git a/make_post_sell/templates/product_new.j2 b/make_post_sell/templates/product_new.j2 index d12fe49..e68e00b 100644 --- a/make_post_sell/templates/product_new.j2 +++ b/make_post_sell/templates/product_new.j2 @@ -38,7 +38,7 @@ id = "description_input" class = "mps-product-description" placeholder = "Product Description" - required />{{ description }} + required>{{ description }}

    @@ -57,16 +57,47 @@
    - If checked, this product will bundle other existing products. + If checked, this product will bundle other existing products.
    (playlist, gallery, album, collection, series, etc.)

    + +
    + + + {% if shop_has_locations %} + + + {% else %} + + location first) + {% endif %} +
    + +
    +
    + + + {%- endblock -%} diff --git a/make_post_sell/templates/shop_location_form.j2 b/make_post_sell/templates/shop_location_form.j2 new file mode 100644 index 0000000..87ecfd1 --- /dev/null +++ b/make_post_sell/templates/shop_location_form.j2 @@ -0,0 +1,200 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    +
    +

    {{ "Edit" if location_id else "New" }} Shop Location

    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + +
    +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_locations.j2 b/make_post_sell/templates/shop_locations.j2 new file mode 100644 index 0000000..5028377 --- /dev/null +++ b/make_post_sell/templates/shop_locations.j2 @@ -0,0 +1,27 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    +
    +

    Shop Locations

    + + {% if request.user and request.user.can_edit_shop(request.shop) %} + Add New Location + {% endif %} +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_products.j2 b/make_post_sell/templates/shop_products.j2 index a7c6352..a860f6e 100644 --- a/make_post_sell/templates/shop_products.j2 +++ b/make_post_sell/templates/shop_products.j2 @@ -10,7 +10,7 @@ tr { text-align: left;} - {% for product in products %} @@ -20,6 +20,7 @@ tr { text-align: left;} + diff --git a/make_post_sell/templates/verification-challenge.j2 b/make_post_sell/templates/verification-challenge.j2 index 39f9700..e8894a5 100644 --- a/make_post_sell/templates/verification-challenge.j2 +++ b/make_post_sell/templates/verification-challenge.j2 @@ -26,6 +26,11 @@ required autofocus /> + +

    diff --git a/make_post_sell/views/__init__.py b/make_post_sell/views/__init__.py index ba147dd..94c555e 100644 --- a/make_post_sell/views/__init__.py +++ b/make_post_sell/views/__init__.py @@ -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 diff --git a/make_post_sell/views/authentication.py b/make_post_sell/views/authentication.py index 0a6073c..54f09d0 100644 --- a/make_post_sell/views/authentication.py +++ b/make_post_sell/views/authentication.py @@ -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")), } diff --git a/make_post_sell/views/billing.py b/make_post_sell/views/billing.py index 02d85ba..03a2f4f 100644 --- a/make_post_sell/views/billing.py +++ b/make_post_sell/views/billing.py @@ -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() diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index d261d96..d7202f2 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -48,7 +48,7 @@ def save_cart(request): @user_required( flash_msg="To save 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 checkout 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") diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index 2d16c2d..22bd62e 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -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 = "" diff --git a/make_post_sell/views/coupon.py b/make_post_sell/views/coupon.py index 5459e46..47dbf69 100644 --- a/make_post_sell/views/coupon.py +++ b/make_post_sell/views/coupon.py @@ -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") diff --git a/make_post_sell/views/invoice.py b/make_post_sell/views/invoice.py new file mode 100644 index 0000000..7b5f41c --- /dev/null +++ b/make_post_sell/views/invoice.py @@ -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(), + } + diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 1d50958..3f2b2b6 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -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( ( - "You successfully uploadeda new {} file!".format(uploaded_file_key), - "success" + 'You successfully uploadeda new {} file!'.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")) diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 2533e72..dddb805 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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], ] diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py new file mode 100644 index 0000000..bef01c1 --- /dev/null +++ b/make_post_sell/views/shop_location.py @@ -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) diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py index 9a2d6db..05b3615 100644 --- a/make_post_sell/views/user.py +++ b/make_post_sell/views/user.py @@ -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")
    Sellable Price Visibility Bundle Ready Title Size +
    Sellable Price Visibility Bundle Physical Ready Title Size
    ${{ '%0.2f' % product.price }} {{ product.human_visibility }} {{ product.is_bundle }}{{ product.is_physical }} {{ product.is_ready }} {{ product.title }} {{ product.human_total_file_bytes }}