From 1736a3aa083cad6a73d4ea4b9269e8de8710be0a Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Tue, 3 Mar 2026 18:04:21 +0530 Subject: [PATCH 1/9] feat: implement hello API endpoint --- backend/python/django_app/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index 0418448be..afec1d9ea 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,9 +1,12 @@ from django.contrib import admin from django.urls import path from django.http import HttpResponse +from django.http import JsonResponse def hello_world(request): - return HttpResponse("Hello, world! This is our interneers-lab Django server.") + # Get 'name' from the query string, default to 'World' if missing + name = request.GET.get("name", "World") + return JsonResponse({"message": f"Hello, {name}!"}) urlpatterns = [ path('admin/', admin.site.urls), From f333f8030f0d54732926153d361bf6948dc6a088 Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Tue, 3 Mar 2026 19:42:28 +0530 Subject: [PATCH 2/9] mongoDB connection made and tested --- backend/python/django_app/settings.py | 16 ++++++++++++++++ backend/python/django_app/urls.py | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/backend/python/django_app/settings.py b/backend/python/django_app/settings.py index 2d7ea95db..eef039af4 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -11,6 +11,9 @@ """ from pathlib import Path +from dotenv import load_dotenv +import os +from pymongo import MongoClient # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -70,6 +73,19 @@ WSGI_APPLICATION = "django_app.wsgi.application" +# MongoDB configuration + +load_dotenv() + +MONGO_USER = os.getenv("MONGO_USER", "root") +MONGO_PASS = os.getenv("MONGO_PASS", "example") +MONGO_PORT = os.getenv("MONGO_PORT", "27019") +MONGO_HOST = os.getenv("MONGO_HOST", "localhost") + +CLIENT = MongoClient( + f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/?authSource=admin" +) + # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index afec1d9ea..ddb79f582 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -2,6 +2,16 @@ from django.urls import path from django.http import HttpResponse from django.http import JsonResponse +from django.conf import settings + +def test_mongo(request): + try: + db = settings.CLIENT["test_db"] + db.command("ping") + return JsonResponse({"status": "MongoDB Connected"}) + except Exception as e: + return JsonResponse({"errorrrrr": str(e)}) + def hello_world(request): # Get 'name' from the query string, default to 'World' if missing @@ -11,4 +21,5 @@ def hello_world(request): urlpatterns = [ path('admin/', admin.site.urls), path('hello/', hello_world), + path('test-mongo/', test_mongo), ] From 2953875e285044473948b2ed24721c621a6ab7db Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Sun, 12 Apr 2026 12:03:55 +0530 Subject: [PATCH 3/9] Add hello-world API with hexagonal architecture --- .../django_app/adapters/http/hello_handler.py | 9 +++++ .../django_app/core/services/hello_service.py | 6 ++++ backend/python/django_app/urls.py | 36 ++++++++++--------- 3 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 backend/python/django_app/adapters/http/hello_handler.py create mode 100644 backend/python/django_app/core/services/hello_service.py diff --git a/backend/python/django_app/adapters/http/hello_handler.py b/backend/python/django_app/adapters/http/hello_handler.py new file mode 100644 index 000000000..f0cd84704 --- /dev/null +++ b/backend/python/django_app/adapters/http/hello_handler.py @@ -0,0 +1,9 @@ +from django.http import JsonResponse +from django_app.core.services.hello_service import HelloService + +service = HelloService() + +def hello_world(request): + name = request.GET.get('name') + message = service.greet(name) + return JsonResponse({"message": message}) \ No newline at end of file diff --git a/backend/python/django_app/core/services/hello_service.py b/backend/python/django_app/core/services/hello_service.py new file mode 100644 index 000000000..befeece4b --- /dev/null +++ b/backend/python/django_app/core/services/hello_service.py @@ -0,0 +1,6 @@ + +class HelloService: + def greet(self, name: str = None) -> str: + if name: + return f"Hello, {name}" + return "Hello World" \ No newline at end of file diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index ddb79f582..b23ff9f83 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,25 +1,29 @@ from django.contrib import admin from django.urls import path -from django.http import HttpResponse -from django.http import JsonResponse -from django.conf import settings +# from adapters.http.hello_handler import hello_world +# from django.http import HttpResponse +# from django.http import JsonResponse +# from django.conf import settings -def test_mongo(request): - try: - db = settings.CLIENT["test_db"] - db.command("ping") - return JsonResponse({"status": "MongoDB Connected"}) - except Exception as e: - return JsonResponse({"errorrrrr": str(e)}) +from django_app.adapters.http.hello_handler import hello_world +# def test_mongo(request): +# try: +# db = settings.CLIENT["test_db"] +# db.command("ping") +# return JsonResponse({"status": "MongoDB Connected"}) +# except Exception as e: +# return JsonResponse({"errorrrrr": str(e)}) -def hello_world(request): - # Get 'name' from the query string, default to 'World' if missing - name = request.GET.get("name", "World") - return JsonResponse({"message": f"Hello, {name}!"}) + +# def hello_world(request): +# # Get 'name' from the query string, default to 'World' if missing +# name = request.GET.get("name", "World") +# return JsonResponse({"message": f"Hello, {name}!"}) urlpatterns = [ path('admin/', admin.site.urls), - path('hello/', hello_world), - path('test-mongo/', test_mongo), + # path('hello/', hello_world), + # path('test-mongo/', test_mongo), + path('hello-world/', hello_world), ] From 73ac7291b7357d83a361e729f8e4b579c0fd8ee6 Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Sun, 12 Apr 2026 12:35:33 +0530 Subject: [PATCH 4/9] feat: add Product CRUD APIs with in-memory storage - Defined Product dataclass model in core/models - Implemented InMemoryProductRepository with save, find, list, delete - Added ProductService with create, read, update, delete and input validation - Added product_handler HTTP adapter with correct HTTP status codes - Wired up URL routes for /products/ and /products// - Tested all endpoints via Postman including validation failure cases --- .../adapters/http/product_handler.py | 48 +++++++++++++++++ .../in_memory_product_repository.py | 22 ++++++++ .../python/django_app/core/models/product.py | 13 +++++ .../core/services/product_service.py | 51 +++++++++++++++++++ backend/python/django_app/urls.py | 22 ++------ 5 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 backend/python/django_app/adapters/http/product_handler.py create mode 100644 backend/python/django_app/adapters/repositories/in_memory_product_repository.py create mode 100644 backend/python/django_app/core/models/product.py create mode 100644 backend/python/django_app/core/services/product_service.py diff --git a/backend/python/django_app/adapters/http/product_handler.py b/backend/python/django_app/adapters/http/product_handler.py new file mode 100644 index 000000000..6425e2881 --- /dev/null +++ b/backend/python/django_app/adapters/http/product_handler.py @@ -0,0 +1,48 @@ +import json +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django_app.core.services.product_service import ProductService +from django_app.adapters.repositories.in_memory_product_repository import InMemoryProductRepository +from dataclasses import asdict + +# Wire up once (in real apps this is done via dependency injection) +repository = InMemoryProductRepository() +service = ProductService(repository) + +@csrf_exempt +def products(request): + if request.method == 'GET': + all_products = service.get_all_products() + return JsonResponse([asdict(p) for p in all_products], safe=False, status=200) + + elif request.method == 'POST': + try: + data = json.loads(request.body) + product = service.create_product(data) + return JsonResponse(asdict(product), status=201) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) + +@csrf_exempt +def product_detail(request, product_id): + if request.method == 'GET': + product = service.get_product(product_id) + if not product: + return JsonResponse({'error': 'Product not found'}, status=404) + return JsonResponse(asdict(product), status=200) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + product = service.update_product(product_id, data) + if not product: + return JsonResponse({'error': 'Product not found'}, status=404) + return JsonResponse(asdict(product), status=200) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) + + elif request.method == 'DELETE': + deleted = service.delete_product(product_id) + if not deleted: + return JsonResponse({'error': 'Product not found'}, status=404) + return JsonResponse({}, status=204) \ No newline at end of file diff --git a/backend/python/django_app/adapters/repositories/in_memory_product_repository.py b/backend/python/django_app/adapters/repositories/in_memory_product_repository.py new file mode 100644 index 000000000..793cddc69 --- /dev/null +++ b/backend/python/django_app/adapters/repositories/in_memory_product_repository.py @@ -0,0 +1,22 @@ +from django_app.core.models.product import Product +from typing import Optional + +class InMemoryProductRepository: + def __init__(self): + self._store = {} # our "database" + + def save(self, product: Product) -> Product: + self._store[product.id] = product + return product + + def find_by_id(self, product_id: str) -> Optional[Product]: + return self._store.get(product_id) + + def find_all(self) -> list[Product]: + return list(self._store.values()) + + def delete(self, product_id: str) -> bool: + if product_id in self._store: + del self._store[product_id] + return True + return False \ No newline at end of file diff --git a/backend/python/django_app/core/models/product.py b/backend/python/django_app/core/models/product.py new file mode 100644 index 000000000..fe4972fe2 --- /dev/null +++ b/backend/python/django_app/core/models/product.py @@ -0,0 +1,13 @@ +import uuid +from dataclasses import dataclass, field +from typing import Optional + +@dataclass +class Product: + name: str + description: str + category: str + price: float + brand: str + quantity: int + id: str = field(default_factory=lambda: str(uuid.uuid4())) \ No newline at end of file diff --git a/backend/python/django_app/core/services/product_service.py b/backend/python/django_app/core/services/product_service.py new file mode 100644 index 000000000..4296a5164 --- /dev/null +++ b/backend/python/django_app/core/services/product_service.py @@ -0,0 +1,51 @@ +from django_app.core.models.product import Product +from typing import Optional + +class ProductService: + def __init__(self, repository): + self.repository = repository # injected — could be in-memory or mongo + + def create_product(self, data: dict) -> Product: + self._validate(data) + product = Product( + name=data['name'], + description=data.get('description', ''), + category=data.get('category', ''), + price=data['price'], + brand=data.get('brand', ''), + quantity=data['quantity'], + ) + return self.repository.save(product) + + def get_product(self, product_id: str) -> Optional[Product]: + return self.repository.find_by_id(product_id) + + def get_all_products(self) -> list[Product]: + return self.repository.find_all() + + def update_product(self, product_id: str, data: dict) -> Optional[Product]: + product = self.repository.find_by_id(product_id) + if not product: + return None + self._validate(data) + product.name = data.get('name', product.name) + product.price = data.get('price', product.price) + product.quantity = data.get('quantity', product.quantity) + product.description = data.get('description', product.description) + product.category = data.get('category', product.category) + product.brand = data.get('brand', product.brand) + return self.repository.save(product) + + def delete_product(self, product_id: str) -> bool: + return self.repository.delete(product_id) + + def _validate(self, data: dict): + errors = {} + if not data.get('name'): + errors['name'] = 'Name is required' + if data.get('price') is not None and data['price'] <= 0: + errors['price'] = 'Price must be greater than 0' + if data.get('quantity') is not None and data['quantity'] < 0: + errors['quantity'] = 'Quantity cannot be negative' + if errors: + raise ValueError(errors) \ No newline at end of file diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index b23ff9f83..70f6755e8 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,29 +1,13 @@ from django.contrib import admin from django.urls import path -# from adapters.http.hello_handler import hello_world -# from django.http import HttpResponse -# from django.http import JsonResponse -# from django.conf import settings - from django_app.adapters.http.hello_handler import hello_world - -# def test_mongo(request): -# try: -# db = settings.CLIENT["test_db"] -# db.command("ping") -# return JsonResponse({"status": "MongoDB Connected"}) -# except Exception as e: -# return JsonResponse({"errorrrrr": str(e)}) - - -# def hello_world(request): -# # Get 'name' from the query string, default to 'World' if missing -# name = request.GET.get("name", "World") -# return JsonResponse({"message": f"Hello, {name}!"}) +from django_app.adapters.http.product_handler import products, product_detail urlpatterns = [ path('admin/', admin.site.urls), # path('hello/', hello_world), # path('test-mongo/', test_mongo), path('hello-world/', hello_world), + path('products/', products), + path('products//', product_detail), ] From 98bd774d3a06e8a887a4cc3df3fc4fff1fde31b6 Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Mon, 13 Apr 2026 01:18:50 +0530 Subject: [PATCH 5/9] feat: migrate product storage from in-memory to MongoDB using MongoEngine - Added docker-compose.yml to run MongoDB in a container - Defined ProductDocument MongoEngine model in adapters/repositories - Implemented MongoProductRepository with domain model mapper - Connected Django settings to MongoDB via mongoengine.connect - Swapped InMemoryProductRepository for MongoProductRepository in handler - Verified data persistence across server restarts via Compass --- .../adapters/http/product_handler.py | 4 +- .../mongo_models/product_document.py | 13 ++++ .../repositories/mongo_product_repository.py | 68 +++++++++++++++++++ .../python/django_app/core/models/product.py | 4 +- backend/python/django_app/settings.py | 17 +++-- backend/python/docker-compose.yaml | 1 + backend/python/requirements.txt | 6 ++ 7 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 backend/python/django_app/adapters/repositories/mongo_models/product_document.py create mode 100644 backend/python/django_app/adapters/repositories/mongo_product_repository.py diff --git a/backend/python/django_app/adapters/http/product_handler.py b/backend/python/django_app/adapters/http/product_handler.py index 6425e2881..7d9ab91a2 100644 --- a/backend/python/django_app/adapters/http/product_handler.py +++ b/backend/python/django_app/adapters/http/product_handler.py @@ -1,12 +1,14 @@ import json from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt +from django_app.adapters.repositories.mongo_product_repository import MongoProductRepository from django_app.core.services.product_service import ProductService from django_app.adapters.repositories.in_memory_product_repository import InMemoryProductRepository from dataclasses import asdict # Wire up once (in real apps this is done via dependency injection) -repository = InMemoryProductRepository() +# repository = InMemoryProductRepository() +repository = MongoProductRepository() service = ProductService(repository) @csrf_exempt diff --git a/backend/python/django_app/adapters/repositories/mongo_models/product_document.py b/backend/python/django_app/adapters/repositories/mongo_models/product_document.py new file mode 100644 index 000000000..43b6a3ab4 --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_models/product_document.py @@ -0,0 +1,13 @@ +from mongoengine import Document, StringField, FloatField, IntField + +class ProductDocument(Document): + name = StringField(required=True, max_length=200) + description = StringField(max_length=1000) + category = StringField(max_length=100) + price = FloatField(required=True, min_value=0) + brand = StringField(max_length=100) + quantity = IntField(required=True, min_value=0) + + meta = { + 'collection': 'products' + } \ No newline at end of file diff --git a/backend/python/django_app/adapters/repositories/mongo_product_repository.py b/backend/python/django_app/adapters/repositories/mongo_product_repository.py new file mode 100644 index 000000000..2115d6036 --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_product_repository.py @@ -0,0 +1,68 @@ +from django_app.core.models.product import Product +from django_app.adapters.repositories.mongo_models.product_document import ProductDocument +from typing import Optional +from bson import ObjectId +from bson.errors import InvalidId + +class MongoProductRepository: + + def _to_domain(self, doc: ProductDocument) -> Product: + """Convert a DB document → core domain model""" + return Product( + id=str(doc.id), + name=doc.name, + description=doc.description or '', + category=doc.category or '', + price=doc.price, + brand=doc.brand or '', + quantity=doc.quantity, + ) + + def _to_object_id(self, product_id: str): + """Safely convert string → ObjectId, return None if invalid""" + try: + return ObjectId(product_id) + except (InvalidId, TypeError): + return None + + def save(self, product: Product) -> Product: + # Check if updating existing or creating new + if product.id: + doc = ProductDocument.objects(id=product.id).first() + if doc: + doc.name = product.name + doc.description = product.description + doc.category = product.category + doc.price = product.price + doc.brand = product.brand + doc.quantity = product.quantity + doc.save() + return self._to_domain(doc) + + # Create new + doc = ProductDocument( + name=product.name, + description=product.description, + category=product.category, + price=product.price, + brand=product.brand, + quantity=product.quantity, + ) + doc.save() + return self._to_domain(doc) + + def find_by_id(self, product_id: str) -> Optional[Product]: + doc = ProductDocument.objects(id=product_id).first() + if not doc: + return None + return self._to_domain(doc) + + def find_all(self) -> list[Product]: + return [self._to_domain(doc) for doc in ProductDocument.objects.all()] + + def delete(self, product_id: str) -> bool: + doc = ProductDocument.objects(id=product_id).first() + if not doc: + return False + doc.delete() + return True \ No newline at end of file diff --git a/backend/python/django_app/core/models/product.py b/backend/python/django_app/core/models/product.py index fe4972fe2..573b834d8 100644 --- a/backend/python/django_app/core/models/product.py +++ b/backend/python/django_app/core/models/product.py @@ -1,4 +1,4 @@ -import uuid +# import uuid from dataclasses import dataclass, field from typing import Optional @@ -10,4 +10,4 @@ class Product: price: float brand: str quantity: int - id: str = field(default_factory=lambda: str(uuid.uuid4())) \ No newline at end of file + id: Optional[str] = None \ No newline at end of file diff --git a/backend/python/django_app/settings.py b/backend/python/django_app/settings.py index eef039af4..5c63d95c7 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv import os from pymongo import MongoClient - +import mongoengine # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -90,12 +90,19 @@ # https://docs.djangoproject.com/en/6.0/ref/settings/#databases DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + # "default": { + # "ENGINE": "django.db.backends.sqlite3", + # "NAME": BASE_DIR / "db.sqlite3", + # } + 'default': { + 'ENGINE': 'django.db.backends.dummy' } } - +mongoengine.connect( + db='interneers_lab_2026_mongodb', + host='localhost', + port=27017 +) # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators diff --git a/backend/python/docker-compose.yaml b/backend/python/docker-compose.yaml index dd07c63bd..3441ee15a 100644 --- a/backend/python/docker-compose.yaml +++ b/backend/python/docker-compose.yaml @@ -1,3 +1,4 @@ +version: '3.8' services: mongodb: image: mongo:8.0 diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt index 52591db3e..5e7ab016b 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -1,2 +1,8 @@ +asgiref==3.11.1 Django==6.0.2 +dnspython==2.8.0 +dotenv==0.9.9 +mongoengine==0.29.1 pymongo==4.16.0 +python-dotenv==1.2.2 +sqlparse==0.5.5 From 08ca0361849f7ef9c5fe37ff5bc225031a6a594b Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Thu, 16 Apr 2026 23:30:45 +0530 Subject: [PATCH 6/9] feat: add product categories, relationships, bulk upload and brand validation - Added ProductCategory domain model, MongoEngine document and repository - Added ProductCategoryService with full CRUD and validation - Updated ProductDocument with ReferenceField to ProductCategoryDocument - Added find_by_category to MongoProductRepository - Added assign/remove category endpoints on products - Made brand a required field with validation - Added bulk CSV product creation endpoint POST /products/bulk/ - Added migration script to backfill brand on existing products --- .../adapters/http/product_category_handler.py | 47 +++++++++++++++ .../adapters/http/product_handler.py | 47 ++++++++++++++- .../mongo_models/product_category_document.py | 7 +++ .../mongo_models/product_document.py | 7 ++- .../mongo_product_category_repository.py | 59 +++++++++++++++++++ .../repositories/mongo_product_repository.py | 31 ++++++++-- .../python/django_app/core/models/product.py | 3 +- .../core/models/product_category.py | 8 +++ .../core/services/product_category_service.py | 40 +++++++++++++ .../core/services/product_service.py | 56 +++++++++++++++++- backend/python/django_app/scripts/debug.py | 20 +++++++ .../django_app/scripts/migrate_add_brand.py | 53 +++++++++++++++++ .../django_app/scripts/seed_categories.py | 45 ++++++++++++++ backend/python/django_app/settings.py | 2 +- backend/python/django_app/urls.py | 12 +++- 15 files changed, 424 insertions(+), 13 deletions(-) create mode 100644 backend/python/django_app/adapters/http/product_category_handler.py create mode 100644 backend/python/django_app/adapters/repositories/mongo_models/product_category_document.py create mode 100644 backend/python/django_app/adapters/repositories/mongo_product_category_repository.py create mode 100644 backend/python/django_app/core/models/product_category.py create mode 100644 backend/python/django_app/core/services/product_category_service.py create mode 100644 backend/python/django_app/scripts/debug.py create mode 100644 backend/python/django_app/scripts/migrate_add_brand.py create mode 100644 backend/python/django_app/scripts/seed_categories.py diff --git a/backend/python/django_app/adapters/http/product_category_handler.py b/backend/python/django_app/adapters/http/product_category_handler.py new file mode 100644 index 000000000..b672cb0c5 --- /dev/null +++ b/backend/python/django_app/adapters/http/product_category_handler.py @@ -0,0 +1,47 @@ +import json +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django_app.core.services.product_category_service import ProductCategoryService +from django_app.adapters.repositories.mongo_product_category_repository import MongoProductCategoryRepository +from dataclasses import asdict + +category_repository = MongoProductCategoryRepository() +service = ProductCategoryService(category_repository) + +@csrf_exempt +def categories(request): + if request.method == 'GET': + all_cats = service.get_all_categories() + return JsonResponse([asdict(c) for c in all_cats], safe=False, status=200) + + elif request.method == 'POST': + try: + data = json.loads(request.body) + category = service.create_category(data) + return JsonResponse(asdict(category), status=201) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) + +@csrf_exempt +def category_detail(request, category_id): + if request.method == 'GET': + category = service.get_category(category_id) + if not category: + return JsonResponse({'error': 'Category not found'}, status=404) + return JsonResponse(asdict(category), status=200) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + category = service.update_category(category_id, data) + if not category: + return JsonResponse({'error': 'Category not found'}, status=404) + return JsonResponse(asdict(category), status=200) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) + + elif request.method == 'DELETE': + deleted = service.delete_category(category_id) + if not deleted: + return JsonResponse({'error': 'Category not found'}, status=404) + return JsonResponse({}, status=204) \ No newline at end of file diff --git a/backend/python/django_app/adapters/http/product_handler.py b/backend/python/django_app/adapters/http/product_handler.py index 7d9ab91a2..2a786ac49 100644 --- a/backend/python/django_app/adapters/http/product_handler.py +++ b/backend/python/django_app/adapters/http/product_handler.py @@ -47,4 +47,49 @@ def product_detail(request, product_id): deleted = service.delete_product(product_id) if not deleted: return JsonResponse({'error': 'Product not found'}, status=404) - return JsonResponse({}, status=204) \ No newline at end of file + return JsonResponse({}, status=204) + +@csrf_exempt +def products_by_category(request, category_id): + """GET /categories//products/""" + if request.method == 'GET': + products = service.get_products_by_category(category_id) + return JsonResponse([asdict(p) for p in products], safe=False, status=200) + +@csrf_exempt +def product_category_assign(request, product_id): + """POST /products//category/ — assign category + DELETE /products//category/ — remove category""" + if request.method == 'POST': + try: + data = json.loads(request.body) + product = service.assign_category(product_id, data['category_id']) + if not product: + return JsonResponse({'error': 'Product not found'}, status=404) + return JsonResponse(asdict(product), status=200) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) + + elif request.method == 'DELETE': + product = service.remove_category(product_id) + if not product: + return JsonResponse({'error': 'Product not found'}, status=404) + return JsonResponse(asdict(product), status=200) + +@csrf_exempt +def bulk_create_products(request): + """POST /products/bulk/ — accepts CSV file""" + if request.method == 'POST': + try: + csv_file = request.FILES.get('file') + if not csv_file: + return JsonResponse({'error': 'No file provided'}, status=400) + csv_content = csv_file.read().decode('utf-8') + products = service.bulk_create_from_csv(csv_content) + return JsonResponse( + {'created': len(products), + 'products': [asdict(p) for p in products]}, + status=201 + ) + except ValueError as e: + return JsonResponse({'errors': e.args[0]}, status=400) \ No newline at end of file diff --git a/backend/python/django_app/adapters/repositories/mongo_models/product_category_document.py b/backend/python/django_app/adapters/repositories/mongo_models/product_category_document.py new file mode 100644 index 000000000..b51caf37e --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_models/product_category_document.py @@ -0,0 +1,7 @@ +from mongoengine import Document, StringField + +class ProductCategoryDocument(Document): + title = StringField(required=True, max_length=100) + description = StringField(max_length=500) + + meta = {'collection': 'product_categories'} \ No newline at end of file diff --git a/backend/python/django_app/adapters/repositories/mongo_models/product_document.py b/backend/python/django_app/adapters/repositories/mongo_models/product_document.py index 43b6a3ab4..17d6ff1a6 100644 --- a/backend/python/django_app/adapters/repositories/mongo_models/product_document.py +++ b/backend/python/django_app/adapters/repositories/mongo_models/product_document.py @@ -1,11 +1,12 @@ -from mongoengine import Document, StringField, FloatField, IntField +from mongoengine import Document, StringField, FloatField, IntField, ReferenceField +from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument class ProductDocument(Document): name = StringField(required=True, max_length=200) description = StringField(max_length=1000) - category = StringField(max_length=100) + category = ReferenceField(ProductCategoryDocument, null=True) price = FloatField(required=True, min_value=0) - brand = StringField(max_length=100) + brand = StringField(required=True, max_length=100) quantity = IntField(required=True, min_value=0) meta = { diff --git a/backend/python/django_app/adapters/repositories/mongo_product_category_repository.py b/backend/python/django_app/adapters/repositories/mongo_product_category_repository.py new file mode 100644 index 000000000..2f4d443e9 --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_product_category_repository.py @@ -0,0 +1,59 @@ +# adapters/repositories/mongo_product_category_repository.py +from bson import ObjectId +from bson.errors import InvalidId +from django_app.core.models.product_category import ProductCategory +from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument +from typing import Optional + +class MongoProductCategoryRepository: + + def _to_domain(self, doc: ProductCategoryDocument) -> ProductCategory: + return ProductCategory( + id=str(doc.id), + title=doc.title, + description=doc.description or '', + ) + + def _to_object_id(self, category_id: str): + try: + return ObjectId(category_id) + except (InvalidId, TypeError): + return None + + def save(self, category: ProductCategory) -> ProductCategory: + if category.id: + oid = self._to_object_id(category.id) + doc = ProductCategoryDocument.objects(id=oid).first() + if doc: + doc.title = category.title + doc.description = category.description + doc.save() + return self._to_domain(doc) + + doc = ProductCategoryDocument( + title=category.title, + description=category.description, + ) + doc.save() + return self._to_domain(doc) + + def find_by_id(self, category_id: str) -> Optional[ProductCategory]: + oid = self._to_object_id(category_id) + if not oid: + return None + doc = ProductCategoryDocument.objects(id=oid).first() + return self._to_domain(doc) if doc else None + + def find_all(self) -> list[ProductCategory]: + return [self._to_domain(doc) for doc in ProductCategoryDocument.objects.all()] + + def delete(self, category_id: str) -> bool: + oid = self._to_object_id(category_id) + if not oid: + return False + doc = ProductCategoryDocument.objects(id=oid).first() + if not doc: + return False + doc.delete() + return True + diff --git a/backend/python/django_app/adapters/repositories/mongo_product_repository.py b/backend/python/django_app/adapters/repositories/mongo_product_repository.py index 2115d6036..07ced7322 100644 --- a/backend/python/django_app/adapters/repositories/mongo_product_repository.py +++ b/backend/python/django_app/adapters/repositories/mongo_product_repository.py @@ -16,6 +16,7 @@ def _to_domain(self, doc: ProductDocument) -> Product: price=doc.price, brand=doc.brand or '', quantity=doc.quantity, + category_id=str(doc.category.id) if doc.category else None, ) def _to_object_id(self, product_id: str): @@ -26,16 +27,26 @@ def _to_object_id(self, product_id: str): return None def save(self, product: Product) -> Product: - # Check if updating existing or creating new + from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument + from bson import ObjectId + + category_doc = None + if product.category_id: + category_doc = ProductCategoryDocument.objects( + id=ObjectId(product.category_id) + ).first() + if product.id: - doc = ProductDocument.objects(id=product.id).first() + oid = self._to_object_id(product.id) + doc = ProductDocument.objects(id=oid).first() if doc: doc.name = product.name doc.description = product.description - doc.category = product.category + # doc.category = product.category doc.price = product.price doc.brand = product.brand doc.quantity = product.quantity + doc.category = category_doc doc.save() return self._to_domain(doc) @@ -43,14 +54,26 @@ def save(self, product: Product) -> Product: doc = ProductDocument( name=product.name, description=product.description, - category=product.category, + # category=product.category, price=product.price, brand=product.brand, quantity=product.quantity, + category=category_doc, ) doc.save() return self._to_domain(doc) + def find_by_category(self, category_id: str) -> list[Product]: + from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument + oid = self._to_object_id(category_id) + if not oid: + return [] + category_doc = ProductCategoryDocument.objects(id=oid).first() + if not category_doc: + return [] + docs = ProductDocument.objects(category=category_doc) + return [self._to_domain(doc) for doc in docs] + def find_by_id(self, product_id: str) -> Optional[Product]: doc = ProductDocument.objects(id=product_id).first() if not doc: diff --git a/backend/python/django_app/core/models/product.py b/backend/python/django_app/core/models/product.py index 573b834d8..4586c9b90 100644 --- a/backend/python/django_app/core/models/product.py +++ b/backend/python/django_app/core/models/product.py @@ -10,4 +10,5 @@ class Product: price: float brand: str quantity: int - id: Optional[str] = None \ No newline at end of file + id: Optional[str] = None + category_id: Optional[str] = None \ No newline at end of file diff --git a/backend/python/django_app/core/models/product_category.py b/backend/python/django_app/core/models/product_category.py new file mode 100644 index 000000000..357fb6370 --- /dev/null +++ b/backend/python/django_app/core/models/product_category.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from typing import Optional + +@dataclass +class ProductCategory: + title: str + description: str + id: Optional[str] = None \ No newline at end of file diff --git a/backend/python/django_app/core/services/product_category_service.py b/backend/python/django_app/core/services/product_category_service.py new file mode 100644 index 000000000..759691e8b --- /dev/null +++ b/backend/python/django_app/core/services/product_category_service.py @@ -0,0 +1,40 @@ +# core/services/product_category_service.py +from django_app.core.models.product_category import ProductCategory +from typing import Optional + +class ProductCategoryService: + def __init__(self, repository): + self.repository = repository + + def create_category(self, data: dict) -> ProductCategory: + self._validate(data) + category = ProductCategory( + title=data['title'], + description=data.get('description', ''), + ) + return self.repository.save(category) + + def get_category(self, category_id: str) -> Optional[ProductCategory]: + return self.repository.find_by_id(category_id) + + def get_all_categories(self) -> list[ProductCategory]: + return self.repository.find_all() + + def update_category(self, category_id: str, data: dict) -> Optional[ProductCategory]: + category = self.repository.find_by_id(category_id) + if not category: + return None + self._validate(data) + category.title = data.get('title', category.title) + category.description = data.get('description', category.description) + return self.repository.save(category) + + def delete_category(self, category_id: str) -> bool: + return self.repository.delete(category_id) + + def _validate(self, data: dict): + errors = {} + if not data.get('title'): + errors['title'] = 'Title is required' + if errors: + raise ValueError(errors) \ No newline at end of file diff --git a/backend/python/django_app/core/services/product_service.py b/backend/python/django_app/core/services/product_service.py index 4296a5164..619f69a17 100644 --- a/backend/python/django_app/core/services/product_service.py +++ b/backend/python/django_app/core/services/product_service.py @@ -2,21 +2,71 @@ from typing import Optional class ProductService: - def __init__(self, repository): + def __init__(self, repository, category_repository=None): self.repository = repository # injected — could be in-memory or mongo + self.category_repository = category_repository def create_product(self, data: dict) -> Product: self._validate(data) + + category_id = data.get('category_id') + if category_id and self.category_repository: + category = self.category_repository.find_by_id(category_id) + if not category: + raise ValueError({'category_id': 'Category not found'}) + product = Product( name=data['name'], description=data.get('description', ''), category=data.get('category', ''), price=data['price'], - brand=data.get('brand', ''), + brand=data.get('brand'), quantity=data['quantity'], + category_id=category_id, ) return self.repository.save(product) + def get_products_by_category(self, category_id: str) -> list[Product]: + return self.repository.find_by_category(category_id) + + def assign_category(self, product_id: str, category_id: str) -> Optional[Product]: + product = self.repository.find_by_id(product_id) + if not product: + return None + if self.category_repository: + category = self.category_repository.find_by_id(category_id) + if not category: + raise ValueError({'category_id': 'Category not found'}) + product.category_id = category_id + return self.repository.save(product) + + def remove_category(self, product_id: str) -> Optional[Product]: + product = self.repository.find_by_id(product_id) + if not product: + return None + product.category_id = None + return self.repository.save(product) + + def bulk_create_from_csv(self, csv_content: str) -> list[Product]: + import csv, io + reader = csv.DictReader(io.StringIO(csv_content)) + created = [] + errors = [] + + for i, row in enumerate(reader): + try: + # CSV values are always strings — cast types explicitly + row['price'] = float(row['price']) + row['quantity'] = int(row['quantity']) + product = self.create_product(row) + created.append(product) + except (ValueError, KeyError) as e: + errors.append({'row': i + 2, 'error': str(e)}) # +2 for header + 0-index + + if errors: + raise ValueError(errors) + return created + def get_product(self, product_id: str) -> Optional[Product]: return self.repository.find_by_id(product_id) @@ -43,6 +93,8 @@ def _validate(self, data: dict): errors = {} if not data.get('name'): errors['name'] = 'Name is required' + if not data.get('brand'): + errors['brand'] = 'Brand is required' if data.get('price') is not None and data['price'] <= 0: errors['price'] = 'Price must be greater than 0' if data.get('quantity') is not None and data['quantity'] < 0: diff --git a/backend/python/django_app/scripts/debug.py b/backend/python/django_app/scripts/debug.py new file mode 100644 index 000000000..18c507fe4 --- /dev/null +++ b/backend/python/django_app/scripts/debug.py @@ -0,0 +1,20 @@ +# debug_db.py +from pymongo import MongoClient + +client = MongoClient('localhost', 27017) + +# 1. Saare database names print karo +print(f"Available Databases: {client.list_database_names()}") + +# 2. Django settings waale DB mein ghuso +db = client['interneers_lab_2026_mongodb'] +print(f"Collections in this DB: {db.list_collection_names()}") + +# 3. Agar 'products' collection hai, toh pehla document dekho +if 'products' in db.list_collection_names(): + first_doc = db.products.find_one() + print(f"First document in 'products': {first_doc}") +else: + print("FATAL: 'products' collection NOT FOUND in this database!") + +client.close() \ No newline at end of file diff --git a/backend/python/django_app/scripts/migrate_add_brand.py b/backend/python/django_app/scripts/migrate_add_brand.py new file mode 100644 index 000000000..6746a21f7 --- /dev/null +++ b/backend/python/django_app/scripts/migrate_add_brand.py @@ -0,0 +1,53 @@ +import os +import sys +from pathlib import Path +from pymongo import MongoClient + +# 1. Path Setup +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_app.settings') + +# 2. Connection details (Auth ke saath) +# Apne settings.py se credentials verify kar lena +# MONGO_USER = "root" +# MONGO_PASS = "example" + +client = MongoClient( + host='localhost', + port=27017, + # username=MONGO_USER, + # password=MONGO_PASS, + # authSource='admin' +) + +db = client['interneers_lab_2026_mongodb'] +collection = db['products'] + +# 3. Pehle check karte hain ki kya humein wo "Electronics" wala product mil raha hai +print("DEBUG: Checking for products with string category...") +sample = collection.find_one({'category': 'Electronics'}) + +if sample: + print(f"FOUND: Product '{sample.get('name')}' has a string category. Fixing now...") + + # Sabhi products jahan category "Electronics" ya koi aur string hai, unhe null karo + result = collection.update_many( + { 'category': { '$type': 'string' } }, + { '$set': { 'category': None } } + ) + print(f"SUCCESS: Fixed {result.modified_count} products.") +else: + print("NOT FOUND: No product found with category='Electronics'.") + print("Checking for ANY product that doesn't have a null/DBRef category...") + + # Ek aur try: Har wo cheez jo null nahi hai aur object nahi hai (string check) + all_docs = collection.find() + count = 0 + for doc in all_docs: + cat = doc.get('category') + if cat and isinstance(cat, str): + collection.update_one({'_id': doc['_id']}, {'$set': {'category': None}}) + count += 1 + print(f"Manual Loop Fix: Fixed {count} products.") + +client.close() \ No newline at end of file diff --git a/backend/python/django_app/scripts/seed_categories.py b/backend/python/django_app/scripts/seed_categories.py new file mode 100644 index 000000000..0a4806b29 --- /dev/null +++ b/backend/python/django_app/scripts/seed_categories.py @@ -0,0 +1,45 @@ +# scripts/seed_categories.py +import django +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_app.settings') +django.setup() + +from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument + +# Clear existing categories first to avoid duplicates on re-run +ProductCategoryDocument.objects.delete() +print("Cleared existing categories") + +categories = [ + { + "title": "Electronics", + "description": "Electronic devices and accessories like keyboards, mice, and hubs" + }, + { + "title": "Food", + "description": "Edible products including snacks, beverages and pantry staples" + }, + { + "title": "Kitchen Essentials", + "description": "Cookware, utensils and appliances for the kitchen" + }, + { + "title": "Stationery", + "description": "Office and school supplies like pens, notebooks and folders" + }, +] + +created = [] +for cat in categories: + doc = ProductCategoryDocument( + title=cat["title"], + description=cat["description"] + ) + doc.save() + created.append(doc) + print(f"Created category: '{doc.title}' with id: {doc.id}") + +print(f"\n✅ Seeded {len(created)} categories successfully") +print("\nCopy these IDs for Postman testing:") +for doc in created: + print(f" {doc.title}: {doc.id}") \ No newline at end of file diff --git a/backend/python/django_app/settings.py b/backend/python/django_app/settings.py index 5c63d95c7..b35f9242b 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -79,7 +79,7 @@ MONGO_USER = os.getenv("MONGO_USER", "root") MONGO_PASS = os.getenv("MONGO_PASS", "example") -MONGO_PORT = os.getenv("MONGO_PORT", "27019") +MONGO_PORT = os.getenv("MONGO_PORT", "27017") MONGO_HOST = os.getenv("MONGO_HOST", "localhost") CLIENT = MongoClient( diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index 70f6755e8..e4ac8f961 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,13 +1,23 @@ from django.contrib import admin from django.urls import path +from django_app.adapters.http.product_category_handler import categories, category_detail from django_app.adapters.http.hello_handler import hello_world -from django_app.adapters.http.product_handler import products, product_detail +from django_app.adapters.http.product_handler import bulk_create_products, product_category_assign, products, product_detail, products_by_category urlpatterns = [ path('admin/', admin.site.urls), # path('hello/', hello_world), # path('test-mongo/', test_mongo), path('hello-world/', hello_world), + # Products path('products/', products), + path('products/bulk/', bulk_create_products), path('products//', product_detail), + path('products//category/', product_category_assign), + + # Categories + path('categories/', categories), + path('categories//', category_detail), + path('categories//products/', products_by_category), + ] From b4c47522b708b05996367e356dda04d3e07ef148 Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Fri, 17 Apr 2026 17:09:27 +0530 Subject: [PATCH 7/9] fix: serialize category as string to resolve JSON TypeError --- .../repositories/mongo_product_repository.py | 8 +++- .../scripts/seed_assign_categories.py | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 backend/python/django_app/scripts/seed_assign_categories.py diff --git a/backend/python/django_app/adapters/repositories/mongo_product_repository.py b/backend/python/django_app/adapters/repositories/mongo_product_repository.py index 07ced7322..04b31ec3c 100644 --- a/backend/python/django_app/adapters/repositories/mongo_product_repository.py +++ b/backend/python/django_app/adapters/repositories/mongo_product_repository.py @@ -8,11 +8,17 @@ class MongoProductRepository: def _to_domain(self, doc: ProductDocument) -> Product: """Convert a DB document → core domain model""" + # print('MongoProductRepository._to_domain doc.category:', doc.category) + + category_title = '' + if doc.category: + category_title = getattr(doc.category, 'title', '') + return Product( id=str(doc.id), name=doc.name, description=doc.description or '', - category=doc.category or '', + category=category_title, price=doc.price, brand=doc.brand or '', quantity=doc.quantity, diff --git a/backend/python/django_app/scripts/seed_assign_categories.py b/backend/python/django_app/scripts/seed_assign_categories.py new file mode 100644 index 000000000..511868b90 --- /dev/null +++ b/backend/python/django_app/scripts/seed_assign_categories.py @@ -0,0 +1,38 @@ +# scripts/seed_assign_categories.py +import django +import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_app.settings') +django.setup() + +from django_app.adapters.repositories.mongo_models.product_document import ProductDocument +from django_app.adapters.repositories.mongo_models.product_category_document import ProductCategoryDocument + +# Fetch all categories into a dict by title for easy lookup +categories = {doc.title: doc for doc in ProductCategoryDocument.objects.all()} +print("Found categories:", list(categories.keys())) + +# Define which product names map to which category +# Adjust these to match whatever products you have in your DB +assignments = { + "Mechanical Keyboard": "Electronics", + "Wireless Mouse": "Electronics", + "USB Hub": "Electronics", + # add more here as needed +} + +for product_name, category_title in assignments.items(): + product = ProductDocument.objects(name=product_name).first() + category = categories.get(category_title) + + if not product: + print(f"⚠️ Product not found: '{product_name}' — skipping") + continue + if not category: + print(f"⚠️ Category not found: '{category_title}' — skipping") + continue + + product.category = category + product.save() + print(f"✅ Assigned '{product_name}' → '{category_title}'") + +print("\nDone! Check Compass to verify the category DBRefs are set.") \ No newline at end of file From c83f775c62f68b15ba66c194d3e40e606c9e723f Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Wed, 29 Apr 2026 13:35:54 +0530 Subject: [PATCH 8/9] made the product service unit test --- .../python/django_app/core/models/product.py | 2 +- backend/python/django_app/pytest.ini | 5 + .../unit/test_product_category_service.py | 0 .../tests/unit/test_product_service.py | 205 ++++++++++++++++++ backend/python/requirements.txt | 7 + 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 backend/python/django_app/pytest.ini create mode 100644 backend/python/django_app/tests/unit/test_product_category_service.py create mode 100644 backend/python/django_app/tests/unit/test_product_service.py diff --git a/backend/python/django_app/core/models/product.py b/backend/python/django_app/core/models/product.py index 4586c9b90..61db29b6f 100644 --- a/backend/python/django_app/core/models/product.py +++ b/backend/python/django_app/core/models/product.py @@ -6,9 +6,9 @@ class Product: name: str description: str - category: str price: float brand: str quantity: int + category: Optional[str] = None id: Optional[str] = None category_id: Optional[str] = None \ No newline at end of file diff --git a/backend/python/django_app/pytest.ini b/backend/python/django_app/pytest.ini new file mode 100644 index 000000000..92c0b986c --- /dev/null +++ b/backend/python/django_app/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = django_app.settings +python_files = tests/test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/python/django_app/tests/unit/test_product_category_service.py b/backend/python/django_app/tests/unit/test_product_category_service.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/django_app/tests/unit/test_product_service.py b/backend/python/django_app/tests/unit/test_product_service.py new file mode 100644 index 000000000..11610e306 --- /dev/null +++ b/backend/python/django_app/tests/unit/test_product_service.py @@ -0,0 +1,205 @@ +import pytest +from unittest.mock import MagicMock +from django_app.core.services.product_service import ProductService +from django_app.core.models.product import Product + +# ─── Helpers ──────────────────────────────────────────────── + +def make_product(**kwargs): + """Helper to create a Product with sensible defaults""" + defaults = dict( + id="507f1e77bcf86cd799439011", + name="Test Keyboard", + description="A test product", + price=4999.0, + brand="Keychron", + quantity=50, + category=None, + category_id=None, + ) + defaults.update(kwargs) + return Product(**defaults) + + +def make_service(saved_product=None, found_product=None, category_found=True): + """Helper to build a ProductService with mocked repositories""" + mock_repo = MagicMock() + mock_category_repo = MagicMock() + + # Default behaviours + mock_repo.save.return_value = saved_product or make_product() + mock_repo.find_by_id.return_value = found_product + mock_repo.find_all.return_value = [] + mock_repo.delete.return_value = True + + mock_category_repo.find_by_id.return_value = ( + MagicMock() if category_found else None + ) + + service = ProductService( + repository=mock_repo, + category_repository=mock_category_repo + ) + return service, mock_repo, mock_category_repo + + +# ─── create_product ───────────────────────────────────────── + +class TestCreateProduct: + + def test_creates_product_with_valid_data(self): + service, mock_repo, _ = make_service() + data = { + "name": "Keyboard", + "price": 4999.0, + "brand": "Keychron", + "quantity": 10, + "description": "Clicky", + } + result = service.create_product(data) + + mock_repo.save.assert_called_once() # repo.save was called + assert result.name == "Test Keyboard" # returns what repo.save returns + + def test_raises_when_name_missing(self): + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "price": 4999.0, + "brand": "Keychron", + "quantity": 10 + }) + assert "name" in exc_info.value.args[0] + + def test_raises_when_brand_missing(self): + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "name": "Keyboard", + "price": 4999.0, + "quantity": 10 + }) + assert "brand" in exc_info.value.args[0] + + def test_raises_when_price_is_zero(self): + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "name": "Keyboard", + "brand": "Keychron", + "price": 0, + "quantity": 10 + }) + assert "price" in exc_info.value.args[0] + + def test_raises_when_price_is_negative(self): + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "name": "Keyboard", + "brand": "Keychron", + "price": -100, + "quantity": 10 + }) + assert "price" in exc_info.value.args[0] + + def test_raises_when_quantity_is_negative(self): + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "name": "Keyboard", + "brand": "Keychron", + "price": 4999.0, + "quantity": -1 + }) + assert "quantity" in exc_info.value.args[0] + + def test_raises_when_category_not_found(self): + service, _, _ = make_service(category_found=False) + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "name": "Keyboard", + "brand": "Keychron", + "price": 4999.0, + "quantity": 10, + "category_id": "nonexistent_id" + }) + assert "category_id" in exc_info.value.args[0] + + def test_multiple_validation_errors_returned_together(self): + """All errors should be returned at once, not one at a time""" + service, _, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_product({ + "price": -100, + "quantity": -5 + # name and brand also missing + }) + errors = exc_info.value.args[0] + assert "name" in errors + assert "brand" in errors + assert "price" in errors + assert "quantity" in errors + + +# ─── get_product ───────────────────────────────────────────── + +class TestGetProduct: + + def test_returns_product_when_found(self): + product = make_product(name="Found Product") + service, mock_repo, _ = make_service(found_product=product) + + result = service.get_product("507f1e77bcf86cd799439011") + + mock_repo.find_by_id.assert_called_once_with("507f1e77bcf86cd799439011") + assert result.name == "Found Product" + + def test_returns_none_when_not_found(self): + service, _, _ = make_service(found_product=None) + result = service.get_product("nonexistent") + assert result is None + + +# ─── update_product ─────────────────────────────────────────── + +class TestUpdateProduct: + + def test_updates_product_when_exists(self): + existing = make_product(name="Old Name") + service, mock_repo, _ = make_service(found_product=existing) + + service.update_product("507f1e77bcf86cd799439011", { + "name": "New Name", + "price": 5999.0, + "brand": "Keychron", + "quantity": 30, + }) + + mock_repo.save.assert_called_once() + + def test_returns_none_when_product_not_found(self): + service, _, _ = make_service(found_product=None) + result = service.update_product("nonexistent", {"name": "X", "brand": "Y", "price": 1, "quantity": 1}) + assert result is None + + +# ─── delete_product ─────────────────────────────────────────── + +class TestDeleteProduct: + + def test_deletes_existing_product(self): + service, mock_repo, _ = make_service() + mock_repo.delete.return_value = True + + result = service.delete_product("507f1e77bcf86cd799439011") + + mock_repo.delete.assert_called_once_with("507f1e77bcf86cd799439011") + assert result is True + + def test_returns_false_when_product_not_found(self): + service, mock_repo, _ = make_service() + mock_repo.delete.return_value = False + + result = service.delete_product("nonexistent") + assert result is False \ No newline at end of file diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt index 5e7ab016b..9e5ae5f77 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -1,8 +1,15 @@ asgiref==3.11.1 +coverage==7.13.5 Django==6.0.2 dnspython==2.8.0 dotenv==0.9.9 +iniconfig==2.3.0 mongoengine==0.29.1 +packaging==26.1 +pluggy==1.6.0 +Pygments==2.20.0 pymongo==4.16.0 +pytest==9.0.3 +pytest-django==4.12.0 python-dotenv==1.2.2 sqlparse==0.5.5 From 51f0d8b715ca0ac472bc63146f8fd6baef560084 Mon Sep 17 00:00:00 2001 From: RashiJyotishi Date: Wed, 29 Apr 2026 13:53:31 +0530 Subject: [PATCH 9/9] Add unit tests for product category service --- .../unit/test_product_category_service.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/backend/python/django_app/tests/unit/test_product_category_service.py b/backend/python/django_app/tests/unit/test_product_category_service.py index e69de29bb..e0478667d 100644 --- a/backend/python/django_app/tests/unit/test_product_category_service.py +++ b/backend/python/django_app/tests/unit/test_product_category_service.py @@ -0,0 +1,69 @@ +import pytest +from unittest.mock import MagicMock +from django_app.core.services.product_category_service import ProductCategoryService +from django_app.core.models.product_category import ProductCategory + + +def make_category(**kwargs): + defaults = dict( + id="507f1e77bcf86cd799439011", + title="Electronics", + description="Electronic items", + ) + defaults.update(kwargs) + return ProductCategory(**defaults) + + +def make_service(found_category=None): + mock_repo = MagicMock() + mock_repo.find_by_id.return_value = found_category + mock_repo.save.return_value = make_category() + mock_repo.find_all.return_value = [] + mock_repo.delete.return_value = True + return ProductCategoryService(repository=mock_repo), mock_repo + + +class TestCreateCategory: + + def test_creates_category_with_valid_data(self): + service, mock_repo = make_service() + service.create_category({"title": "Electronics", "description": "Gadgets"}) + mock_repo.save.assert_called_once() + + def test_raises_when_title_missing(self): + service, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_category({"description": "No title here"}) + assert "title" in exc_info.value.args[0] + + def test_raises_when_title_empty_string(self): + service, _ = make_service() + with pytest.raises(ValueError) as exc_info: + service.create_category({"title": "", "description": "Empty title"}) + assert "title" in exc_info.value.args[0] + + +class TestGetCategory: + + def test_returns_category_when_found(self): + category = make_category(title="Food") + service, mock_repo = make_service(found_category=category) + + result = service.get_category("507f1e77bcf86cd799439011") + + mock_repo.find_by_id.assert_called_once_with("507f1e77bcf86cd799439011") + assert result.title == "Food" + + def test_returns_none_when_not_found(self): + service, _ = make_service(found_category=None) + result = service.get_category("nonexistent") + assert result is None + + +class TestDeleteCategory: + + def test_deletes_existing_category(self): + service, mock_repo = make_service() + result = service.delete_category("507f1e77bcf86cd799439011") + mock_repo.delete.assert_called_once_with("507f1e77bcf86cd799439011") + assert result is True \ No newline at end of file