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/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 new file mode 100644 index 000000000..2a786ac49 --- /dev/null +++ b/backend/python/django_app/adapters/http/product_handler.py @@ -0,0 +1,95 @@ +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 = MongoProductRepository() +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) + +@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/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/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 new file mode 100644 index 000000000..17d6ff1a6 --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_models/product_document.py @@ -0,0 +1,14 @@ +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 = ReferenceField(ProductCategoryDocument, null=True) + price = FloatField(required=True, min_value=0) + brand = StringField(required=True, 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_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 new file mode 100644 index 000000000..04b31ec3c --- /dev/null +++ b/backend/python/django_app/adapters/repositories/mongo_product_repository.py @@ -0,0 +1,97 @@ +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""" + # 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=category_title, + 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): + """Safely convert string → ObjectId, return None if invalid""" + try: + return ObjectId(product_id) + except (InvalidId, TypeError): + return None + + def save(self, product: Product) -> Product: + 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: + 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.price = product.price + doc.brand = product.brand + doc.quantity = product.quantity + doc.category = category_doc + 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, + 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: + 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 new file mode 100644 index 000000000..61db29b6f --- /dev/null +++ b/backend/python/django_app/core/models/product.py @@ -0,0 +1,14 @@ +# import uuid +from dataclasses import dataclass, field +from typing import Optional + +@dataclass +class Product: + name: str + description: 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/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/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/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 new file mode 100644 index 000000000..619f69a17 --- /dev/null +++ b/backend/python/django_app/core/services/product_service.py @@ -0,0 +1,103 @@ +from django_app.core.models.product import Product +from typing import Optional + +class ProductService: + 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'), + 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) + + 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 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: + errors['quantity'] = 'Quantity cannot be negative' + if errors: + raise ValueError(errors) \ 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/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_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 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 2d7ea95db..b35f9242b 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -11,7 +11,10 @@ """ from pathlib import Path - +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 @@ -70,16 +73,36 @@ 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", "27017") +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 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/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..e0478667d --- /dev/null +++ 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 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/django_app/urls.py b/backend/python/django_app/urls.py index 0418448be..e4ac8f961 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,11 +1,23 @@ from django.contrib import admin from django.urls import path -from django.http import HttpResponse - -def hello_world(request): - return HttpResponse("Hello, world! This is our interneers-lab Django server.") +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 bulk_create_products, product_category_assign, products, product_detail, products_by_category urlpatterns = [ path('admin/', admin.site.urls), - path('hello/', hello_world), + # 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), + ] 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..9e5ae5f77 100644 --- a/backend/python/requirements.txt +++ b/backend/python/requirements.txt @@ -1,2 +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