Просмотр исходного кода

Added ID for unique variations created via style/color options in the item. Fixes issue with variants of the item rewritten if another variant is added (for example customer orders two same shirts with different sizes).

main.py - generates ids on the start of application. Modified parts of code to use new product_variation_id

db.py - added functionality to work with variation ids - create/write/get (all the ids are written to item files at the start of application)

serializer.py - writing variation id to .md files of items

cart.py - small changes for cart to use variation ID

schema.py - variation id included in cart/product models

templates/partial/checkout-item.html - updated view of the cart to show all the selected variations of the item depending on size/style.
slow_tiger 1 год назад
Родитель
Сommit
e329e98a90
7 измененных файлов с 166 добавлено и 85 удалено
  1. 20 16
      app/cart.py
  2. 84 36
      app/db.py
  3. 38 12
      app/main.py
  4. 0 1
      app/parser.py
  5. 2 14
      app/schema.py
  6. 17 5
      app/serializer.py
  7. 5 1
      app/templates/partials/checkout-item.html

+ 20 - 16
app/cart.py

@@ -3,7 +3,7 @@ from typing import NoReturn
 
 import shortuuid
 
-from app.schema import CartUpdate, DocumentProduct, create_product_id, CartSessionItem, Settings, ProductInfoCode
+from app.schema import CartUpdate, DocumentProduct, CartSessionItem, Settings, ProductInfoCode
 from app.template import make_product_info_code
 from app.db import get_product_by_product_id
 
@@ -12,18 +12,18 @@ def product_exist_in_cart(
     product_id: str,
     size: str | None,
     style: str | None,
+    product_variation_id: str | None,
     session_cart_items: dict[str, dict[str, str | dict[str, str]]],
 ) -> bool:
     """Check if given product exists in the cart already by constructing
     a full label using the product's id and eventual size and style
     options.
     """
-    product_id_label = create_product_id(product_id, size, style)
 
-    if product_id_label in session_cart_items:
+    if product_id in session_cart_items or product_variation_id in session_cart_items:
         return True
-    else:
-        return False
+
+    return False
 
 
 def initialize_cart(session, currency: str) -> dict[int, str] | NoReturn:
@@ -46,7 +46,7 @@ def initialize_cart(session, currency: str) -> dict[int, str] | NoReturn:
 
 def get_inventory_amount(session_item, products) -> int | None:
     """Returns the inventory amount value for any product by matching it
-    against the list of selected producs in the cart. We need to make
+    against the list of selected products in the cart. We need to make
     use of the selected product's style and size options in order to
     retrieve the correct inventory amount value.
     """
@@ -108,9 +108,10 @@ def update_cart_meta(
                 session_meta_subtotal += item_quantity * item["price"]
 
             # -- update also each item quantity and availability under session.cart.items
-            cart_product_id = create_product_id(
-                item["product_id"], item["options"]["size"], item["options"]["style"]
-            )
+            if item["options"]["product_variation_id"]:
+                cart_product_id = item["options"]["product_variation_id"]
+            else:
+                cart_product_id = item["product_id"]
 
             session["items"][cart_product_id]["quantity"] = item_quantity
             session["items"][cart_product_id]["availability"] = inventory_amount > 0
@@ -145,9 +146,11 @@ def update_cart(
     item: if Cart contains the product, increase / decrease product's
     quantity, else add / remove the product to / from the Cart.
     """
-    product_id = create_product_id(
-        form.product_id, form.options.size, form.options.style
-    )
+
+    if form.options.product_variation_id:
+        product_id = form.options.product_variation_id # variation ID is used for item session storage if it exists
+    else:
+        product_id = form.product_id
 
     if product_exists:
         # get currently updated product
@@ -189,9 +192,10 @@ def update_cart(
     else:
         if form.operation == "add":
             if quantity <= inventory_amount:
-                session["items"][product_id] = {
+                update_data =  {
                     "product_id": form.product_id,
                     "options": {
+                        "product_variation_id": form.options.product_variation_id,
                         "size": form.options.size,
                         "style": form.options.style,
                     },
@@ -200,6 +204,8 @@ def update_cart(
                     "availability": inventory_amount > 0,
                 }
 
+                session["items"][product_id] = update_data
+
     update_cart_meta(session, shipping_amount)
 
 
@@ -224,8 +230,6 @@ def clear_cart(session, currency: str) -> NoReturn:
 def calculate_quantity_requested_by_user(
     session_cart_items: dict[str, dict[str, str | dict[str, str]]],
     product_id: str,
-    size: str,
-    style: str,
     quantity: int,
 ) -> int:
     """Helper function to retrieve the number of items of a specific
@@ -235,7 +239,7 @@ def calculate_quantity_requested_by_user(
     quantity_requested_by_user = 0
 
     if len(session_cart_items.keys()) > 0:
-        cart_product_id = create_product_id(product_id, size, style)
+        cart_product_id = product_id
 
         if cart_product_id in session_cart_items:
             quantity_requested_by_user = (

+ 84 - 36
app/db.py

@@ -9,7 +9,7 @@ from openpyxl import load_workbook
 from pydantic import ValidationError
 
 from app.git import git_add_commit
-from app.parser import read_dir, read_file
+from app.parser import read_dir, read_file, parse_file
 from app.schema import (
     CartOrderInfo,
     CartSession,
@@ -19,11 +19,10 @@ from app.schema import (
     DocumentProductMeta,
     OrdersBlock,
     ShippingInfo,
-    create_product_id,
     Settings,
-    ProductShippingMeta,    
+    ProductShippingMeta,
 )
-from app.serializer import convert_pydantic_product_to_text, format_yaml_multiline, format_order_items, format_order_shipping_info
+from app.serializer import convert_pydantic_product_to_text, format_order_items, format_order_shipping_info
 
 logger = logging.getLogger(__name__)
 
@@ -46,43 +45,36 @@ def check_product_availability(
     is_selectable = False
     amount = 0
 
-    # we might receive a GET request from /products/<product-id> for a
-    # product with options but no option has been selected yet, cause
-    # the user just navigated to the page. in this case we can't make
-    # any filtering based on the options fields, so we just return the
-    # product to be available.
-    if product.meta.options and size is None and style is None:
-        is_available = True
-        is_selectable = True
-
+    if style or size:
+        product_variation_id = get_variation_id(product, size, style)
     else:
-        selected_inventory = [
-            inventory
-            for inventory in product.meta.inventory
-            if inventory.size == size and inventory.style == style
-        ]
-
-        if len(selected_inventory) > 0:
-            for model in selected_inventory:
-                if model.size == size and model.style == style:
-                    is_available = model.amount > 0
-
-                    # we check if the user's requested quantity for the given item is
-                    # between 1 and <= the model inventory's amount value
-                    is_selectable = (
-                        model.amount - quantity > 0 and quantity <= model.amount
-                    )
-                    amount = model.amount
+        product_variation_id = None
+
+    if product_variation_id:
+        for variant in product.meta.inventory:
+            if variant.product_variation_id == product_variation_id:
+                is_available = variant.amount > 0
+                is_selectable = (variant.amount - quantity) > 0
+                amount = variant.amount
+                break
+    else:
+        is_available = product.meta.inventory[0].amount > 0
+        is_selectable = (product.meta.inventory[0].amount - quantity) > 0
+        amount = product.meta.inventory[0].amount
 
-    return (is_available, is_selectable, amount)
+    return is_available, is_selectable, amount
 
 
 def update_product_inventory(
-    filename: str, quantity: int, size: str, style: str, settings
+    filename: str,
+    quantity: int,
+    product_variation_id: str,
+    settings
 ) -> NoReturn:
     """Read given Product document and update its inventory amount based
     on the given product's size and style. Afterwards write back
     document to disk.
+
     """
     doc = read_file(
         settings.git_repo,
@@ -95,7 +87,7 @@ def update_product_inventory(
     inventory = doc.meta.inventory
 
     for model in inventory:
-        if model.size == size and model.style == style:
+        if model.product_variation_id == product_variation_id:
             # run extra check to see if what would be decreased
             # would amount to at best 0 (eg. not going negative)
             if model.amount - quantity >= 0:
@@ -115,6 +107,64 @@ def update_product_inventory(
         git_add_commit(settings.git_repo, filepath, commit_msg)
 
 
+def create_variation_id(product: DocumentProduct):
+    """
+    Create a list of unique variation IDs mixing main product
+    id with size and style for variation for specified product
+    """
+
+    if len(product.meta.inventory) > 1:
+        variation_ids = []
+        for variant in product.meta.inventory:
+            if not variant.product_variation_id:
+                unique_variation_id = f"{product.meta.product_id}_"
+                if variant.size:
+                    unique_variation_id += f"_{variant.size}"
+                if variant.style:
+                    unique_variation_id += f"_{variant.style}"
+                variation_ids.append(unique_variation_id)
+            else:
+                variation_ids.append(None)
+        return variation_ids
+
+    return None # returns None in case there are no variations of the product or single variant only
+
+
+def write_variation_id(product:DocumentProduct, settings):
+    """ Generate and write IDs for variation of the product into file"""
+
+    filepath = f'{Path(settings.git_repo)}/{product.meta.path}/index.md'
+    product_file = parse_file(settings.git_repo, Path(filepath), settings.block_types)
+
+    ids = create_variation_id(product)
+
+    if ids:
+        for i in range(0, len(product.meta.inventory)):
+            if ids[i]:
+                product_file.meta.inventory[i].product_variation_id = ids[i]
+
+    txt_doc = convert_pydantic_product_to_text(product_file)
+    p = Path(filepath)
+
+    if p.exists():
+        with open(p, "w") as f:
+            f.write(txt_doc)
+
+        # update git repo
+    #    commit_msg = "Update product inventory"
+    #    git_add_commit(settings.git_repo, filepath, commit_msg)
+
+
+def get_variation_id(product:DocumentProduct, size: str | None, style: str | None) -> str | None:
+    """Returns variation ID for product that has size or style"""
+    if size or style:
+        for variant in product.meta.inventory:
+            if variant.size == size and variant.style == style:
+                return variant.product_variation_id
+
+    return None
+
+
 def get_products_by_category(settings):
     """Helper function to product a list of products organized by their categories."""
     products = read_dir(
@@ -225,9 +275,7 @@ def prepare_order_info(
     if len(ci.keys()) > 0:
         for k, v in ci.items():
             for meta in products_meta:
-                cart_product_id = create_product_id(
-                    meta.product_id, v.options.size, v.options.style
-                )
+                cart_product_id = meta.product_id
 
                 if cart_product_id == k:
                     item = CartOrderInfo(

+ 38 - 12
app/main.py

@@ -1,5 +1,6 @@
 import logging
 import os
+from contextlib import asynccontextmanager
 from pathlib import Path
 from typing import Literal
 from urllib.parse import urljoin, urlparse
@@ -39,6 +40,8 @@ from app.db import (
     save_shipping_info,
     update_product_inventory,
     update_status_order,
+    write_variation_id,
+    get_variation_id,
 )
 from app.email import send_email
 from app.parser import md, read_dir, read_file
@@ -60,7 +63,6 @@ from app.template import (
     prepare_sets_for_menu,
     to_srcset,
     assets_hashing,
-    make_product_info_code,
     make_slugify
 )
 
@@ -88,8 +90,29 @@ file_handler.setFormatter(formatter)
 main_logger.addHandler(stream_handler)
 main_logger.addHandler(file_handler)
 
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """
+    On startup of application we check that all the product variations
+    have their unique IDs
+    """
+
+    products_list = read_dir(
+        settings.git_repo,
+        settings.document_match,
+        settings.document_exclude,
+        settings.block_types,
+        exclude_doc="products",
+        tree="products",
+    )
+
+    for single_product in products_list:
+        write_variation_id(single_product, settings)
 
-app = FastAPI(docs_url=None, redoc_url=None)
+
+    yield
+
+app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
 
 # -- read settings
 settings = read_settings("settings.toml")
@@ -182,7 +205,7 @@ async def root(request: Request):
 @app.get("/products", response_class=RedirectResponse, status_code=301)
 async def products():
     """
-    The products view does not exists, so we return the user to the main view.
+    The products view does not exist, so we return the user to the main view.
     """
 
     return "/"
@@ -218,7 +241,7 @@ async def product(
     # adding anything to the cart yet, just want to see if we could
     # add 1 more item to it.
     quantity_requested_by_user = calculate_quantity_requested_by_user(
-        session_data["cart"]["items"], doc.meta.product_id, size, style, quantity=0
+        session_data["cart"]["items"], doc.meta.product_id, quantity=0,
     )
 
     triplet = check_product_availability(
@@ -295,7 +318,7 @@ async def product(
 @app.get("/pages", response_class=RedirectResponse, status_code=301)
 async def products():
     """
-    The pages view does not exists, so we return the user to the main view.
+    The pages view does not exist, so we return the user to the main view.
     """
 
     return "/"
@@ -422,15 +445,20 @@ async def checkout_update(
         tree,
     )
 
+    product_variation_id = get_variation_id(selected_product, size, style)
+
     form = CartUpdate(
         operation=operation,
         product_id=product_id,
-        options={"size": size, "style": style},
+        options={"size": size,
+                 "style": style,
+                 "product_variation_id": product_variation_id
+                 },
         price=price,
     )    
 
     quantity_requested_by_user = calculate_quantity_requested_by_user(
-        session_data["cart"]["items"], product_id, size, style, quantity
+        session_data["cart"]["items"], product_id, quantity
     )
 
     triplet = check_product_availability(
@@ -445,7 +473,7 @@ async def checkout_update(
     is_product_available, is_product_selectable, inventory_amount = triplet
 
     product_exists = product_exist_in_cart(
-        product_id, size, style, session_data["cart"]["items"]
+        product_id, size, style, product_variation_id, session_data["cart"]["items"]
     )
 
     update_cart(
@@ -764,8 +792,7 @@ async def stripe_webhook(request: Request, background_tasks: BackgroundTasks):
                         update_product_inventory(
                             product_info.filename,
                             product_info.quantity,
-                            product_info.size,
-                            product_info.style,
+                            product_info.product_variation_id,
                             settings,
                         )
 
@@ -945,8 +972,7 @@ async def now_webhook(request: Request, background_tasks: BackgroundTasks):
                             update_product_inventory(
                                 product_info.filename,
                                 product_info.quantity,
-                                product_info.size,
-                                product_info.style,
+                                product_info.product_variation_id,
                                 settings,
                             )
 

+ 0 - 1
app/parser.py

@@ -60,7 +60,6 @@ def set_default_product_meta_fields(block) -> dict[str, str]:
         elif 'category' not in block:
             block['category'] = ''
 
-
         if 'options' in block and len(block['options']) > 0:
             if 'size' not in block['options']:
                 block['options']['size'] = None

+ 2 - 14
app/schema.py

@@ -43,6 +43,7 @@ class CartSessionMeta(BaseModel):
 
 
 class CartSessionItemOptions(BaseModel):
+    product_variation_id: str | None = None
     size: Literal["s", "m", "l", "xl", "xxl"] | None
     style: str | None
 
@@ -220,6 +221,7 @@ class DocumentProductMetaOptions(BaseModel):
 
 
 class DocumentProductMetaInventory(BaseModel):
+    product_variation_id: str | None = None
     size: str | None = None
     style: str | None = None
     amount: int
@@ -339,17 +341,3 @@ class NowPaymentsInvoice(BaseModel):
     order_id: str
     success_url: str | None = None
     cancel_url: str | None = None
-
-
-def create_product_id(
-    product_id: str,
-    size: Literal["s", "m", "l", "xl", "xxl"] | None,
-    style: str | None,
-):
-    """Create the product id for the selected option combination (size,
-    style) of the given product.
-    """
-    if size and style:
-        return f"{product_id}__{size}-{style}"
-    else:
-        return f"{product_id}"

+ 17 - 5
app/serializer.py

@@ -27,10 +27,17 @@ def convert_pydantic_product_to_text(doc: DocumentProduct) -> str:
     # -- options
     options = []
     if doc.meta.options:
-        size = typeset_flat_list("size", doc_dict_meta["options"]["size"])
-        style = typeset_flat_list("style", doc_dict_meta["options"]["style"])
-
         options.append("options:")
+
+        if doc_dict_meta["options"]["size"]:
+            size = typeset_flat_list("size", doc_dict_meta["options"]["size"])
+        else:
+            size = "  size: "
+        if doc_dict_meta["options"]["style"]:
+            style = typeset_flat_list("style", doc_dict_meta["options"]["style"])
+        else:
+            style = "  style: "
+
         options.append(size)
         options.append(style)
 
@@ -42,10 +49,15 @@ def convert_pydantic_product_to_text(doc: DocumentProduct) -> str:
     for item in doc_dict_meta["inventory"]:
         unit = []
 
+        if item["product_variation_id"]:
+            unit.append(f"  - product_variation_id: \"{item['product_variation_id']}\"")
+        else:
+            unit.append("  - product_variation_id:")
+
         if item["size"]:
-            unit.append(f"  - size: \"{item['size']}\"")
+            unit.append(f"    size: \"{item['size']}\"")
         else:
-            unit.append("  - size:")
+            unit.append("    size:")
 
         if item["style"]:
             unit.append(f"    style: \"{item['style']}\"")

+ 5 - 1
app/templates/partials/checkout-item.html

@@ -14,7 +14,11 @@
 	{{ item.title }}
 	{% if item.options.size and item.options.style %}
 	— {{ item.options.style | capitalize }} / Size {{ item.options.size | upper }}
-	{% endif %}
+    {% elif item.options.size %}
+    — {{ item.options.size | upper }}
+    {% elif item.options.style %}
+    — Size {{ item.options.style | capitalize }}
+    {% endif %}
       </h3>
       {% set operation = 'delete' %}
       {% include "partials/checkout-item-form.html" %}