diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a225cf521 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +# ----------------------------- +# Variables +# ----------------------------- +BACKEND_DIR = backend/python +FRONTEND_DIR = frontend +VENV = venv + +PYTHON = $(BACKEND_DIR)/$(VENV)/Scripts/python +PIP = $(BACKEND_DIR)/$(VENV)/Scripts/pip + +# ----------------------------- +# Setup +# ----------------------------- + +venv: + cd $(BACKEND_DIR) && python -m venv $(VENV) + +install-backend: venv + $(PIP) install --upgrade pip + $(PIP) install -r $(BACKEND_DIR)/requirements.txt + +install-frontend: + cd $(FRONTEND_DIR) && yarn install + +setup: install-backend install-frontend + +# ----------------------------- +# Run servers +# ----------------------------- + +run-backend: + cd $(BACKEND_DIR) && $(VENV)/Scripts/python manage.py runserver + +run-frontend: + cd $(FRONTEND_DIR) && yarn start + +# ----------------------------- +# Testing +# ----------------------------- + +test: + cd $(BACKEND_DIR) && $(VENV)/Scripts/python -m pytest + +coverage: + cd $(BACKEND_DIR) && $(VENV)/Scripts/python -m pytest --cov + +html-coverage: + cd $(BACKEND_DIR) && $(VENV)/Scripts/python -m pytest --cov --cov-report=html diff --git a/README.md b/README.md index 2d09412d9..c2c57c75c 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,157 @@ -# Interneers Lab +# Product Service & Product Category API -Welcome to the **Interneers Lab** repository! This serves as a minimal starter kit for learning and experimenting with: -- **Django** (Python) -- **Golang** (Go) -- **React** (with TypeScript) -- **MongoDB** (via Docker Compose) -- Development environment in **VSCode** (recommended) +This repository contains backend services for managing **Products** and **Product Categories**. +The project includes APIs, service layers, unit tests, integration tests, and automated development workflows using a **Makefile**. -**Important:** Use the **same email** you shared during onboarding when configuring Git and related tools. That ensures consistency across all internal systems. - -### Project structure +--- +## Project Structure ``` -backend/ - go/ # Golang backend (see backend/go/README.md) - python/ # Django (Python) backend (see backend/python/README.md) -frontend/ # React + TypeScript (see frontend/README.md) +├── backend/ +│ ├── python/ +│ │ ├── django_app/ +│ │ ├── htmlcov/ +│ │ ├── product_service/ +│ │ ├── product_category/ +│ │ ├── warehouse/ +│ │ ├── docker-compose.yaml +│ │ ├── pytest.ini +│ │ └── requirements.txt +| | +│ └── go/ +│ +├── frontend/ +│ +├── Makefile +├── README.md +└── CHANGELOG.md ``` +--- + +## Features + +* Product CRUD APIs +* Product Category APIs +* Service layer abstraction +* Unit and integration tests +* Test coverage reporting +* Automated development commands using Makefile --- -## Table of Contents +## Prerequisites + +Before running the project ensure the following are installed: -1. [Getting Started with Git & Forking](#getting-started-with-git-and-forking) -2. [Prerequisites & where to find them](#prerequisites--where-to-find-them) -3. [Setting up & running](#setting-up--running) -4. [Development Workflow](#development-workflow) - - [Pushing Your First Change](#pushing-your-first-change) -5. [Making your first change](#making-your-first-change) -6. [Running Tests](#running-tests) -7. [Frontend Setup](#frontend-setup) -8. [Further Reading](#further-reading) +* Python 3.10+ +* pip +* Node.js (for frontend) +* Yarn +* Make --- -## Getting Started with Git and Forking +## Setup Instructions -### 1. Setting up Git and the Repo +Clone the repository: -1. **Install Git** (if not already): - - **macOS**: [Homebrew](https://brew.sh/) users can run `brew install git`. - - **Windows**: Use [Git for Windows](https://gitforwindows.org/). - - **Linux**: Install via your distro's package manager, e.g., `sudo apt-get install git` (Ubuntu/Debian). +```bash +git clone +cd interneers-lab +``` -2. **Configure Git** with your name and email: - ```bash - git config --global user.name "Your Name" - git config --global user.email "your.email@example.com" # Use the same email you shared during onboarding - ``` +Create virtual environment and install dependencies: -3. **What is Forking?** +```bash +make setup +``` - Forking a repository on GitHub creates your own copy under your GitHub account, where you can make changes independently without affecting the original repo. Later, you can make pull requests to merge changes back if needed. +This command will: -4. Fork the Rippling/interneers-lab repository (ensure you're in the correct org or your personal GitHub account, as directed). -5. **Clone** your forked repo: - ```bash - git clone git@github.com:/interneers-lab.git - cd interneers-lab - ``` +* Create a Python virtual environment +* Install backend dependencies +* Install frontend dependencies -## Prerequisites & where to find them +--- -Prerequisites (Python, Go, Node, Docker, etc.) and how to verify your setup are documented in each part of the repo: +## Running the Backend -- **[backend/python/README.md](backend/python/README.md)** — Python/Django, virtualenv, MongoDB -- **[backend/go/README.md](backend/go/README.md)** — Go, MongoDB -- **[frontend/README.md](frontend/README.md)** — Node, Yarn, React +Start the backend server: -Use the README for the part you're working on. +```bash +make backend +``` --- -## Setting up & running +## Running the Frontend -Setup and run instructions live in the domain READMEs: +Start the frontend application: -- **Python backend:** [backend/python/README.md](backend/python/README.md) — venv, dependencies, `runserver`, Docker Compose for MongoDB -- **Go backend:** [backend/go/README.md](backend/go/README.md) — `make setup`, `make build-and-run`, Docker Compose -- **Frontend:** [frontend/README.md](frontend/README.md) +```bash +make frontend +``` --- -## Development Workflow +## Running Tests -### Making your first change +Execute all tests: -Step-by-step tutorials live in the domain READMEs: +```bash +make test +``` -- **[backend/python/README.md](backend/python/README.md)** — Django starters (e.g. Hello World, Hello {name} API) -- **[backend/go/README.md](backend/go/README.md)** — Go hello-world and APIs -- **[frontend/README.md](frontend/README.md)** — React hello-world and APIs +This will run **pytest test suites** including unit tests. -### Pushing Your First Change +--- -1. **Stage and commit**: - ```bash - git add . - git commit -m "Your descriptive commit message" - ``` -2. **Push to your forked repo (main branch by default):** - ```bash - git push origin main - ``` +## Running Tests with Coverage + +Generate test coverage: + +```bash +make coverage +``` + +Example output: + +``` +---------- coverage ---------- +Name Stmts Miss Cover +---------------------------------------------- +product_service/service.py 45 3 93% +product_category/service.py 30 2 93% +---------------------------------------------- +TOTAL 75 5 93% +``` --- -## Running Tests +## Development Workflow -See the domain READMEs for how to run tests in each stack: +Typical development workflow: -- [backend/python/README.md](backend/python/README.md) -- [backend/go/README.md](backend/go/README.md) -- [frontend/README.md](frontend/README.md) +```bash +make setup +make test +make coverage +``` --- -## Further Reading +## Testing Overview + +The project includes: + +### Unit Tests + +* Test service layer logic +* Mock repository layers + +### Integration Tests -Each domain has detailed README with links to relevant docs. In general: +* Validate API endpoints +* Test full request-response flow -- **Django:** [docs.djangoproject.com](https://docs.djangoproject.com/) -- **React:** [react.dev](https://react.dev/learn) -- **Go:** [go.dev/doc](https://go.dev/doc/) -- **MongoDB:** [docs.mongodb.com](https://docs.mongodb.com/) -- **Docker Compose:** [docs.docker.com/compose](https://docs.docker.com/compose/) +Integration tests may require additional services (e.g. MongoDB) and may be skipped if dependencies are unavailable. diff --git a/backend/python/.coverage b/backend/python/.coverage new file mode 100644 index 000000000..9273cce32 Binary files /dev/null and b/backend/python/.coverage differ diff --git a/backend/python/.coveragerc b/backend/python/.coveragerc new file mode 100644 index 000000000..735496120 --- /dev/null +++ b/backend/python/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + warehouse/* diff --git a/backend/python/README.md b/backend/python/README.md index 1522f5e23..f1df257ec 100644 --- a/backend/python/README.md +++ b/backend/python/README.md @@ -455,3 +455,4 @@ docker compose down # Stop MongoDB docker compose ps # List running containers docker compose logs -f # View logs ``` + \ No newline at end of file diff --git a/backend/python/conftest.py b/backend/python/conftest.py new file mode 100644 index 000000000..22f9ced54 --- /dev/null +++ b/backend/python/conftest.py @@ -0,0 +1,66 @@ +import pytest +from pymongo import MongoClient +from mongoengine import disconnect, connect + +@pytest.fixture(scope="session") +def mongo_client(): + client = MongoClient("mongodb://root:example@localhost:27019/?authSource=admin") + yield client + client.close() + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + disconnect() + connect( + db="test_db", + host="mongodb://root:example@localhost:27019/test_db?authSource=admin" + ) + yield + disconnect() + + +@pytest.fixture(scope="function") +def test_db(mongo_client): + db = mongo_client["test_db"] + + for collection in db.list_collection_names(): + db[collection].delete_many({}) + + yield db + + for collection in db.list_collection_names(): + db[collection].delete_many({}) + +@pytest.fixture +def seeded_categories(test_db): + from products.models import Category + + cat1 = Category(name="Fruits", description="Fresh fruits").save() + cat2 = Category(name="Vegetables", description="Fresh vegetables").save() + + return [str(cat1.id), str(cat2.id)] + +from products.models import Product + +@pytest.fixture +def seeded_products(seeded_categories): + p1 = Product( + name="Apple", + price=10, + category=str(seeded_categories[0]), + brand="Fruit Brand", + quantity=50 + ).save() + + p2 = Product( + name="Carrot", + price=8, + category=str(seeded_categories[1]), + brand="Veg Brand", + quantity=40 + ).save() + + return [p1.id, p2.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..6a1998122 100644 --- a/backend/python/django_app/settings.py +++ b/backend/python/django_app/settings.py @@ -37,6 +37,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", + "products", ] MIDDLEWARE = [ @@ -121,3 +123,12 @@ # https://docs.djangoproject.com/en/6.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +from mongoengine import connect + +from mongoengine import connect + +connect( + db="product_db", + host="mongodb://root:example@localhost:27019/product_db?authSource=admin" +) diff --git a/backend/python/django_app/urls.py b/backend/python/django_app/urls.py index 0418448be..5647a254d 100644 --- a/backend/python/django_app/urls.py +++ b/backend/python/django_app/urls.py @@ -1,11 +1,8 @@ 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.urls import path,include +from django.http import JsonResponse urlpatterns = [ path('admin/', admin.site.urls), - path('hello/', hello_world), -] + path('', include("products.urls")), +] \ No newline at end of file diff --git a/backend/python/products/__init__.py b/backend/python/products/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/products/admin.py b/backend/python/products/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/backend/python/products/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/python/products/apps.py b/backend/python/products/apps.py new file mode 100644 index 000000000..bb7bd52ba --- /dev/null +++ b/backend/python/products/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProductServiceConfig(AppConfig): + name = 'products' diff --git a/backend/python/products/controllers/category.py b/backend/python/products/controllers/category.py new file mode 100644 index 000000000..b008d2c12 --- /dev/null +++ b/backend/python/products/controllers/category.py @@ -0,0 +1,93 @@ +import json + +from rest_framework.decorators import api_view +from django.http import JsonResponse +from ..services.product_service import ProductService +from ..services.category_service import CategoryService +from ..serializers import * +from ..exceptions import * +from ..validators import * +from ..responses import * + +product_service = ProductService() +category_service = CategoryService() + +@api_view(["GET", "POST"]) +def categories(request): + + if request.method == "GET": + categories = category_service.get_all_categories() + serialized_categories = [serialize_catgeory(category) for category in categories] + return success_response("categories",serialized_categories, 200) + + elif request.method == "POST": + try: + data = json.loads(request.body) + data=validate_category(data) + category = category_service.create_category(data) + serialized_category = serialize_catgeory(category) + return success_response("category created",serialized_category, 201) + except InvalidData as e: + return error_response(str(e), 400) + + else: + return invalid_method_response() +@api_view(["GET","PUT","PATCH","DELETE"]) +def category_detail(request, category_id): + + if request.method == "GET": + try: + category = category_service.get_category(category_id) + serialized_category = serialize_catgeory(category) + return success_response("category",serialized_category, 200) + except CategoryError as e: + return error_response(e.message, e.status_code) + + elif request.method == "PUT": + try: + data = json.loads(request.body) + data=validate_category(data) + category = category_service.update_category(category_id, data) + serialized_category = serialize_catgeory(category) + return success_response("category updated",serialized_category, 200) + except CategoryError as e: + return error_response(e.message, e.status_code) + except InvalidData as e: + return error_response(str(e), 400) + + elif request.method == "PATCH": + try: + data = json.loads(request.body) + data=validate_category(data,[]) + category = category_service.update_category(category_id, data, fields_required=False) + serialized_category = serialize_catgeory(category) + return success_response("category updated",serialized_category, 200) + except CategoryError as e: + return error_response(e.message, e.status_code) + except InvalidData as e: + return error_response(str(e), 400) + + elif request.method == "DELETE": + try: + category=category_service.delete_category(category_id) + return success_response("category deleted",serialize_catgeory(category), 200) + except CategoryError as e: + return error_response(e.message, e.status_code) + else: + return invalid_method_response() + + +@api_view(["GET"]) +def list_products_by_category_id(request, category_id): + if request.method == "GET": + try: + sort_by=request.GET.get("sort_by","-updated_at") + category_service.get_category(category_id) + except CategoryError as e: + return error_response(e.message, e.status_code) + + products = product_service.list_products_by_category_id(category_id, sort_by) + serialized_products = [serialize_product(product) for product in products] + return success_response("products",serialized_products, 200) + else: + return invalid_method_response() \ No newline at end of file diff --git a/backend/python/products/controllers/product.py b/backend/python/products/controllers/product.py new file mode 100644 index 000000000..9b477ae34 --- /dev/null +++ b/backend/python/products/controllers/product.py @@ -0,0 +1,80 @@ +import json + +from rest_framework.decorators import api_view +from ..services.product_service import ProductService +from ..services.category_service import CategoryService +from ..serializers import * +from ..exceptions import * +from ..validators import * +from ..responses import * + +product_service = ProductService() +category_service = CategoryService() +@api_view(["GET", "POST"]) +def products(request): + + if request.method == "GET": + sort_by=request.GET.get("sort_by","-updated_at") + products = product_service.list_products(sort_by) + serialized_products = [serialize_product(product) for product in products] + return success_response("products",serialized_products, 200) + + elif request.method == "POST": + try: + data = json.loads(request.body) + data=validate_product(data) + product = product_service.create_product(data) + serialized_product = serialize_product(product) + return success_response("product created",serialized_product, 201) + except InvalidData as e: + return error_response(str(e), 400) + else: + return invalid_method_response() + +@api_view(["GET","PUT","PATCH","DELETE"]) +def product_detail(request, product_id): + + if request.method == "GET": + try: + product = product_service.get_product(product_id) + serialized_product = serialize_product(product) + return success_response("product",serialized_product, 200) + except ProductError as e: + return error_response(e.message, e.status_code) + + + elif request.method == "PUT": + try: + data = json.loads(request.body) + data=validate_product(data) + product = product_service.update_product(product_id, data) + serialized_product = serialize_product(product) + return success_response("product updated",serialized_product, 200) + except (ProductError,CategoryError) as e: + return error_response(e.message,e.status_code) + except InvalidData as e: + return error_response(str(e), 400) + + elif request.method == "PATCH": + try: + data = json.loads(request.body) + data=validate_product(data, []) + product = product_service.update_product(product_id, data) + serialized_product = serialize_product(product) + return success_response("product updated",serialized_product, 200) + except ProductError as e: + return error_response(e.message, e.status_code) + except InvalidData as e: + return error_response(str(e), 400) + + elif request.method == "DELETE": + + try: + product = product_service.delete_product(product_id) + return success_response("product deleted",serialize_product(product), 200) + except ProductError as e: + return error_response(e.message, e.status_code) + + else: + return invalid_method_response() + \ No newline at end of file diff --git a/backend/python/products/exceptions.py b/backend/python/products/exceptions.py new file mode 100644 index 000000000..55b953697 --- /dev/null +++ b/backend/python/products/exceptions.py @@ -0,0 +1,53 @@ +class ProductError(Exception): + def __init__(self, message, status_code=400): + self.message = message + self.status_code = status_code + super().__init__(message) + +class ProductNotFound(ProductError): + def __init__(self, product_id=None, status_code=404): + message = f"Product not found." + if product_id: + message += f" Product ID: {product_id}" + else: + message += f" Product ID: None" + super().__init__(message, status_code) + +class InvalidProductId(ProductError): + def __init__(self, product_id=None): + message = f"Invalid product ID." + if product_id: + message += f" Product ID: {product_id}" + else: + message += f" Product ID: None" + super().__init__(message) + +class InvalidData(Exception): + def __init__(self, message=None): + if message: + super().__init__(message) + else: + super().__init__("Invalid data") + +class CategoryError(Exception): + def __init__(self, message, status_code=400): + self.message = message + self.status_code = status_code + super().__init__(message) +class InvalidCategoryId(CategoryError): + def __init__(self, category_id=None): + message = f"Invalid category ID." + if category_id: + message += f" Category ID: {category_id}" + else: + message += f" Category ID: None" + super().__init__(message, status_code=400) + +class CategoryNotFound(CategoryError): + def __init__(self, category_id): + message = f"Category not found ." + if category_id: + message += f" Category ID: {category_id}" + else: + message += f" Category ID: None" + super().__init__(message, status_code=404) \ No newline at end of file diff --git a/backend/python/products/management/commands/migrate_brands.py b/backend/python/products/management/commands/migrate_brands.py new file mode 100644 index 000000000..96bc18234 --- /dev/null +++ b/backend/python/products/management/commands/migrate_brands.py @@ -0,0 +1,15 @@ +from products.models import Product +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Migrate brands from old format to new format" + + def handle(self, *args, **options): + products = Product.objects() + for product in products: + if not product.brand or product.brand.strip() == "": + product.brand = "Unknown" + product.save() + + self.stdout.write(self.style.SUCCESS("Successfully migrated brands for all products")) + \ No newline at end of file diff --git a/backend/python/products/migrations/__init__.py b/backend/python/products/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/products/models.py b/backend/python/products/models.py new file mode 100644 index 000000000..3e531ddfa --- /dev/null +++ b/backend/python/products/models.py @@ -0,0 +1,35 @@ +import datetime +from mongoengine import Document, ReferenceField, StringField, FloatField, IntField, DateTimeField, NULLIFY + + +class Category(Document): + name=StringField(required=True) + description=StringField() + created_at = DateTimeField(default=datetime.datetime.now) + updated_at = DateTimeField(default=datetime.datetime.now) + + def save(self, *args, **kwargs): + + self.updated_at = datetime.datetime.now() + return super().save(*args, **kwargs) + + + + +class Product(Document): + name=StringField(required=True) + description=StringField() + category = ReferenceField(Category,reverse_delete_rule=NULLIFY) # reverse_delete_rule=NULLIFY + price=FloatField(required=True) + brand=StringField(required=True) + quantity=IntField(required=True) + created_at = DateTimeField(default=datetime.datetime.now) + updated_at = DateTimeField(default=datetime.datetime.now) + + def save(self, *args, **kwargs): + + self.updated_at = datetime.datetime.now() + return super().save(*args, **kwargs) + + + diff --git a/backend/python/products/repository.py b/backend/python/products/repository.py new file mode 100644 index 000000000..172f9c799 --- /dev/null +++ b/backend/python/products/repository.py @@ -0,0 +1,72 @@ +from bson import ObjectId +from .models import Product, Category + + +class ProductRepository: + + @staticmethod + def create(data): + product = Product(**data) + product.save() + return product + + @staticmethod + def get_all(sort_by): + allowed_sorts = ['name', '-name', 'price', '-price', 'created_at', '-created_at', 'updated_at', '-updated_at'] + + if sort_by not in allowed_sorts: + sort_by = '-updated_at' + return Product.objects().order_by(sort_by) + + @staticmethod + def get_by_id(id): + return Product.objects(id=id).first() + + @staticmethod + def update(product): + product.save() + return product + + @staticmethod + def delete(id): + product = Product.objects(id=id).first() + if product: + product.delete() + return product + + @staticmethod + def get_all_by_category_id(category_id, sort_by): + allowed_sorts = ['name', '-name', 'price', '-price', 'created_at', '-created_at', 'updated_at', '-updated_at'] + + if sort_by not in allowed_sorts: + sort_by = '-updated_at' + return Product.objects(category=category_id).order_by(sort_by) + + +class CategoryRepository: + + @staticmethod + def create(data): + category = Category(**data) + category.save() + return category + + @staticmethod + def get_all(): + return Category.objects() + + @staticmethod + def get_by_id(category_id,): + return Category.objects(id=category_id).first() + + @staticmethod + def update(category): + category.save() + return category + + @staticmethod + def delete(category_id): + category = Category.objects(id=category_id).first() + if category: + category.delete() + return category \ No newline at end of file diff --git a/backend/python/products/responses.py b/backend/python/products/responses.py new file mode 100644 index 000000000..68ff503dd --- /dev/null +++ b/backend/python/products/responses.py @@ -0,0 +1,26 @@ +from rest_framework.response import Response +def success_response(data_name,data, status_code): + return Response( + { + "success": True, + data_name : data + }, + status=status_code, + ) + +def error_response(message, status_code): + return Response( + { + "success": False, + "error": message + }, + status=status_code, + ) +def invalid_method_response(): + return Response( + { + "success": False, + "error": "Invalid request method" + }, + status=400, + ) \ No newline at end of file diff --git a/backend/python/products/serializers.py b/backend/python/products/serializers.py new file mode 100644 index 000000000..15a2b5bce --- /dev/null +++ b/backend/python/products/serializers.py @@ -0,0 +1,21 @@ +def serialize_product(product): + return { + "id": str(product.id), + "name": product.name, + "description": product.description, + "category": str(product.category.id) if product.category else None, + "price": product.price, + "brand": product.brand, + "quantity": product.quantity, + "created_at": product.created_at, + "updated_at": product.updated_at + } + +def serialize_catgeory(category): + return { + "id": str(category.id), + "name": category.name, + "description": category.description, + "created_at": category.created_at, + "updated_at": category.updated_at, + } diff --git a/backend/python/products/services/category_service.py b/backend/python/products/services/category_service.py new file mode 100644 index 000000000..674244eda --- /dev/null +++ b/backend/python/products/services/category_service.py @@ -0,0 +1,44 @@ +from bson import ObjectId +from ..repository import ProductRepository, CategoryRepository +from ..exceptions import * +from ..validators import * + +class CategoryService: + + def __init__(self, category_repository=CategoryRepository()): + self.category_repository = category_repository + + def create_category(self, data): + return self.category_repository.create(data) + + def get_all_categories(self): + return self.category_repository.get_all() + + def get_category(self, category_id): + + if not ObjectId.is_valid(category_id): + raise InvalidCategoryId(category_id=category_id) + + category = self.category_repository.get_by_id(category_id) + + if not category: + raise CategoryNotFound(category_id=category_id) + + return category + + def update_category(self, category_id, data, fields_required=True): + + category = self.get_category(category_id) + + for key, value in data.items(): + setattr(category, key, value) + + return self.category_repository.update(category) + + def delete_category(self, category_id): + + category = self.get_category(category_id) + + self.category_repository.delete(category_id) + + return category \ No newline at end of file diff --git a/backend/python/products/services/product_service.py b/backend/python/products/services/product_service.py new file mode 100644 index 000000000..3db99f1dc --- /dev/null +++ b/backend/python/products/services/product_service.py @@ -0,0 +1,80 @@ +from bson import ObjectId + +from ..repository import ProductRepository, CategoryRepository +from ..exceptions import ( + InvalidProductId, + ProductNotFound, + InvalidCategoryId, + CategoryNotFound, +) + + +class ProductService: + + def __init__(self, product_repository=None, category_repository=None): + self.product_repository = product_repository or ProductRepository() + self.category_repository = category_repository or CategoryRepository() + + # ---------- CREATE ---------- + + def create_product(self, data): + return self.product_repository.create(data) + + # ---------- READ ---------- + + def list_products(self, sort_by): + return self.product_repository.get_all(sort_by) + + def get_product(self, product_id): + self._validate_product_id(product_id) + + product = self.product_repository.get_by_id(product_id) + + if not product: + raise ProductNotFound(product_id=product_id) + + return product + + def list_products_by_category_id(self, category_id, sort_by): + return self.product_repository.get_all_by_category_id(category_id, sort_by) + + # ---------- UPDATE ---------- + + def update_product(self, product_id, data): + product = self.get_product(product_id) + + for key, value in data.items(): + + if key == "category": + value = self._validate_category(value) + + setattr(product, key, value) + + return self.product_repository.update(product) + + # ---------- DELETE ---------- + + def delete_product(self, product_id): + product = self.get_product(product_id) + + self.product_repository.delete(product_id) + + return product + + # ---------- HELPERS ---------- + + def _validate_product_id(self, product_id): + if not ObjectId.is_valid(product_id): + raise InvalidProductId(product_id=product_id) + + def _validate_category(self, category_id): + + if not ObjectId.is_valid(category_id): + raise InvalidCategoryId(category_id=category_id) + + category = self.category_repository.get_by_id(category_id) + + if not category: + raise CategoryNotFound(category_id=category_id) + + return category \ No newline at end of file diff --git a/backend/python/products/tests/integration/test_category_api.py b/backend/python/products/tests/integration/test_category_api.py new file mode 100644 index 000000000..b27e4ee65 --- /dev/null +++ b/backend/python/products/tests/integration/test_category_api.py @@ -0,0 +1,88 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +@pytest.fixture +def client(): + return APIClient() + +def test_list_categories(client, seeded_categories): + + url=reverse("categories") + response = client.get(url) + + assert response.status_code == 200 + print(response) + assert len(response.data["categories"]) == 2 + +def test_create_category(client, seeded_categories): + + data = { + "name": "Dairy", + "description": "Milk products" + } + url=reverse("categories") + response = client.post(url, data, format="json") + + assert response.status_code == 201 + assert response.data["category created"]["name"] == "Dairy" + +def test_get_category_detail(client, seeded_categories): + + category_id = str(seeded_categories[0]) + + response = client.get(f"/categories/{category_id}/") + + assert response.status_code == 200 + assert response.data["category"]["name"] == "Fruits" + +def test_update_category(client, seeded_categories): + + category_id = str(seeded_categories[0]) + + data = { + "name": "Fresh Fruits", + "description": "Updated description" + } + + response = client.put( + f"/categories/{category_id}/", + data, + format="json" + ) + + assert response.status_code == 200 + assert response.data["category updated"]["name"] == "Fresh Fruits" + +def test_patch_category(client, seeded_categories): + + category_id = str(seeded_categories[0]) + + data = {"name": "Patched Fruits"} + + response = client.patch( + f"/categories/{category_id}/", + data, + format="json" + ) + + assert response.status_code == 200 + +def test_products_by_category(client, seeded_categories, seeded_products): + + category_id = str(seeded_categories[0]) + print(category_id) + response = client.get(f"/categories/{category_id}/products/") + print(response) + assert response.status_code == 200 + assert len(response.data["products"]) >= 1 + +def test_delete_category(client, seeded_categories): + + category_id = str(seeded_categories[0]) + + url=reverse("category_detail", args=[category_id]) + response = client.delete(url) + + assert response.status_code == 200 + diff --git a/backend/python/products/tests/integration/test_product_api.py b/backend/python/products/tests/integration/test_product_api.py new file mode 100644 index 000000000..2a19da02b --- /dev/null +++ b/backend/python/products/tests/integration/test_product_api.py @@ -0,0 +1,140 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + +@pytest.fixture +def client(): + return APIClient() + +def test_list_products(client, seeded_products): + + url = reverse("products") + + response = client.get(url) + + assert response.status_code == 200 + + assert "products" in response.data + assert len(response.data["products"]) == 2 + +def test_list_products_sorted(client, seeded_products): + + url = reverse("products") + + response = client.get(url, {"sort_by": "price"}) + + assert response.status_code == 200 + + products = response.data["products"] + + assert products[0]["price"] <= products[1]["price"] + +def test_create_product(client, seeded_categories): + + category_id = str(seeded_categories[0]) + + data = { + "name": "Banana", + "price": 5, + "category": category_id, + "brand": "Fruit Brand", + "quantity": 30 + } + + url = reverse("products") + + response = client.post(url, data, format="json") + + assert response.status_code == 201 + + product = response.data["product created"] + + assert product["name"] == "Banana" + assert product["price"] == 5 + +def test_create_product_invalid_data(client): + + data = { + "name": "", + "price": -10 + } + + url = reverse("products") + + response = client.post(url, data, format="json") + + assert response.status_code == 400 + +def test_get_product(client, seeded_products): + + product_id = str(seeded_products[0]) + + url = reverse("product_detail", args=[product_id]) + + response = client.get(url) + + assert response.status_code == 200 + + product = response.data["product"] + + assert product["name"] == "Apple" + +def test_update_product(client, seeded_products, seeded_categories): + + product_id = str(seeded_products[0]) + category_id = str(seeded_categories[0]) + + data = { + "name": "Green Apple", + "price": 12, + "category": category_id, + "brand": "Fruit Brand", + "quantity": 60 + } + + url = reverse("product_detail", args=[product_id]) + + response = client.put(url, data, format="json") + + assert response.status_code == 200 + + updated_product = response.data["product updated"] + + assert updated_product["name"] == "Green Apple" + assert updated_product["price"] == 12 + +def test_patch_product(client, seeded_products): + + product_id = str(seeded_products[0]) + + data = { + "price": 20 + } + + url = reverse("product_detail", args=[product_id]) + + response = client.patch(url, data, format="json") + + assert response.status_code == 200 + + updated_product = response.data["product updated"] + + assert updated_product["price"] == 20 + +def test_delete_product(client, seeded_products): + + product_id = str(seeded_products[0]) + + url = reverse("product_detail", args=[product_id]) + + response = client.delete(url) + + assert response.status_code == 200 + +def test_get_product_invalid_id(client): + + url = reverse("product_detail", args=["invalid-id"]) + + response = client.get(url) + + assert response.status_code == 400 or response.status_code == 404 \ No newline at end of file diff --git a/backend/python/products/tests/unit/test_category_service.py b/backend/python/products/tests/unit/test_category_service.py new file mode 100644 index 000000000..be52bdd7c --- /dev/null +++ b/backend/python/products/tests/unit/test_category_service.py @@ -0,0 +1,113 @@ +import pytest +from bson import ObjectId + +from products.services.category_service import CategoryService +from products.exceptions import ( + InvalidCategoryId, + CategoryNotFound +) + +def test_create_category(mocker): + + repo = mocker.Mock() + + data = {"name": "Electronics"} + + repo.create.return_value = data + + service = CategoryService(repo) + + result = service.create_category(data) + + assert result == data + repo.create.assert_called_once_with(data) + +def test_get_all_categories(mocker): + + repo = mocker.Mock() + + categories = [ + {"name": "Electronics"}, + {"name": "Books"} + ] + + repo.get_all.return_value = categories + + service = CategoryService(repo) + + result = service.get_all_categories() + + assert result == categories + repo.get_all.assert_called_once() + +def test_get_category_success(mocker): + + repo = mocker.Mock() + + category_id = str(ObjectId()) + category = {"_id": category_id, "name": "Electronics"} + + repo.get_by_id.return_value = category + + service = CategoryService(repo) + + result = service.get_category(category_id) + + assert result == category + +def test_get_category_invalid_id(mocker): + + service = CategoryService(mocker.Mock()) + + with pytest.raises(InvalidCategoryId): + service.get_category("invalid-id") + +def test_get_category_not_found(mocker): + + repo = mocker.Mock() + + repo.get_by_id.return_value = None + + service = CategoryService(repo) + + category_id = str(ObjectId()) + + with pytest.raises(CategoryNotFound): + service.get_category(category_id) + +def test_update_category(mocker): + + repo = mocker.Mock() + + category_id = str(ObjectId()) + + category = mocker.Mock() + + repo.get_by_id.return_value = category + repo.update.return_value = category + + service = CategoryService(repo) + + data = {"name": "Updated Category"} + + result = service.update_category(category_id, data) + + repo.update.assert_called_once_with(category) + assert result == category + +def test_delete_category(mocker): + + repo = mocker.Mock() + + category_id = str(ObjectId()) + + category = {"_id": category_id} + + repo.get_by_id.return_value = category + + service = CategoryService(repo) + + result = service.delete_category(category_id) + + repo.delete.assert_called_once_with(category_id) + assert result == category \ No newline at end of file diff --git a/backend/python/products/tests/unit/test_product_service.py b/backend/python/products/tests/unit/test_product_service.py new file mode 100644 index 000000000..8acf0a573 --- /dev/null +++ b/backend/python/products/tests/unit/test_product_service.py @@ -0,0 +1,192 @@ +import pytest +from bson import ObjectId + +from products.services.product_service import ProductService +from products.exceptions import ( + InvalidProductId, + ProductNotFound, + InvalidCategoryId, + CategoryNotFound +) + +def test_create_product(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + data = {"name": "Laptop", "price": 1000} + + product_repo.create.return_value = data + + service = ProductService(product_repo, category_repo) + + result = service.create_product(data) + + assert result == data + product_repo.create.assert_called_once_with(data) + +def test_list_products(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + products = [{"name": "Laptop"}, {"name": "Phone"}] + + product_repo.get_all.return_value = products + + service = ProductService(product_repo, category_repo) + + result = service.list_products("-price") + + assert result == products + product_repo.get_all.assert_called_once_with("-price") + +def test_get_product_success(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + product = {"_id": product_id, "name": "Laptop"} + + product_repo.get_by_id.return_value = product + + service = ProductService(product_repo, category_repo) + + result = service.get_product(product_id) + + assert result == product + +def test_get_product_invalid_id(mocker): + + service = ProductService(mocker.Mock(), mocker.Mock()) + + with pytest.raises(InvalidProductId): + service.get_product("invalid-id") + + +def test_get_product_not_found(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_repo.get_by_id.return_value = None + + service = ProductService(product_repo, category_repo) + + product_id = str(ObjectId()) + + with pytest.raises(ProductNotFound): + service.get_product(product_id) + +def test_update_product(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + + product = mocker.Mock() + product_repo.get_by_id.return_value = product + product_repo.update.return_value = product + + service = ProductService(product_repo, category_repo) + + data = {"name": "Updated Laptop"} + + result = service.update_product(product_id, data) + + product_repo.update.assert_called_once_with(product) + +def test_update_product_with_category(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + category_id = str(ObjectId()) + + product = mocker.Mock() + + product_repo.get_by_id.return_value = product + product_repo.update.return_value = product + + category = {"name": "Electronics"} + category_repo.get_by_id.return_value = category + + service = ProductService(product_repo, category_repo) + + data = {"category": category_id} + + service.update_product(product_id, data) + + category_repo.get_by_id.assert_called_once_with(category_id) + +def test_delete_product(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + + product = {"_id": product_id} + + product_repo.get_by_id.return_value = product + + service = ProductService(product_repo, category_repo) + + result = service.delete_product(product_id) + + product_repo.delete.assert_called_once_with(product_id) + assert result == product + +def test_list_products_by_category_id(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + products = [{"name": "Laptop"}] + + product_repo.get_all_by_category_id.return_value = products + + service = ProductService(product_repo, category_repo) + + result = service.list_products_by_category_id("cat123", "-price") + + assert result == products + product_repo.get_all_by_category_id.assert_called_once_with("cat123", "-price") + + +def test_update_product_invalid_category_id(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + + product_repo.get_by_id.return_value = mocker.Mock() + + service = ProductService(product_repo, category_repo) + + data = {"category": "invalid-id"} + + with pytest.raises(InvalidCategoryId): + service.update_product(product_id, data) + +def test_update_product_category_not_found(mocker): + + product_repo = mocker.Mock() + category_repo = mocker.Mock() + + product_id = str(ObjectId()) + category_id = str(ObjectId()) + + product_repo.get_by_id.return_value = mocker.Mock() + category_repo.get_by_id.return_value = None + + service = ProductService(product_repo, category_repo) + + data = {"category": category_id} + + with pytest.raises(CategoryNotFound): + service.update_product(product_id, data) \ No newline at end of file diff --git a/backend/python/products/urls.py b/backend/python/products/urls.py new file mode 100644 index 000000000..e78776360 --- /dev/null +++ b/backend/python/products/urls.py @@ -0,0 +1,38 @@ +from django.urls import path +from .controllers import product, category + +urlpatterns = [ + path("products/", product.products, name="products"), + # GET -> list products + # POST -> create product + + path("products//", product.product_detail, name="product_detail"), + # GET -> get product + # PUT -> update product + # PATCH -> partial update + # DELETE -> delete product + +] + +urlpatterns += [ + path("categories/", category.categories, name="categories"), + # GET -> list categories + # POST -> create category + + path( + "categories//", + category.category_detail, + name="category_detail", + ), + # GET -> retrieve category + # PUT -> update category + # PATCH -> partial update + # DELETE -> delete category + + path( + "categories//products/", + category.list_products_by_category_id, + name="products_by_category", + ), + # GET -> list products by category +] \ No newline at end of file diff --git a/backend/python/products/validators.py b/backend/python/products/validators.py new file mode 100644 index 000000000..afeb428ef --- /dev/null +++ b/backend/python/products/validators.py @@ -0,0 +1,33 @@ +from .exceptions import InvalidData + + +required_product_fields = ["name", "price", "brand", "quantity"] +required_catgory_fields = ["name"] +def validate_product(data, required_fields=required_product_fields): + for field in required_fields: + if field not in data: + raise InvalidData(f"Missing field: {field}") + + if data.get("price") and float(data["price"]) < 0: + raise InvalidData("Price must be non-negative") + + if data.get("quantity") and int(data["quantity"]) < 0: + raise InvalidData("Quantity must be non-negative") + + if data.get("name") and data["name"].strip() == "": + raise InvalidData("Name cannot be empty or whitespace") + + if data.get("brand") and data["brand"].strip() == "": + raise InvalidData("Brand cannot be empty or whitespace") + + return data + +def validate_category(data, required_fields=required_catgory_fields): + for field in required_fields: + if field not in data: + raise InvalidData(f"Missing field: {field}") + + if data.get("name") and data["name"].strip() == "": + raise InvalidData("Name cannot be empty or whitespace") + + return data \ No newline at end of file diff --git a/backend/python/pytest.ini b/backend/python/pytest.ini new file mode 100644 index 000000000..044e42026 --- /dev/null +++ b/backend/python/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = django_app.settings +python_files = test_*.py *_tests.py \ No newline at end of file