From 722de10485f19c88de002a0e1f5002accb81a53c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 5 Jun 2024 13:24:28 -0400 Subject: [PATCH 01/28] stub in new shop_location and inventory models --- make_post_sell/models/inventory.py | 31 ++++++++++++++++++++++++++ make_post_sell/models/shop_location.py | 30 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 make_post_sell/models/inventory.py create mode 100644 make_post_sell/models/shop_location.py diff --git a/make_post_sell/models/inventory.py b/make_post_sell/models/inventory.py new file mode 100644 index 0000000..737d994 --- /dev/null +++ b/make_post_sell/models/inventory.py @@ -0,0 +1,31 @@ +import uuid +from sqlalchemy import Column, Integer, BigInteger +from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key +from sqlalchemy.orm import relationship, backref + +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", backref=backref("inventories", cascade="all, delete-orphan")) + product = relationship("Product", backref=backref("inventories", cascade="all, delete-orphan")) + + 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() diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py new file mode 100644 index 0000000..4885af8 --- /dev/null +++ b/make_post_sell/models/shop_location.py @@ -0,0 +1,30 @@ +import uuid +from sqlalchemy import Column, Unicode, BigInteger +from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key +from sqlalchemy.orm import relationship, backref + +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) + + shop = relationship("Shop", backref=backref("shop_locations", cascade="all, delete-orphan")) + + 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() From 9dd56e0898e7a2ced70723e768d2be3cbdd484e6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 5 Jun 2024 13:28:43 -0400 Subject: [PATCH 02/28] register new tables with ORM modified: make_post_sell/models/meta.py --- make_post_sell/models/meta.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 209f0e0..c6cc878 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -18,6 +18,8 @@ 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", From 8af327bed1601738e35c4288e0b23e93c6a5d0c9 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 10:14:51 -0400 Subject: [PATCH 03/28] migration scripts for adding new tables and fields. modified: make_post_sell/models/__init__.py modified: make_post_sell/models/inventory.py modified: make_post_sell/models/meta.py modified: make_post_sell/models/product.py modified: make_post_sell/models/shop.py modified: make_post_sell/models/shop_location.py new file: make_post_sell/scripts/alembic/versions/cc6211ec360e_physical_products_shop_locations_and_.py --- make_post_sell/models/__init__.py | 2 + make_post_sell/models/inventory.py | 4 +- make_post_sell/models/meta.py | 1 - make_post_sell/models/product.py | 19 +++--- make_post_sell/models/shop.py | 4 ++ make_post_sell/models/shop_location.py | 2 +- ...e_physical_products_shop_locations_and_.py | 61 +++++++++++++++++++ 7 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/cc6211ec360e_physical_products_shop_locations_and_.py 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/inventory.py b/make_post_sell/models/inventory.py index 737d994..430f274 100644 --- a/make_post_sell/models/inventory.py +++ b/make_post_sell/models/inventory.py @@ -13,8 +13,8 @@ class Inventory(RBase, Base): created_timestamp = Column(BigInteger, nullable=False) updated_timestamp = Column(BigInteger, nullable=False) - shop_location = relationship("ShopLocation", backref=backref("inventories", cascade="all, delete-orphan")) - product = relationship("Product", backref=backref("inventories", cascade="all, delete-orphan")) + shop_location = relationship("ShopLocation", uselist=False) + product = relationship("Product", uselist=False) def __init__(self, shop_location, product, quantity=0): if not product.is_physical: diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index c6cc878..7368a9e 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -16,7 +16,6 @@ UUIDType = TempUUIDType(binary=False) CLASS_TO_TABLE = { - "Address": "mps_address", "Shop": "mps_shop", "ShopLocation": "mps_shop_location", "Inventory": "mps_inventory", diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 773de89..3339c2e 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="dynamic", 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", @@ -241,6 +240,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""" 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 index 4885af8..03d03bf 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -16,7 +16,7 @@ class ShopLocation(RBase, Base): postal_code = Column(Unicode(16), nullable=False) created_timestamp = Column(BigInteger, nullable=False) - shop = relationship("Shop", backref=backref("shop_locations", cascade="all, delete-orphan")) + shop = relationship("Shop", uselist=False) def __init__(self, shop, name, address, city, state, country, postal_code): self.id = uuid.uuid1() 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 ### From 47be09817f69fec4b5cf99e649c5f15c71dfc08c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 10:26:35 -0400 Subject: [PATCH 04/28] remove unneeded references. modified: make_post_sell/models/inventory.py modified: make_post_sell/models/shop_location.py --- make_post_sell/models/inventory.py | 2 +- make_post_sell/models/shop_location.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/make_post_sell/models/inventory.py b/make_post_sell/models/inventory.py index 430f274..b20cea8 100644 --- a/make_post_sell/models/inventory.py +++ b/make_post_sell/models/inventory.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import Column, Integer, BigInteger from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key -from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm import relationship class Inventory(RBase, Base): """This class represents the inventory of a product at a specific location.""" diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py index 03d03bf..ecc1b64 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import Column, Unicode, BigInteger from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key -from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm import relationship class ShopLocation(RBase, Base): """This class represents a physical location of a shop.""" From a1037a54e8e7e8f9a0b43cc068b4b57a3f1e4fa2 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 11:45:39 -0400 Subject: [PATCH 05/28] more fields for shop_location modified: make_post_sell/models/shop_location.py new file: make_post_sell/scripts/alembic/versions/ddfbd6d96915_shop_location_rates_and_hours.py --- make_post_sell/models/shop_location.py | 44 +++++++++++++++++- ...bd6d96915_shop_location_rates_and_hours.py | 46 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/ddfbd6d96915_shop_location_rates_and_hours.py diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py index ecc1b64..528a49b 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -1,7 +1,8 @@ import uuid -from sqlalchemy import Column, Unicode, BigInteger +from sqlalchemy import Column, Unicode, BigInteger, Boolean, Integer from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, backref +from ..lib.currency import cents_to_dollars, dollars_to_cents class ShopLocation(RBase, Base): """This class represents a physical location of a shop.""" @@ -16,6 +17,21 @@ class ShopLocation(RBase, Base): 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): @@ -28,3 +44,27 @@ class ShopLocation(RBase, Base): 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) 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 ### From c17c4bf2e4dad9132782cf03b8aa2a81a4a0b95b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 11:51:18 -0400 Subject: [PATCH 06/28] modified: make_post_sell/models/shop_location.py --- make_post_sell/models/shop_location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py index 528a49b..dc144f7 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import Column, Unicode, BigInteger, Boolean, Integer from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key -from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm import relationship from ..lib.currency import cents_to_dollars, dollars_to_cents class ShopLocation(RBase, Base): From 3452cf7a58c5cfca783dd124fa113fbafbbfae2c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 12:04:14 -0400 Subject: [PATCH 07/28] modified: make_post_sell/models/shop_location.py --- make_post_sell/models/shop_location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py index dc144f7..1895266 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, Unicode, BigInteger, Boolean, Integer +from sqlalchemy import Column, Unicode, BigInteger, Boolean from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key from sqlalchemy.orm import relationship from ..lib.currency import cents_to_dollars, dollars_to_cents From 505947fd6bb26918803b811286059a6fb85e0eb6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 13:15:58 -0400 Subject: [PATCH 08/28] GPT-4 helped me stub in the forms for creating and updating shop locations modified: make_post_sell/routes.py modified: make_post_sell/templates/actions_view.j2 new file: make_post_sell/templates/shop_location_form.j2 new file: make_post_sell/templates/shop_locations.j2 new file: make_post_sell/views/shop_location.py --- make_post_sell/routes.py | 10 +- make_post_sell/templates/actions_view.j2 | 4 + .../templates/shop_location_form.j2 | 200 ++++++++++++++++++ make_post_sell/templates/shop_locations.j2 | 22 ++ make_post_sell/views/shop_location.py | 178 ++++++++++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 make_post_sell/templates/shop_location_form.j2 create mode 100644 make_post_sell/templates/shop_locations.j2 create mode 100644 make_post_sell/views/shop_location.py diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 9cc7ea1..c987c50 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -70,6 +70,11 @@ 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") + # shop routes. config.add_route("shop_new", "/s/new") @@ -105,7 +110,10 @@ def includeme(config): config.add_route("product_description_edit", "/p/{product_id}/description/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/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/shop_location_form.j2 b/make_post_sell/templates/shop_location_form.j2 new file mode 100644 index 0000000..314a257 --- /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..5ce420a --- /dev/null +++ b/make_post_sell/templates/shop_locations.j2 @@ -0,0 +1,22 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
+
+

Shop Locations

+ Add New Location + +
+
+ +{%- endblock -%} diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py new file mode 100644 index 0000000..884acc0 --- /dev/null +++ b/make_post_sell/views/shop_location.py @@ -0,0 +1,178 @@ +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPFound + +from . import user_required, shop_owner_required, shop_editor_required +from ..models.shop import get_shop_by_id +from ..models.shop_location import ShopLocation +from ..lib.currency import dollars_to_cents + + +def checkbox_to_bool(checkbox): + return True if checkbox == "on" else False + +def bool_to_checkbox(boolean): + return "on" if boolean == True 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") +@shop_owner_required() +def shop_locations(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")) + + return { + "shop": shop, + "locations": shop.shop_locations, + } From 2e79f44089a7209409dc5aac8a1e859367439278 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 17:19:10 -0400 Subject: [PATCH 09/28] More work for physical products modified: make_post_sell/models/product.py modified: make_post_sell/routes.py modified: make_post_sell/templates/product.j2 modified: make_post_sell/templates/product_edit.j2 modified: make_post_sell/templates/product_new.j2 modified: make_post_sell/templates/shop_products.j2 modified: make_post_sell/views/__init__.py modified: make_post_sell/views/authentication.py modified: make_post_sell/views/billing.py modified: make_post_sell/views/cart.py modified: make_post_sell/views/content.py modified: make_post_sell/views/coupon.py modified: make_post_sell/views/product.py modified: make_post_sell/views/shop.py modified: make_post_sell/views/shop_location.py modified: make_post_sell/views/user.py --- make_post_sell/models/product.py | 11 +- make_post_sell/routes.py | 2 + make_post_sell/templates/product.j2 | 17 ++- make_post_sell/templates/product_edit.j2 | 42 ++++-- make_post_sell/templates/product_new.j2 | 35 ++++- make_post_sell/templates/shop_products.j2 | 3 +- make_post_sell/views/__init__.py | 16 +-- make_post_sell/views/authentication.py | 9 +- make_post_sell/views/billing.py | 19 +-- make_post_sell/views/cart.py | 95 ++++++++----- make_post_sell/views/content.py | 12 +- make_post_sell/views/coupon.py | 29 ++-- make_post_sell/views/product.py | 162 ++++++++++++++++------ make_post_sell/views/shop.py | 115 ++++++++------- make_post_sell/views/shop_location.py | 13 +- make_post_sell/views/user.py | 21 +-- 16 files changed, 389 insertions(+), 212 deletions(-) diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 3339c2e..20bd5b6 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -202,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 @@ -345,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) @@ -507,7 +509,6 @@ def get_products_from_a_shop(shop, visibility=1): def get_products_by_keywords(dbsession, keywords, shop=None): - scores = {} hits = {} @@ -517,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 = ( @@ -525,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/routes.py b/make_post_sell/routes.py index c987c50..8510d3d 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -108,12 +108,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_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/templates/product.j2 b/make_post_sell/templates/product.j2 index 4f9d2be..9ab8035 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -58,13 +58,16 @@

${{ '%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 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.)

    + +
    + +
    +
    + + + {%- 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/views/__init__.py b/make_post_sell/views/__init__.py index ba147dd..712e78d 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,7 +15,6 @@ 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) @@ -24,6 +24,7 @@ def user_required( return HTTPFound(get_referer_or_home(request)) return inner + return wrapped @@ -35,7 +36,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 +65,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 +91,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..3a3812f 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,7 +74,9 @@ 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", ) @@ -88,7 +92,6 @@ def join_or_log_in(request): @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", "") 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..4bb5bf3 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() @@ -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.active + 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: @@ -234,7 +246,6 @@ def cart_add_product(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: @@ -251,8 +262,10 @@ def cart_remove_product(request): 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") + 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") cart.remove_product(product) request.dbsession.add(cart) request.dbsession.flush() @@ -266,7 +279,6 @@ def cart_remove_product(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 +290,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) @@ -302,11 +313,18 @@ 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") + 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", + ) cart.set_product_quantity(product, quantity) request.dbsession.add(cart) request.dbsession.flush() @@ -323,11 +341,10 @@ def cart_quantity_product(request): @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: @@ -359,7 +376,7 @@ def cart_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)) msg = ("Please confirm your order.", "info") @@ -367,7 +384,7 @@ def cart_checkout(request): 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,7 +416,7 @@ 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 @@ -429,34 +446,34 @@ def cart_complete_checkout(request): # only create a real charge if the cart has a positive price. # this conditional allows us to support freebies. if cart.requires_payment: + stripe_user_shop = request.shop.stripe_user_shop(request.user) - 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, + 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, ) except request.shop.stripe.error.CardError as e: - # rollback changes to database because charge was NOT successful! request.dbsession.rollback() err = e.error # Error code will be authentication_required if authentication is needed - payment_intent_id = err.payment_intent['id'] + payment_intent_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") + 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 - if successful_checkout: # flush changes to database because charge was successful! @@ -464,7 +481,9 @@ def cart_complete_checkout(request): msg = ("Success, you have completed the purchase!", "success") request.session.flash(msg) # email the authenticated user regarding their purchase. - send_purchase_email(request, request.user.email, cart.products.values(), cart.total) + 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) # save the cart, so that we end up with a new empty cart. 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..320c59e 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 diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 1d50958..88b3341 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,53 @@ 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)) + + for location in ( + request.dbsession.query(ShopLocation).filter_by(shop_id=product.shop_id).all() + ): + quantity = request.params.get(f"inventory_{location.id}", None) + if quantity is not None: + try: + quantity = int(quantity) + inventory = ( + request.dbsession.query(Inventory) + .filter_by(product_id=product.id, shop_location_id=location.id) + .first() + ) + if inventory: + inventory.update_quantity(quantity) + else: + inventory = Inventory( + shop_location=location, product=product, quantity=quantity + ) + request.dbsession.add(inventory) + except ValueError: + request.session.flash( + (f"Invalid quantity for location {location.name}.", "error") + ) + + 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 +404,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 +426,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 +434,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..2327089 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 = ( + "Sheepishly refusing to remove myself!".format(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 index 884acc0..8ae59c4 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -10,6 +10,7 @@ from ..lib.currency import dollars_to_cents def checkbox_to_bool(checkbox): return True if checkbox == "on" else False + def bool_to_checkbox(boolean): return "on" if boolean == True else "off" @@ -164,15 +165,9 @@ def shop_location_edit(request): @view_config(route_name="shop_locations", renderer="shop_locations.j2") -@shop_owner_required() +# @shop_owner_required() def shop_locations(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")) - return { - "shop": shop, - "locations": shop.shop_locations, + "shop": request.shop, + "locations": request.shop.shop_locations, } 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") From 0b7e64397624185b782a59e9e01980809116e89e Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 18:29:16 -0400 Subject: [PATCH 10/28] Better sql query for product inventories modified: make_post_sell/models/product.py modified: make_post_sell/routes.py modified: make_post_sell/templates/shop_locations.j2 modified: make_post_sell/views/product.py modified: make_post_sell/views/shop.py modified: make_post_sell/views/shop_location.py --- make_post_sell/models/product.py | 2 +- make_post_sell/routes.py | 1 + make_post_sell/templates/shop_locations.j2 | 9 +++++++-- make_post_sell/views/product.py | 21 ++++----------------- make_post_sell/views/shop.py | 2 +- make_post_sell/views/shop_location.py | 21 +++++++++++++++++++-- 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 20bd5b6..706054c 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -108,7 +108,7 @@ class Product(RBase, Base): is_physical = Column(Boolean, default=False, nullable=False) # Add relationship to inventories - inventories = relationship("Inventory", lazy="dynamic", back_populates="product") + 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. diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 8510d3d..d7f1f35 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -74,6 +74,7 @@ def includeme(config): config.add_route("shop_location_new", "/s/{shop_id}/locations/new") config.add_route("shop_location_edit", "/s/{shop_id}/locations/{location_id}/edit") config.add_route("shop_locations", "/s/{shop_id}/locations") + config.add_route('shop_location_switch', '/shop/{shop_id}/location/{location_id}/switch') # shop routes. config.add_route("shop_new", "/s/new") diff --git a/make_post_sell/templates/shop_locations.j2 b/make_post_sell/templates/shop_locations.j2 index 5ce420a..1be942b 100644 --- a/make_post_sell/templates/shop_locations.j2 +++ b/make_post_sell/templates/shop_locations.j2 @@ -8,14 +8,19 @@

    Shop Locations

    - Add New Location + {% if request.user.can_edit_shop(request.shop) %} + Add New Location + {% endif %}
    diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 88b3341..0911940 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -364,28 +364,15 @@ def product_inventory_edit(request): request.session.flash(("This product is not physical.", "error")) return HTTPFound(get_referer_or_home(request)) - for location in ( - request.dbsession.query(ShopLocation).filter_by(shop_id=product.shop_id).all() - ): - quantity = request.params.get(f"inventory_{location.id}", None) + for inventory in product.inventories: + quantity = request.params.get(f"inventory_{inventory.shop_location.id}", None) if quantity is not None: try: quantity = int(quantity) - inventory = ( - request.dbsession.query(Inventory) - .filter_by(product_id=product.id, shop_location_id=location.id) - .first() - ) - if inventory: - inventory.update_quantity(quantity) - else: - inventory = Inventory( - shop_location=location, product=product, quantity=quantity - ) - request.dbsession.add(inventory) + inventory.update_quantity(quantity) except ValueError: request.session.flash( - (f"Invalid quantity for location {location.name}.", "error") + (f"Invalid quantity for location {inventory.shop_location.name}.", "error") ) request.dbsession.flush() diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 2327089..dddb805 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -320,7 +320,7 @@ def shop_user_remove(request): if user: if user == request.user: msg = ( - "Sheepishly refusing to remove myself!".format(user.email), + f"Sheepishly refusing to remove myself! {user.email}", "error", ) request.session.flash(msg) diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py index 8ae59c4..832c30d 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -1,10 +1,9 @@ from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound -from . import user_required, shop_owner_required, shop_editor_required +from . import shop_owner_required from ..models.shop import get_shop_by_id from ..models.shop_location import ShopLocation -from ..lib.currency import dollars_to_cents def checkbox_to_bool(checkbox): @@ -171,3 +170,21 @@ def shop_locations(request): "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") + + # 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["current_shop_location"] = str(location.id) + + # Redirect to the shop home page + return HTTPFound(request.route_url("shop", shop_id=shop_id)) From f218e30ad920c20015ab16b85d47f4e8779ed9d1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 19:26:04 -0400 Subject: [PATCH 11/28] fix inventory management modified: make_post_sell/views/product.py --- make_post_sell/views/product.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 0911940..3f2b2b6 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -340,7 +340,7 @@ def product_edit(request): .all() ) inventories = { - inv.shop_location_id: inv.quantity for inv in product.inventories + inv.shop_location.id: inv.quantity for inv in product.inventories } return { @@ -364,19 +364,33 @@ def product_inventory_edit(request): request.session.flash(("This product is not physical.", "error")) return HTTPFound(get_referer_or_home(request)) - for inventory in product.inventories: - quantity = request.params.get(f"inventory_{inventory.shop_location.id}", None) + # 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.update_quantity(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 {inventory.shop_location.name}.", "error") - ) + request.session.flash((f"Invalid quantity for location {location.name}.", "error")) + + if updated: + request.dbsession.flush() + request.session.flash(("Inventory updated successfully.", "success")) - request.dbsession.flush() - request.session.flash(("Inventory updated successfully.", "success")) return HTTPFound(get_referer_or_home(request)) From 6055eed135875f56d24850c4d2cb5e0163f01e4b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 19:43:44 -0400 Subject: [PATCH 12/28] flash when shop location is switched. --- make_post_sell/views/shop_location.py | 1 + 1 file changed, 1 insertion(+) diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py index 832c30d..3de6662 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -185,6 +185,7 @@ def shop_location_switch(request): # Update the session with the selected location request.session["current_shop_location"] = str(location.id) + request.session.flash((f"You are now viewing the {location.name} shop location", "success")) # Redirect to the shop home page return HTTPFound(request.route_url("shop", shop_id=shop_id)) From 8705842e454865b827f997cb587c07535e0665be Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 8 Jun 2024 20:30:17 -0400 Subject: [PATCH 13/28] prevent adding to cart if no inventory at shop_location modified: make_post_sell/models/shop_location.py modified: make_post_sell/request_methods.py modified: make_post_sell/routes.py modified: make_post_sell/templates/product.j2 modified: make_post_sell/templates/shop_locations.j2 modified: make_post_sell/views/shop_location.py --- make_post_sell/models/shop_location.py | 7 +++++- make_post_sell/request_methods.py | 11 +++++++-- make_post_sell/routes.py | 2 +- make_post_sell/templates/product.j2 | 26 +++++++++++++++------- make_post_sell/templates/shop_locations.j2 | 2 +- make_post_sell/views/shop_location.py | 2 +- 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/make_post_sell/models/shop_location.py b/make_post_sell/models/shop_location.py index 1895266..2f4f209 100644 --- a/make_post_sell/models/shop_location.py +++ b/make_post_sell/models/shop_location.py @@ -1,6 +1,6 @@ import uuid from sqlalchemy import Column, Unicode, BigInteger, Boolean -from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key +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 @@ -68,3 +68,8 @@ class ShopLocation(RBase, Base): @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 d7f1f35..4ccd12a 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -74,7 +74,7 @@ def includeme(config): config.add_route("shop_location_new", "/s/{shop_id}/locations/new") config.add_route("shop_location_edit", "/s/{shop_id}/locations/{location_id}/edit") config.add_route("shop_locations", "/s/{shop_id}/locations") - config.add_route('shop_location_switch', '/shop/{shop_id}/location/{location_id}/switch') + config.add_route('shop_location_switch', '/s/{shop_id}/location/{location_id}/switch') # shop routes. config.add_route("shop_new", "/s/new") diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 9ab8035..c59a9cf 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -70,15 +70,25 @@ {% 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 %} + Sold Out {% 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/shop_locations.j2 b/make_post_sell/templates/shop_locations.j2 index 1be942b..83d0d05 100644 --- a/make_post_sell/templates/shop_locations.j2 +++ b/make_post_sell/templates/shop_locations.j2 @@ -13,7 +13,7 @@
  • {{ location.name }} {% if request.user.can_edit_shop(request.shop) %} - edit + ⚙ edit {% endif %}
  • {% endfor %} diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py index 3de6662..a2f3630 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -184,7 +184,7 @@ def shop_location_switch(request): return HTTPFound(request.route_url("shop_locations", shop_id=shop_id)) # Update the session with the selected location - request.session["current_shop_location"] = str(location.id) + request.session["shop_location_id"] = str(location.id) request.session.flash((f"You are now viewing the {location.name} shop location", "success")) # Redirect to the shop home page From 6706223f17fe1ade14d95c3853f6fb999dde0291 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 9 Jun 2024 11:00:19 -0400 Subject: [PATCH 14/28] allow users without being logged in to switch shop locations modified: make_post_sell/templates/product.j2 modified: make_post_sell/templates/shop_locations.j2 modified: make_post_sell/views/shop_location.py --- make_post_sell/templates/product.j2 | 6 +++++- make_post_sell/templates/shop_locations.j2 | 6 +++--- make_post_sell/views/shop_location.py | 20 +++++++++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index c59a9cf..4fa3d33 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -75,7 +75,11 @@ {% if inventory and inventory.quantity > 0 %} Add To Cart {% else %} - Sold Out + + {% 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 diff --git a/make_post_sell/templates/shop_locations.j2 b/make_post_sell/templates/shop_locations.j2 index 83d0d05..5028377 100644 --- a/make_post_sell/templates/shop_locations.j2 +++ b/make_post_sell/templates/shop_locations.j2 @@ -11,14 +11,14 @@
      {% for location in locations %}
    • - {{ location.name }} - {% if request.user.can_edit_shop(request.shop) %} + {{ location.name }} + {% if request.user and request.user.can_edit_shop(request.shop) %} ⚙ edit {% endif %}
    • {% endfor %}
    - {% if request.user.can_edit_shop(request.shop) %} + {% if request.user and request.user.can_edit_shop(request.shop) %} Add New Location {% endif %} diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py index a2f3630..ba13ee5 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -171,21 +171,31 @@ def shop_locations(request): "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() + 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")) + 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")) + request.session.flash( + (f"You are now viewing the {location.name} shop location", "success") + ) - # Redirect to the shop home page - return HTTPFound(request.route_url("shop", shop_id=shop_id)) + # Redirect to the next URL or the shop home page + return HTTPFound(next_url) From b4698efb997e5f69a2c46b72a44946599b461fdd Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 9 Jun 2024 16:34:31 -0400 Subject: [PATCH 15/28] modified: make_post_sell/views/shop_location.py --- make_post_sell/views/shop_location.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/make_post_sell/views/shop_location.py b/make_post_sell/views/shop_location.py index ba13ee5..6c22e54 100644 --- a/make_post_sell/views/shop_location.py +++ b/make_post_sell/views/shop_location.py @@ -11,7 +11,7 @@ def checkbox_to_bool(checkbox): def bool_to_checkbox(boolean): - return "on" if boolean == True else "off" + return "on" if boolean else "off" @view_config(route_name="shop_location_new", renderer="shop_location_form.j2") @@ -164,7 +164,6 @@ def shop_location_edit(request): @view_config(route_name="shop_locations", renderer="shop_locations.j2") -# @shop_owner_required() def shop_locations(request): return { "shop": request.shop, From e5c49e5347acb30a03b5eebbc11478b18e5756b2 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 9 Jun 2024 17:06:02 -0400 Subject: [PATCH 16/28] Shop Locations - fast switcher modified: make_post_sell/templates/base.j2 --- make_post_sell/templates/base.j2 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index b5ee528..5823035 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -56,7 +56,11 @@
    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 }}
    + + + + + + + + + + {% 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/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(), + } + From 597ac4938b9ddb1a69b2790e278262e0377ea4c5 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 16 Jun 2024 21:58:11 -0400 Subject: [PATCH 23/28] fixup shop_location_form template modified: make_post_sell/templates/shop_location_form.j2 --- make_post_sell/templates/shop_location_form.j2 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/make_post_sell/templates/shop_location_form.j2 b/make_post_sell/templates/shop_location_form.j2 index 314a257..87ecfd1 100644 --- a/make_post_sell/templates/shop_location_form.j2 +++ b/make_post_sell/templates/shop_location_form.j2 @@ -112,24 +112,24 @@

    - +

    - +

    @@ -146,13 +146,13 @@

    - +

    @@ -169,13 +169,13 @@

    - +

    From c9460d10ed45957068025f3552585f2bbc83d079 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 16 Jun 2024 22:35:29 -0400 Subject: [PATCH 24/28] make carts private again... lol modified: make_post_sell/views/cart.py --- make_post_sell/views/cart.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 42a67de..b2352bb 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -172,12 +172,8 @@ def cart_by_id(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.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: From 62283edffd5de6352f09fabe3e111a443bb7e6c6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Mon, 17 Jun 2024 18:43:02 -0400 Subject: [PATCH 25/28] Only allow carts without owners to be modified by unauthenticated users. If a cart has an owner, only the owner can make changes to it. modified: make_post_sell/views/cart.py --- make_post_sell/views/cart.py | 75 +++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index b2352bb..921ee38 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -172,8 +172,12 @@ def cart_by_id(request): if cart is None: msg = ("That cart_id does not exist.", "error") - elif 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: @@ -217,27 +221,28 @@ 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") @@ -255,21 +260,25 @@ 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) - ): + 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)) @@ -302,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) @@ -314,10 +333,7 @@ def cart_quantity_product(request): ("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) - ): + else: msg = ( 'You updated the quantity of "{}" in the cart.'.format(product.title), "success", @@ -326,9 +342,6 @@ def cart_quantity_product(request): 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)) @@ -338,6 +351,14 @@ 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", From f169d23aad534cf019f86a2385fb135fd9cd2af3 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 19 Jun 2024 10:13:50 -0400 Subject: [PATCH 26/28] make sure we can clone carts and merge all attributes into new cart. modified: make_post_sell/models/cart.py modified: make_post_sell/routes.py modified: make_post_sell/templates/cart.j2 modified: make_post_sell/templates/join-or-log-in.j2 modified: make_post_sell/templates/verification-challenge.j2 modified: make_post_sell/views/__init__.py modified: make_post_sell/views/authentication.py modified: make_post_sell/views/cart.py --- make_post_sell/models/cart.py | 9 +++++++++ make_post_sell/routes.py | 3 ++- make_post_sell/templates/cart.j2 | 6 +++--- make_post_sell/templates/join-or-log-in.j2 | 5 +++++ .../templates/verification-challenge.j2 | 5 +++++ make_post_sell/views/__init__.py | 5 ++++- make_post_sell/views/authentication.py | 15 ++++++++++++--- make_post_sell/views/cart.py | 4 ++-- 8 files changed, 42 insertions(+), 10 deletions(-) diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index a751036..7e9c6c7 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -156,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 diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 26488fc..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,7 +60,7 @@ def includeme(config): config.add_route("cart_remove_product", "/cart/{cart_id}/remove") config.add_route("cart_quantity_product", "/cart/{cart_id}/quantity") - config.add_route("cart_handling_option", "/u/cart/{cart_id}/handling-option") + config.add_route("cart_handling_option", "/cart/{cart_id}/handling-option") config.add_route("view_invoice", "/invoices/{invoice_id}") diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 index a422cd1..3227271 100644 --- a/make_post_sell/templates/cart.j2 +++ b/make_post_sell/templates/cart.j2 @@ -196,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 @@ -216,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/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/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 712e78d..94c555e 100644 --- a/make_post_sell/views/__init__.py +++ b/make_post_sell/views/__init__.py @@ -19,8 +19,11 @@ def user_required( 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 diff --git a/make_post_sell/views/authentication.py b/make_post_sell/views/authentication.py index 3a3812f..54f09d0 100644 --- a/make_post_sell/views/authentication.py +++ b/make_post_sell/views/authentication.py @@ -82,11 +82,14 @@ def join_or_log_in(request): 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")), } @@ -127,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") @@ -139,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/cart.py b/make_post_sell/views/cart.py index 921ee38..d5d7dee 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -101,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) @@ -119,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: @@ -128,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) From 6b471b6ecdc13c6316a58b91d469206971a6747f Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 19 Jun 2024 10:47:50 -0400 Subject: [PATCH 27/28] keep track of next for main log in button. Allow coupons to be removed from session carts which do not have a user or owner associated. modified: make_post_sell/templates/base.j2 modified: make_post_sell/views/coupon.py --- make_post_sell/templates/base.j2 | 2 +- make_post_sell/views/coupon.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index 5823035..7a359c0 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -71,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/views/coupon.py b/make_post_sell/views/coupon.py index 320c59e..47dbf69 100644 --- a/make_post_sell/views/coupon.py +++ b/make_post_sell/views/coupon.py @@ -97,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") @@ -111,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") From 3ca8ca119062986f7ce2a9b073ba608a084dcb0b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 19 Jun 2024 11:17:20 -0400 Subject: [PATCH 28/28] refactor cart_checkout logic ordering to keep people on the cart page for as long as needed to get a valid cart before asking for address or billing. modified: make_post_sell/views/cart.py --- make_post_sell/views/cart.py | 54 +++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index d5d7dee..d7202f2 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -403,38 +403,42 @@ def cart_checkout(request): elif cart.is_empty: msg = ("That cart is empty, you cannot checkout.", "error") - elif cart.physical_products and not cart.handling_option: - msg = ("Please select a handling option for your physical items.", "error") - - elif cart.physical_products and not request.user.active_address: - msg = ("Please add a shipping address to proceed with checkout.", "info") - request.session.flash(msg) - return HTTPFound("/u/addresses") - - 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")) return HTTPFound(get_referer_or_home(request)) - # check inventory for physical products. - inventory_errors = cart.check_inventory(request.shop_location) - if inventory_errors: - for error_message in inventory_errors: - request.session.flash((error_message, "error")) - return HTTPFound(get_referer_or_home(request)) + # 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)