complete functional inventory management app#65
Conversation
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot start |
|
bugbot run |
|
bugbot run |
|
bug bot run |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default mode and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: NaN bypasses frontend price and quantity validation
- Converted price and quantity once and reject non-finite values before saving so NaN cannot pass validation.
Or push these changes by commenting:
@cursor push 137f25f674
Preview (137f25f674)
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
.DS_Store
+.venv
+.env
\ No newline at end of file
diff --git a/backend/python/Product/__init__.py b/backend/python/Product/__init__.py
new file mode 100644
diff --git a/backend/python/Product/admin.py b/backend/python/Product/admin.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/admin.py
@@ -1,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/backend/python/Product/apps.py b/backend/python/Product/apps.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/apps.py
@@ -1,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ProductConfig(AppConfig):
+ name = 'Product'
diff --git a/backend/python/Product/migration.py b/backend/python/Product/migration.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/migration.py
@@ -1,0 +1,95 @@
+"""
+Migration script: Week 3 → Week 4
+
+Problem:
+ Before Week 4, Product.category was a StringField storing a plain text
+ category name (e.g. "Electronics"). After Week 4, it is a ReferenceField
+ pointing to a ProductCategory document.
+
+ Existing products in MongoDB have a string value in their 'category' field.
+ MongoEngine will now expect an ObjectId there instead. Without migration,
+ those products will either error on load or silently drop the category value.
+
+Strategy:
+ 1. For each unique category string found in existing products, create a
+ ProductCategory document with that string as its title.
+ 2. Update every product that had that string to instead reference the new
+ ProductCategory document.
+ 3. Products with no category string (null/empty) are left with category=None.
+
+Run this script ONCE after deploying the Week 4 code, before starting the server:
+ python manage.py shell < Product/migration.py
+
+Or run it as a standalone Django management command if you prefer.
+"""
+
+import django
+import os
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings")
+django.setup()
+
+from datetime import datetime, timezone
+from mongoengine import connect
+from mongoengine.connection import get_db
+
+
+def run_migration():
+ db = get_db()
+ products_collection = db["products"]
+ categories_collection = db["product_categories"]
+
+ now = datetime.now(timezone.utc)
+ existing_strings = products_collection.distinct("category")
+ existing_strings = [c for c in existing_strings if c and isinstance(c, str)]
+
+ print(
+ f"Found {len(existing_strings)} unique category string(s): {existing_strings}"
+ )
+
+ # Step 2: Create a ProductCategory document for each unique string
+ string_to_id = {}
+ for category_string in existing_strings:
+ # Check if a category with this title already exists (idempotent re-runs)
+ existing = categories_collection.find_one({"title": category_string})
+ if existing:
+ string_to_id[category_string] = existing["_id"]
+ print(f" Category '{category_string}' already exists — skipping creation")
+ else:
+ result = categories_collection.insert_one(
+ {
+ "title": category_string,
+ "description": f"Auto-migrated from legacy category string: '{category_string}'",
+ "created_at": now,
+ "updated_at": now,
+ }
+ )
+ string_to_id[category_string] = result.inserted_id
+ print(
+ f" Created category '{category_string}' with id {result.inserted_id}"
+ )
+
+ # Step 3: Update all products to use the ObjectId reference instead of the string
+ total_updated = 0
+ for category_string, category_id in string_to_id.items():
+ result = products_collection.update_many(
+ {"category": category_string}, {"$set": {"category": category_id}}
+ )
+ print(
+ f" Updated {result.modified_count} product(s) from '{category_string}' → ObjectId({category_id})"
+ )
+ total_updated += result.modified_count
+
+ # Step 4: Null out any remaining products with no category or invalid references
+ null_result = products_collection.update_many(
+ {"category": {"$type": "string"}}, # catch any remaining string values
+ {"$set": {"category": None}},
+ )
+ if null_result.modified_count:
+ print(
+ f" Nullified {null_result.modified_count} product(s) with unresolvable category strings"
+ )
+
+ print(f"\nMigration complete. {total_updated} product(s) updated total.")
+
+run_migration()
diff --git a/backend/python/Product/migrations/__init__.py b/backend/python/Product/migrations/__init__.py
new file mode 100644
diff --git a/backend/python/Product/models.py b/backend/python/Product/models.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/models.py
@@ -1,0 +1,115 @@
+from mongoengine import (
+ Document,
+ StringField,
+ FloatField,
+ IntField,
+ DateTimeField,
+ ReferenceField,
+ LazyReferenceField,
+)
+
+"""
+ Domain models for the inventory system.
+
+ Week 4 changes:
+ - ProductCategory is a new first-class entity with its own collection.
+ - Product.category is now a ReferenceField to ProductCategory instead
+ of a plain StringField. This creates a real relationship between the
+ two documents in MongoDB, similar to a foreign key in SQL.
+ - Migration note: existing products with a string category field will
+ have a null category reference after this change. The migration script
+ (migration.py) handles backfilling these to a default category.
+"""
+
+
+class ProductCategory(Document):
+ """
+ Represents a product category in the inventory system.
+
+ Examples: "Food", "Electronics", "Kitchen Essentials", "Toys"
+
+ Stored in the 'product_categories' collection. Products reference
+ this document via a ReferenceField, so changing a category's title
+ is automatically reflected everywhere without updating Product docs.
+ """
+
+ title = StringField(required=True, max_length=255)
+ description = StringField(required=True)
+ created_at = DateTimeField(required=True)
+ updated_at = DateTimeField(required=True)
+
+ meta = {"collection": "product_categories"}
+
+ def __str__(self):
+ return self.title
+
+ def to_dict(self):
+ return {
+ "id": str(self.id),
+ "title": self.title,
+ "description": self.description,
+ "created_at": self.created_at.isoformat(),
+ "updated_at": self.updated_at.isoformat(),
+ }
+
+
+class Product(Document):
+ """
+ Represents a product in the warehouse inventory.
+
+ Week 4: category is now a ReferenceField to ProductCategory.
+ MongoEngine stores the ObjectId of the category document in the
+ 'category' field of each product document in MongoDB. When accessed,
+ MongoEngine automatically fetches the referenced category document.
+ """
+
+ name = StringField(required=True, max_length=255)
+ description = StringField(required=True)
+ # ReferenceField stores the ObjectId of the related ProductCategory.
+ # reverse_delete_rule=mongoengine.NULLIFY would nullify this field
+ # if the category is deleted — we handle this in the service layer instead
+ # to give more control and clearer error messages.
+ category = ReferenceField(ProductCategory, required=False)
+ price = FloatField(required=True)
+ brand = StringField(required=True)
+ quantity = IntField(required=True)
+ created_at = DateTimeField(required=True)
+ updated_at = DateTimeField(required=True)
+
+ meta = {"collection": "products"}
+
+ def __str__(self):
+ return f"{self.name} ({self.brand})"
+
+ def to_dict(self):
+ """
+ Serializes the Product to a plain dict.
+
+ The category field is either serialized as a nested dict (if the
+ referenced document is loaded) or as just its ID string (if not loaded).
+ This handles both the case where category is populated and where it is None
+ (for products created before Week 4 that have no category assigned).
+ """
+ category_data = None
+ if self.category:
+ try:
+ # Access the referenced document — triggers a DB fetch if not cached
+ category_data = {
+ "id": str(self.category.id),
+ "title": self.category.title,
+ }
+ except Exception:
+ # Category reference exists but document was deleted
+ category_data = {"id": str(self.category.id), "title": None}
+
+ return {
+ "id": str(self.id),
+ "name": self.name,
+ "description": self.description,
+ "category": category_data,
+ "price": self.price,
+ "brand": self.brand,
+ "quantity": self.quantity,
+ "created_at": self.created_at.isoformat(),
+ "updated_at": self.updated_at.isoformat(),
+ }
diff --git a/backend/python/Product/repositories.py b/backend/python/Product/repositories.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/repositories.py
@@ -1,0 +1,149 @@
+from .models import Product, ProductCategory
+from datetime import datetime, timezone
+from mongoengine.errors import InvalidQueryError, ValidationError as MongoValidationError
+
+"""
+ Repository layer — the only layer that directly interacts with MongoDB.
+"""
+
+
+class ProductCategoryRepository:
+ def create(self, data: dict) -> ProductCategory:
+ now = datetime.now(timezone.utc)
+ category = ProductCategory(
+ title=data["title"],
+ description=data["description"],
+ created_at=now,
+ updated_at=now,
+ )
+ category.save()
+ return category
+
+ def get_by_id(self, category_id: str) -> ProductCategory | None:
+ try:
+ return ProductCategory.objects.get(id=category_id)
+ except ProductCategory.DoesNotExist:
+ return None
+ except (InvalidQueryError, MongoValidationError, Exception):
+ return None
+
+ def get_all(self) -> list:
+ return list(ProductCategory.objects.all())
+
+ def count(self) -> int:
+ # Categories have no filters — plain count
+ return ProductCategory.objects.count()
+
+ def get_paginated(self, skip: int, limit: int) -> list:
+ return list(ProductCategory.objects.skip(skip).limit(limit))
+
+ def update(self, category: ProductCategory, data: dict) -> ProductCategory:
+ if "title" in data:
+ category.title = data["title"]
+ if "description" in data:
+ category.description = data["description"]
+ category.updated_at = datetime.now(timezone.utc)
+ category.save()
+ return category
+
+ def delete(self, category: ProductCategory) -> None:
+ category.delete()
+
+
+class ProductRepository:
+ def create(self, data: dict, category: ProductCategory | None = None) -> Product:
+ now = datetime.now(timezone.utc)
+ product = Product(
+ name=data["name"],
+ description=data["description"],
+ category=category,
+ price=float(data["price"]),
+ brand=data["brand"],
+ quantity=int(data["quantity"]),
+ created_at=now,
+ updated_at=now,
+ )
+ product.save()
+ return product
+
+ def get_by_id(self, product_id: str) -> Product | None:
+ try:
+ return Product.objects.get(id=product_id)
+ except Product.DoesNotExist:
+ return None
+ except (InvalidQueryError, MongoValidationError, Exception):
+ return None
+
+ def count(self, filters: dict = None) -> int:
+ """
+ Returns product count, optionally with DB-side filters applied.
+ filters is a dict of MongoEngine query kwargs e.g. {"brand": "Apple", "price__gte": 100}
+ """
+ qs = Product.objects
+ if filters:
+ qs = qs.filter(**filters)
+ return qs.count()
+
+ def get_paginated(self, skip: int, limit: int, filters: dict = None) -> list:
+ """
+ Fetches a page of products using DB-side skip/limit with optional filters.
+ Both count() and get_paginated() must always receive the same filters
+ so pagination totals match the actual results returned.
+ """
+ qs = Product.objects
+ if filters:
+ qs = qs.filter(**filters)
+ return list(qs.skip(skip).limit(limit))
+
+ def get_by_category(self, category: ProductCategory) -> list:
+ return list(Product.objects.filter(category=category))
+
+ def count_by_category(self, category: ProductCategory) -> int:
+ return Product.objects.filter(category=category).count()
+
+ def update(self, product: Product, data: dict, category=False) -> Product:
+ for field in ["name", "description", "brand"]:
+ if field in data:
+ setattr(product, field, data[field])
+ if "price" in data:
+ product.price = float(data["price"])
+ if "quantity" in data:
+ product.quantity = int(data["quantity"])
+ if category is not False:
+ product.category = category
+ product.updated_at = datetime.now(timezone.utc)
+ product.save()
+ return product
+
+ def set_category(self, product: Product, category: ProductCategory) -> Product:
+ product.category = category
+ product.updated_at = datetime.now(timezone.utc)
+ product.save()
+ return product
+
+ def remove_category(self, product: Product) -> Product:
+ product.category = None
+ product.updated_at = datetime.now(timezone.utc)
+ product.save()
+ return product
+
+ def bulk_create(self, products: list) -> list:
+ now = datetime.now(timezone.utc)
+ product_objects = [
+ Product(
+ name=p["name"],
+ description=p["description"],
+ category=p.get("category"),
+ price=float(p["price"]),
+ brand=p["brand"],
+ quantity=int(p["quantity"]),
+ created_at=now,
+ updated_at=now,
+ )
+ for p in products
+ ]
+ Product.objects.insert(product_objects, load_bulk=False)
+ return product_objects
+
+ def delete(self, product: Product) -> None:
+ product.delete()
\ No newline at end of file
diff --git a/backend/python/Product/services.py b/backend/python/Product/services.py
new file mode 100644
--- /dev/null
+++ b/backend/python/Product/services.py
@@ -1,0 +1,398 @@
+import csv
+import io
+from .repositories import ProductRepository, ProductCategoryRepository
+from .validators import validate_product_data, validate_category_data, validate_csv_row
+
+"""
+ Service layer — owns all business logic for the Product and ProductCategory domains.
+"""
+
+
+class ProductCategoryService:
+ def __init__(
+ self,
+ category_repository: ProductCategoryRepository = None,
+ product_repository: ProductRepository = None,
+ ):
+ self.category_repository = category_repository or ProductCategoryRepository()
+ self.product_repository = product_repository or ProductRepository()
+
+ def create_category(self, data: dict) -> dict:
+ errors = validate_category_data(data, require_all_fields=True)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+ category = self.category_repository.create(data)
+ return {"category": category.to_dict()}
+
+ def get_category(self, category_id: str) -> dict:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+ return {"category": category.to_dict()}
+
+ def get_all_categories(self, page: int, page_size: int) -> dict:
+ total = self.category_repository.count()
+ total_pages = max(1, (total + page_size - 1) // page_size)
+
+ if page > total_pages and total > 0:
+ return {"error": f"Page {page} does not exist. Only {total_pages} page(s) available."}
+
+ skip = (page - 1) * page_size
+ categories = self.category_repository.get_paginated(skip=skip, limit=page_size)
+
+ return {
+ "page": page,
+ "page_size": page_size,
+ "total_categories": total,
+ "total_pages": total_pages,
+ "categories": [c.to_dict() for c in categories],
+ }
+
+ def full_update_category(self, category_id: str, data: dict) -> dict:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+ errors = validate_category_data(data, require_all_fields=True)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+ updated = self.category_repository.update(category, data)
+ return {"category": updated.to_dict()}
+
+ def partial_update_category(self, category_id: str, data: dict) -> dict:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+ if not data:
+ return {"error": "Provide at least one field to update."}
+ errors = validate_category_data(data, require_all_fields=False)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+ updated = self.category_repository.update(category, data)
+ return {"category": updated.to_dict()}
+
+ def delete_category(self, category_id: str) -> dict:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+
+ product_count = self.product_repository.count_by_category(category)
+ if product_count > 0:
+ return {
+ "error": f"Cannot delete category '{category.title}' — "
+ f"{product_count} product(s) are still assigned to it. "
+ f"Reassign or remove those products first."
+ }
+
+ self.category_repository.delete(category)
+ return {}
+
+ def get_products_in_category(self, category_id: str) -> dict:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+ products = self.product_repository.get_by_category(category)
+ return {
+ "category": category.to_dict(),
+ "total_products": len(products),
+ "products": [p.to_dict() for p in products],
+ }
+
+
+class ProductService:
+ def __init__(
+ self,
+ repository: ProductRepository = None,
+ category_repository: ProductCategoryRepository = None,
+ ):
+ self.repository = repository or ProductRepository()
+ self.category_repository = category_repository or ProductCategoryRepository()
+
+ def _resolve_category(self, category_id: str | None):
+ """
+ Resolves a category_id string to a ProductCategory object.
+ Returns:
+ - ProductCategory if found
+ - None if category_id is None
+ - dict with "error" key if not found
+ """
+ if not category_id:
+ return None
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": f"Category with id '{category_id}' not found"}
+ return category
+
+ def _build_filters(self, params: dict) -> dict | str:
+ """
+ Translates raw query parameters into MongoEngine filter kwargs.
+
+ Supported filters:
+ category_ids — comma-separated ObjectId strings e.g. ?category_ids=id1,id2
+ min_price — inclusive minimum price e.g. ?min_price=100
+ max_price — inclusive maximum price e.g. ?max_price=500
+ min_quantity — inclusive minimum quantity e.g. ?min_quantity=10
+ max_quantity — inclusive maximum quantity e.g. ?max_quantity=100
+ brand — exact brand match e.g. ?brand=Apple
+ search — case-insensitive name contains e.g. ?search=iphone
+
+ Returns a MongoEngine filter dict or an error string if validation fails.
+ Filter building lives here in the Service because resolving category IDs
+ and validating ranges is business logic, not HTTP parsing.
+ """
+ filters = {}
+
+ # --- category_ids ---
+ category_ids_raw = params.get("category_ids", "").strip()
+ if category_ids_raw:
+ category_id_list = [c.strip() for c in category_ids_raw.split(",") if c.strip()]
+ resolved = []
+ for cat_id in category_id_list:
+ category = self.category_repository.get_by_id(cat_id)
+ if not category:
+ return f"Category with id '{cat_id}' not found"
+ resolved.append(category)
+ if resolved:
+ filters["category__in"] = resolved
+
+ # --- price range ---
+ if params.get("min_price"):
+ try:
+ min_price = float(params["min_price"])
+ if min_price < 0:
+ return "min_price must be a non-negative number"
+ filters["price__gte"] = min_price
+ except ValueError:
+ return "min_price must be a valid number"
+
+ if params.get("max_price"):
+ try:
+ max_price = float(params["max_price"])
+ if max_price < 0:
+ return "max_price must be a non-negative number"
+ filters["price__lte"] = max_price
+ except ValueError:
+ return "max_price must be a valid number"
+
+ if "price__gte" in filters and "price__lte" in filters:
+ if filters["price__gte"] > filters["price__lte"]:
+ return "min_price cannot be greater than max_price"
+
+ # --- quantity range ---
+ if params.get("min_quantity"):
+ try:
+ min_qty = int(params["min_quantity"])
+ if min_qty < 0:
+ return "min_quantity must be a non-negative integer"
+ filters["quantity__gte"] = min_qty
+ except ValueError:
+ return "min_quantity must be a valid integer"
+
+ if params.get("max_quantity"):
+ try:
+ max_qty = int(params["max_quantity"])
+ if max_qty < 0:
+ return "max_quantity must be a non-negative integer"
+ filters["quantity__lte"] = max_qty
+ except ValueError:
+ return "max_quantity must be a valid integer"
+
+ if "quantity__gte" in filters and "quantity__lte" in filters:
+ if filters["quantity__gte"] > filters["quantity__lte"]:
+ return "min_quantity cannot be greater than max_quantity"
+
+ # --- brand ---
+ brand = params.get("brand", "").strip()
+ if brand:
+ filters["brand"] = brand
+
+ # --- name search ---
+ search = params.get("search", "").strip()
+ if search:
+ filters["name__icontains"] = search
+
+ return filters
+
+ def create_product(self, data: dict) -> dict:
+ errors = validate_product_data(data, require_all_fields=True)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+
+ category_id = data.get("category_id")
+ category = self._resolve_category(category_id)
+ if isinstance(category, dict):
+ return category
+
+ product = self.repository.create(data, category=category)
+ return {"product": product.to_dict()}
+
+ def get_product(self, product_id: str) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+ return {"product": product.to_dict()}
+
+ def get_all_products(self, page: int, page_size: int, filter_params: dict = None) -> dict:
+ """
+ Fetches a paginated, optionally filtered page of products.
+
+ filter_params is a dict of raw query string values from the request.
+ The Service translates these into MongoEngine filter kwargs via
+ _build_filters() and passes them to the Repository for DB-side filtering.
+ """
+ filters = {}
+ if filter_params:
+ filters = self._build_filters(filter_params)
+ if isinstance(filters, str):
+ # _build_filters returned an error message
+ return {"error": filters}
+
+ total_products = self.repository.count(filters=filters or None)
+ total_pages = max(1, (total_products + page_size - 1) // page_size)
+
+ if page > total_pages and total_products > 0:
+ return {"error": f"Page {page} does not exist. Only {total_pages} page(s) available."}
+
+ skip = (page - 1) * page_size
+ products = self.repository.get_paginated(skip=skip, limit=page_size, filters=filters or None)
+
+ return {
+ "page": page,
+ "page_size": page_size,
+ "total_products": total_products,
+ "total_pages": total_pages,
+ "filters_applied": {
+ k: [str(c.id) for c in v] if k == "category__in" else v
+ for k, v in filters.items()
+ } if filter_params and filters else {},
+ "products": [p.to_dict() for p in products],
+ }
+
+ def full_update_product(self, product_id: str, data: dict) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+
+ errors = validate_product_data(data, require_all_fields=True)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+
+ category_id = data.get("category_id")
+ category = self._resolve_category(category_id)
+ if isinstance(category, dict):
+ return category
+
+ updated = self.repository.update(product, data, category=category)
+ return {"product": updated.to_dict()}
+
+ def partial_update_product(self, product_id: str, data: dict) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+
+ if not data:
+ return {"error": "Provide at least one field to update."}
+
+ errors = validate_product_data(data, require_all_fields=False)
+ if errors:
+ return {"error": "Validation failed", "details": errors}
+
+ if "category_id" in data:
+ category = self._resolve_category(data["category_id"])
+ if isinstance(category, dict):
+ return category
+ else:
+ category = False # sentinel: do not touch category
+
+ updated = self.repository.update(product, data, category=category)
+ return {"product": updated.to_dict()}
+
+ def delete_product(self, product_id: str) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+ self.repository.delete(product)
+ return {}
+
+ def add_product_to_category(self, product_id: str, category_id: str) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ return {"error": "Category not found"}
+ updated = self.repository.set_category(product, category)
+ return {"product": updated.to_dict()}
+
+ def remove_product_from_category(self, product_id: str) -> dict:
+ product = self.repository.get_by_id(product_id)
+ if not product:
+ return {"error": "Product not found"}
+ if not product.category:
+ return {"error": "Product is not assigned to any category"}
+ updated = self.repository.remove_category(product)
+ return {"product": updated.to_dict()}
+
+ def bulk_create_from_csv(self, csv_content: str) -> dict:
+ try:
+ reader = csv.DictReader(io.StringIO(csv_content))
+ except Exception:
+ return {"error": "Failed to parse CSV content"}
+
+ required_columns = {"name", "description", "price", "brand", "quantity"}
+ rows = []
+ all_errors = []
+
+ for row_number, row in enumerate(reader, start=1):
+ if row_number == 1 and not required_columns.issubset(set(row.keys())):
+ missing = required_columns - set(row.keys())
+ return {
+ "error": "CSV is missing required columns",
+ "missing_columns": list(missing),
+ "required_columns": list(required_columns),
+ }
+
+ parsed_row = dict(row)
+ try:
+ parsed_row["price"] = float(parsed_row["price"])
+ except (ValueError, TypeError):
+ pass
+ try:
+ parsed_row["quantity"] = int(parsed_row["quantity"])
+ except (ValueError, TypeError):
+ pass
+
+ category_id = parsed_row.get("category_id", "").strip() or None
+ parsed_row["category_id"] = category_id
+
+ validation = validate_csv_row(parsed_row, row_number)
+ if validation["errors"]:
+ all_errors.append(validation)
+ else:
+ category = None
+ if category_id:
+ category = self.category_repository.get_by_id(category_id)
+ if not category:
+ all_errors.append({
+ "row": row_number,
+ "errors": {"category_id": f"Category '{category_id}' not found"},
+ })
+ continue
+ parsed_row["category"] = category
+ rows.append(parsed_row)
+
... diff truncated: showing 800 of 7074 linesYou can send follow-ups to the cloud agent here.
| if (!form.description.trim()) return setValidationError("Description is required"); // ← added | ||
| if (!form.brand.trim()) return setValidationError("Brand is required"); | ||
| if (Number(form.price) <= 0) return setValidationError("Price must be greater than 0"); | ||
| if (Number(form.quantity) <= 0) return setValidationError("Quantity must be greater than 0"); |
There was a problem hiding this comment.
NaN bypasses frontend price and quantity validation
Low Severity
The handleSubmit validation checks Number(form.price) <= 0 and Number(form.quantity) <= 0, but non-numeric strings like "abc" produce NaN, and NaN <= 0 is always false in JavaScript. This allows invalid non-numeric input to pass client-side validation and be sent to the backend as null (since JSON.stringify converts NaN to null). The backend catches this, but the user gets a less helpful server-side error instead of immediate client-side feedback.
Reviewed by Cursor Bugbot for commit 25dff91. Configure here.
|
bugbot run |
|
bugbot run |



What this PR does
Backend
repositories.py → services.py → views.py) to separate:Product.categoryfrom a plain string to a ReferenceField pointing toProductCategory.GET /products/, including:PUT /products/<id>/category/POST /products/bulk/validators.pyfor reusable field-level validation and consistent JSON error responses..env, including:Frontend
client.ts) so all backend API calls are centralized.types/index.ts) for:ProductCategoryProductFiltersProductPayloadHeaderFilterBarPaginationProductListProduct(detail view)ProductForm(create/edit modal)SettingsPanelWelcomeScreenCategoriesPage//categories/reportsTests
Added comprehensive test coverage including:
Unit Tests
Integration Tests
Note
Medium Risk
Introduces new MongoDB persistence, environment-driven Django settings (including CORS), and new API surface area for products/categories/CSV import, plus a large frontend UI build-out; issues here could break core CRUD flows or deployments if env vars are misconfigured.
Overview
Delivers a full inventory management stack by adding a MongoEngine-backed Django API for
productsandcategories, structured with a Controller–Service–Repository split, including pagination, rich filtering onGET /products/, category membership endpoints, and CSV bulk import.Updates data modeling so
Product.categorybecomes aReferenceFieldto a newProductCategorydocument and adds reusable validation + extensive unit/integration tests, while switching Django config to.env-driven settings (MongoDB connection, dedicated test DB, and CORS).Builds out the React + TypeScript frontend with a typed API client, routing, filtering/pagination UI, and user settings (theme/density/animations), and adds Playwright E2E tests + GitHub Actions workflow; also updates repo docs and ignores
.env/.venv.Reviewed by Cursor Bugbot for commit fa064c7. Configure here.