diff --git a/.gitignore b/.gitignore index 496ee2ca..38975ad7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,165 @@ -.DS_Store \ No newline at end of file +.DS_Store +db.sqlite3 + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md index 7a07b246..cdb2dc67 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,62 @@ -_Fork this project and send us a pull request_ - -Write a simple python webservice that uses, manipuates and returns the data found here: [https://www.unisport.dk/api/products/batch/](https://www.unisport.dk/api/products/batch/?list=200776,213591,200775,197250,213590,200780,209588,217706,205990,212703,197237,205989,211651,213626,217710,200783,213576,202483,200777,203860,198079,189052,205946,209125,200784,190711,201338,201440,206026,213587,172011,209592,193539,173432,200785,201442,203854,213577,200802,197362). - - -**/products/** - - -should return the first 10 objects ordered with the cheapest first. - -**/products/?page=2** - - The products should be paginated where **page** in the url above should return the next 10 objects - - **/products/id/** - -should return the individual product. - - - -**_Remember to test_** -**_Remember to document (why, not how)_** - -#### Bonus: - extend the service so the products can also be created, edited and deleted in a backend of choice. - - -_You are welcome to use any thirdparty python web framework or library that you are familiar with._ - -#### Forking and Pull Requests -Information on how to work with forks and pull requests can be found here https://help.github.com/categories/collaborating-with-issues-and-pull-requests/ +## Overview +The project is realized using Django and DRF. The endpoints are documented and testable at `/docs` and `/redoc`. + +Each product can have 0..n stock items tied to itself. Each product can be tied to 0..n labels. The labels are indepentend from the products. + +The code is inside a django app called `products`, which exposes the following urls: +- `products/`: Retrieves the paginated list of the products, with the products ordered by the related stock min price. Also allows creation. +- `products/{pk}`: Retrieves the specified product. Allows updates and deletes. +- `products/{pk}/stock`: Retrieves the stock for a given product. Allows the creation of new stock items for the given product. +- `products/{pk}/stock/{pk}`: Retrieves the specified stock for the given product. Allows updates and deletes. +- `products/{pk}/labels`: Retrieves the labels tied to the given product. Allows the addition of new labels to the given product. +- `products/{pk}/labels/{pk}`: Retrieves the specified label for the given stock. Allows the removal of the specified label from the given product. +- `labels/`: Retrieves all labels, paginated. Allows creation. +- `labels/{pk}`: Retrieves the specified label. Allows updates and deletes. + +### Structure +Here is the structure at a glance, omitting non-important parts: +``` +├── helpers +│   └── base_models.py: classes use for validation and type hints +├── models: contains ORM models divided in subfiles +│   ├── label.py +│   ├── product.py +│   └── stock.py +├── scripts +│   └── import.py: Import script +├── serializers: contains the serializers divided in subfiles +│   ├── label_serializer.py +│   ├── product_serializer.py +│   └── stock_serializer.py +├── tests: containts factories for the models and the tests on the endpoint calls +│   ├── factories +│   │   └── products.py +│   └── views +│   ├── test_label.py +│   ├── test_product.py +│   └── test_stock.py +├── urls.py +└── views: contains the views divided in subfiles + ├── label_view.py + ├── product_view.py + └── stock_view.py +``` +### Data import +In `products/scripts` a `import.py` script is provided to retrieve the data from the provided URL and import it into the database. The script can be run with the `runscript` command, as shown in the "Running" section. It uses pydantic for validation. + +### Database +The dbms is sqlite for simplicity. + +### Running +From inside the repo directory, run: +``` +pip install -r requirements.txt +python manage.py runscript products.scripts.import +python manage.py runserver +``` +### Testing +The tests can be found inside `products/tests`. Since there is virtually no logic on the models, the tests are done on the endpoint calls to assess the responses. +From inside the repo directory, run: +``` +python manage.py test +``` \ No newline at end of file diff --git a/assignment/__init__.py b/assignment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assignment/asgi.py b/assignment/asgi.py new file mode 100644 index 00000000..11f4d618 --- /dev/null +++ b/assignment/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for assignment project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "assignment.settings") + +application = get_asgi_application() diff --git a/assignment/settings.py b/assignment/settings.py new file mode 100644 index 00000000..26dfa8f3 --- /dev/null +++ b/assignment/settings.py @@ -0,0 +1,122 @@ +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = "django-insecure-x5i^cz_f^aemkhu^%bqb+5_3e5buku!e#ah1rm2610d*f=sf-=" + +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "products", + "django_extensions", + "drf_spectacular", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "assignment.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "assignment.wsgi.application" + + +# Database + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = "static/" + +# Default primary key field type + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Rest Framework settings +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +# Spectacular settings +SPECTACULAR_SETTINGS = { + "TITLE": "Unisport Assignment API", + "DESCRIPTION": "Unisport assignment webservice", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + # OTHER SETTINGS +} + +UNISPORT_IMPORT_URL = "https://www.unisport.dk/api/products/batch/?list=352862,338698,333481,352865,291976,307185,291980,338686,338855,332095,352861,330881,352844,261886,291471,310179,291979,332226,330878,352846,291999,333488,256358,338702,332094,202481,242514,338701,198053,307473,338670,261888,332097,338842,291986,307063,338682,352864,332100,333482,266297,307070,194647,338765,305856,307468,194646,338700,332230,332103,291984,292004,352843,307889,332369,307066,332096,330884,291784,338679" diff --git a/assignment/urls.py b/assignment/urls.py new file mode 100644 index 00000000..98a5457b --- /dev/null +++ b/assignment/urls.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.urls import path +from django.urls import include +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("products.urls")), + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), +] diff --git a/assignment/wsgi.py b/assignment/wsgi.py new file mode 100644 index 00000000..7a69642c --- /dev/null +++ b/assignment/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for assignment project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "assignment.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 00000000..6f90ff28 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "assignment.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/products/__init__.py b/products/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/admin.py b/products/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/products/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/products/apps.py b/products/apps.py new file mode 100644 index 00000000..7b0ca83a --- /dev/null +++ b/products/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "products" diff --git a/products/helpers/__init__.py b/products/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/helpers/base_models.py b/products/helpers/base_models.py new file mode 100644 index 00000000..f5b42d4d --- /dev/null +++ b/products/helpers/base_models.py @@ -0,0 +1,58 @@ +from pydantic import BaseModel +from typing import List, Optional, Dict, Any + + +class Price(BaseModel): + currency: str + min_price: str + max_price: str + recommended_retail_price: str + discount_percentage: str + + +class LabelItem(BaseModel): + id: int + name: str + priority: int + color: str + background_color: str + active: bool + + +class StockItem(BaseModel): + pk: int + sku_id: int + size_id: int + barcode: str + order_by: int + name: str + name_short: str + stock_info: str + price: str + recommended_retail_price: str + discount_percentage: str + supplier: str + is_marketplace: bool + availability: str + + +class ProductItem(BaseModel): + id: str + product_id: int + style: str + prices: Price + name: str + relative_url: str + image: str + delivery: str + online: bool + active: bool + labels: List[LabelItem] + is_customizable: bool + paid_print: bool + is_exclusive: bool + stock: List[StockItem] + customization_template_id: Optional[int] + currency: str + url: str + attributes: Dict[str, Any] diff --git a/products/migrations/0001_initial.py b/products/migrations/0001_initial.py new file mode 100644 index 00000000..af37a838 --- /dev/null +++ b/products/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# Generated by Django 5.0.6 on 2024-05-19 15:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Label", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("priority", models.IntegerField()), + ("color", models.CharField(max_length=7)), + ("background_color", models.CharField(max_length=7)), + ("active", models.BooleanField()), + ], + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("product_id", models.IntegerField()), + ("style", models.CharField(max_length=50)), + ("name", models.CharField(max_length=255)), + ("relative_url", models.CharField(max_length=255)), + ("image", models.URLField(max_length=500)), + ("delivery", models.CharField(max_length=50)), + ("online", models.BooleanField()), + ("active", models.BooleanField()), + ("is_customizable", models.BooleanField()), + ("paid_print", models.BooleanField()), + ("is_exclusive", models.BooleanField()), + ( + "customization_template_id", + models.IntegerField(blank=True, null=True), + ), + ("currency", models.CharField(max_length=10)), + ("url", models.URLField(max_length=500)), + ("attributes", models.JSONField()), + ( + "labels", + models.ManyToManyField( + related_name="products", to="products.label" + ), + ), + ], + ), + migrations.CreateModel( + name="Stock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("sku_id", models.IntegerField()), + ("size_id", models.IntegerField()), + ("barcode", models.CharField(max_length=20)), + ("order_by", models.IntegerField()), + ("name", models.CharField(max_length=50)), + ("name_short", models.CharField(max_length=50)), + ("stock_info", models.CharField(blank=True, max_length=255, null=True)), + ("price", models.DecimalField(decimal_places=2, max_digits=10)), + ( + "recommended_retail_price", + models.DecimalField(decimal_places=2, max_digits=10), + ), + ( + "discount_percentage", + models.DecimalField(decimal_places=2, max_digits=5), + ), + ("supplier", models.CharField(max_length=50)), + ("is_marketplace", models.BooleanField()), + ("availability", models.CharField(max_length=50)), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stock", + to="products.product", + ), + ), + ], + ), + ] diff --git a/products/migrations/__init__.py b/products/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/models/__init__.py b/products/models/__init__.py new file mode 100644 index 00000000..f9904408 --- /dev/null +++ b/products/models/__init__.py @@ -0,0 +1,8 @@ +from products.models.product import * # noqa +from products.models.product import __all__ as product_model_all +from products.models.label import * # noqa +from products.models.label import __all__ as label_model_all +from products.models.stock import * # noqa +from products.models.stock import __all__ as stock_model_all + +__all__ = [*product_model_all, *label_model_all, *stock_model_all] diff --git a/products/models/label.py b/products/models/label.py new file mode 100644 index 00000000..67aeec13 --- /dev/null +++ b/products/models/label.py @@ -0,0 +1,14 @@ +from django.db import models + +__all__ = ["Label"] + + +class Label(models.Model): + name = models.CharField(max_length=255) + priority = models.IntegerField() + color = models.CharField(max_length=7) + background_color = models.CharField(max_length=7) + active = models.BooleanField() + + def __str__(self): + return f"{self.id} {self.name}" diff --git a/products/models/product.py b/products/models/product.py new file mode 100644 index 00000000..f70b23d6 --- /dev/null +++ b/products/models/product.py @@ -0,0 +1,26 @@ +from django.db import models + + +__all__ = ["Product"] + + +class Product(models.Model): + product_id = models.IntegerField() + style = models.CharField(max_length=50) + name = models.CharField(max_length=255) + relative_url = models.CharField(max_length=255) + image = models.URLField(max_length=500) + delivery = models.CharField(max_length=50) + online = models.BooleanField() + active = models.BooleanField() + labels = models.ManyToManyField("Label", related_name="products") + is_customizable = models.BooleanField() + paid_print = models.BooleanField() + is_exclusive = models.BooleanField() + customization_template_id = models.IntegerField(null=True, blank=True) + currency = models.CharField(max_length=10) + url = models.URLField(max_length=500) + attributes = models.JSONField() + + def __str__(self): + return f"{self.id} {self.name}" diff --git a/products/models/stock.py b/products/models/stock.py new file mode 100644 index 00000000..144416eb --- /dev/null +++ b/products/models/stock.py @@ -0,0 +1,26 @@ +from django.db import models + + +__all__ = ["Stock"] + + +class Stock(models.Model): + product = models.ForeignKey( + "Product", related_name="stock", on_delete=models.CASCADE + ) + sku_id = models.IntegerField() + size_id = models.IntegerField() + barcode = models.CharField(max_length=20) + order_by = models.IntegerField() + name = models.CharField(max_length=50) + name_short = models.CharField(max_length=50) + stock_info = models.CharField(max_length=255, blank=True, null=True) + price = models.DecimalField(max_digits=10, decimal_places=2) + recommended_retail_price = models.DecimalField(max_digits=10, decimal_places=2) + discount_percentage = models.DecimalField(max_digits=5, decimal_places=2) + supplier = models.CharField(max_length=50) + is_marketplace = models.BooleanField() + availability = models.CharField(max_length=50) + + def __str__(self): + return f"{self.id} {self.name}" diff --git a/products/scripts/.null-ls_431020_import.py b/products/scripts/.null-ls_431020_import.py new file mode 100644 index 00000000..7e8cfee0 --- /dev/null +++ b/products/scripts/.null-ls_431020_import.py @@ -0,0 +1,76 @@ +import requests +import json +from django.conf import settings +from products.models import Product, Label, Stock +from products.helpers.base_models import ProductItem, LabelItem, StockItem, Price + + +def cleanup() -> None: + Product.objects.all().delete() + Label.objects.all().delete() + + +def create_product(product: ProductItem) -> Product: + product_model = Product.objects.create( + product_id=product.product_id, + style=product.style, + name=product.name, + relative_url=product.relative_url, + image=product.image, + delivery=product.delivery, + online=product.online, + active=product.active, + is_customizable=product.is_customizable, + paid_print=product.paid_print, + is_exclusive=product.is_exclusive, + customization_template_id=product.customization_template_id, + currency=product.currency, + url=product.url, + attributes=product.attributes, + ) + for label in product.labels: + label_model = Label.objects.create( + name=label.name, + priority=label.priority, + color=label.color, + background_color=label.background_color, + active=label.active, + ) + product_model.labels.add(label_model) + for stock in product.stock: + Stock.objects.create( + product=product_model, + sku_id=stock.sku_id, + size_id=stock.size_id, + barcode=stock.barcode, + order_by=stock.order_by, + name=stock.name, + name_short=stock.name_short, + stock_info=stock.stock_info, + price=stock.price, + recommended_retail_price=stock.recommended_retail_price, + discount_percentage=stock.discount_percentage, + supplier=stock.supplier, + is_marketplace=stock.is_marketplace, + availability=stock.availability, + ) + return product_model + + +def print_product_info(product: Product) -> None: + print(f"Created product: {product.id} - {product.name}") + print(f"- Labels: {[l for l in product.labels.all()]}") + print(f"- Stock: {[s for s in product.stock.all()]}") + + +def run(): + # Load import URL from environment variable UNISPORT_IMPORT_URL + import_url = settings.UNISPORT_IMPORT_URL + response = requests.get(import_url) + data = response.json() + + products = data["products"] + for product in products: + validated_product = ProductItem(**product) + product_model = create_product(validated_product) + print_product_info(product_model) diff --git a/products/scripts/import.py b/products/scripts/import.py new file mode 100644 index 00000000..272d7c09 --- /dev/null +++ b/products/scripts/import.py @@ -0,0 +1,77 @@ +from django.db.models.base import transaction +import requests +import json +from django.conf import settings +from products.models import Product, Label, Stock +from products.helpers.base_models import ProductItem + + +def cleanup() -> None: + Product.objects.all().delete() + Label.objects.all().delete() + + +def create_product(product: ProductItem) -> Product: + product_model = Product.objects.create( + product_id=product.product_id, + style=product.style, + name=product.name, + relative_url=product.relative_url, + image=product.image, + delivery=product.delivery, + online=product.online, + active=product.active, + is_customizable=product.is_customizable, + paid_print=product.paid_print, + is_exclusive=product.is_exclusive, + customization_template_id=product.customization_template_id, + currency=product.currency, + url=product.url, + attributes=product.attributes, + ) + for label in product.labels: + label_model, _ = Label.objects.get_or_create( + name=label.name, + priority=label.priority, + color=label.color, + background_color=label.background_color, + active=label.active, + ) + product_model.labels.add(label_model) + for stock in product.stock: + Stock.objects.create( + product=product_model, + sku_id=stock.sku_id, + size_id=stock.size_id, + barcode=stock.barcode, + order_by=stock.order_by, + name=stock.name, + name_short=stock.name_short, + stock_info=stock.stock_info, + price=stock.price, + recommended_retail_price=stock.recommended_retail_price, + discount_percentage=stock.discount_percentage, + supplier=stock.supplier, + is_marketplace=stock.is_marketplace, + availability=stock.availability, + ) + return product_model + + +def print_product_info(product: Product) -> None: + print(f"Created product: {product.id} - {product.name}") + print(f"- Labels: {[l for l in product.labels.all()]}") + print(f"- Stock: {[s for s in product.stock.all()]}") + + +@transaction.atomic +def run() -> None: + cleanup() + import_url = settings.UNISPORT_IMPORT_URL + response = requests.get(import_url) + data = response.json() + products = data["products"] + for product in products: + validated_product = ProductItem(**product) + product_model = create_product(validated_product) + print_product_info(product_model) diff --git a/products/serializers/__init__.py b/products/serializers/__init__.py new file mode 100644 index 00000000..2f03f848 --- /dev/null +++ b/products/serializers/__init__.py @@ -0,0 +1,8 @@ +from products.serializers.label_serializer import * # noqa +from products.serializers.label_serializer import __all__ as label_serializer_all +from products.serializers.product_serializer import * # noqa +from products.serializers.product_serializer import __all__ as product_serializer_all +from products.serializers.stock_serializer import * # noqa +from products.serializers.stock_serializer import __all__ as stock_serializer_all + +__all__ = [*label_serializer_all, *product_serializer_all, *stock_serializer_all] diff --git a/products/serializers/label_serializer.py b/products/serializers/label_serializer.py new file mode 100644 index 00000000..840e3733 --- /dev/null +++ b/products/serializers/label_serializer.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from products.models import Label + +__all__ = ["LabelSerializer", "ProductLabelSerializer"] + + +class LabelSerializer(serializers.ModelSerializer): + class Meta: + model = Label + read_only_fields = ["id"] + fields = [ + "name", + "priority", + "color", + "background_color", + "active", + ] + read_only_fields + + +class ProductLabelSerializer(serializers.ModelSerializer): + label_id = serializers.IntegerField(source="id") + + class Meta: + model = Label + read_only_fields = ["name", "priority", "color", "background_color", "active"] + fields = ["label_id"] + read_only_fields diff --git a/products/serializers/product_serializer.py b/products/serializers/product_serializer.py new file mode 100644 index 00000000..8c3f69e3 --- /dev/null +++ b/products/serializers/product_serializer.py @@ -0,0 +1,56 @@ +from django.db.models import Max, Min +from rest_framework import serializers + +from products.helpers.base_models import Price +from products.models import Product + +__all__ = ["ProductSerializer", "PriceSerializer"] + + +class PriceSerializer(serializers.Serializer): + min_price = serializers.DecimalField(max_digits=10, decimal_places=2) + max_price = serializers.DecimalField(max_digits=10, decimal_places=2) + currency = serializers.CharField(max_length=10) + reccomended_retail_price = serializers.DecimalField(max_digits=10, decimal_places=2) + discount_percentage = serializers.DecimalField(max_digits=5, decimal_places=2) + + +class ProductSerializer(serializers.ModelSerializer): + prices = serializers.SerializerMethodField() + + class Meta: + model = Product + read_only_fields = ["id", "stock", "prices", "labels"] + fields = [ + "product_id", + "style", + "name", + "relative_url", + "image", + "delivery", + "online", + "active", + "labels", + "is_customizable", + "paid_print", + "is_exclusive", + "customization_template_id", + "currency", + "url", + "attributes", + ] + read_only_fields + depth = 1 + + def get_prices(self, obj: Product) -> Price: + """ + Get the prices of the product based on the related stock + """ + prices_info: Price = obj.stock.aggregate( + min_price=Min("price"), + max_price=Max("price"), + reccomended_retail_price=Max("recommended_retail_price"), + discount_percentage=Max("discount_percentage"), + ) + prices_info["currency"] = obj.currency + prices: Price = PriceSerializer(prices_info).data + return prices diff --git a/products/serializers/stock_serializer.py b/products/serializers/stock_serializer.py new file mode 100644 index 00000000..f5a4fe16 --- /dev/null +++ b/products/serializers/stock_serializer.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from products.models import Stock + +__all__ = ["StockSerializer"] + + +class StockSerializer(serializers.ModelSerializer): + class Meta: + model = Stock + read_only_fields = ["id", "product"] + fields = [ + "sku_id", + "size_id", + "barcode", + "order_by", + "name", + "name_short", + "stock_info", + "price", + "recommended_retail_price", + "discount_percentage", + "supplier", + "is_marketplace", + "availability", + ] + read_only_fields diff --git a/products/tests/__init__.py b/products/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/tests/factories/__init__.py b/products/tests/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/tests/factories/products.py b/products/tests/factories/products.py new file mode 100644 index 00000000..43534aa1 --- /dev/null +++ b/products/tests/factories/products.py @@ -0,0 +1,76 @@ +import factory +from products.models import Product, Label, Stock + + +class LabelFactory(factory.django.DjangoModelFactory): + class Meta: + model = Label + + name = factory.Faker("word") + priority = factory.Faker("random_int") + color = factory.Faker("hex_color") + background_color = factory.Faker("hex_color") + active = factory.Faker("boolean") + + +class ProductFactory(factory.django.DjangoModelFactory): + class Meta: + model = Product + + product_id = factory.Sequence(lambda n: n) + style = factory.Faker("word") + name = factory.Faker("word") + relative_url = factory.Faker("url") + image = factory.Faker("url") + delivery = factory.Faker("word") + online = factory.Faker("boolean") + active = factory.Faker("boolean") + is_customizable = factory.Faker("boolean") + paid_print = factory.Faker("boolean") + is_exclusive = factory.Faker("boolean") + customization_template_id = factory.Faker("random_int") + currency = "DKK" + url = factory.Faker("url") + attributes = "{}" + + @factory.post_generation + def labels(self, create, extracted, **kwargs): + if not create or not extracted: + return + + self.labels.add(*extracted) + + +class ProductWithSingleStockFactory(ProductFactory): + @factory.post_generation + def stock(self, create, extracted, **kwargs): + if create: + StockFactory(product=self) + + +class StockFactory(factory.django.DjangoModelFactory): + class Meta: + model = Stock + + product = factory.SubFactory(ProductFactory) + sku_id = factory.Faker("random_int") + size_id = factory.Faker("random_int") + barcode = factory.Faker("ean") + order_by = factory.Faker("random_int") + name = factory.Faker("word") + name_short = factory.Faker("word") + stock_info = factory.Faker("word") + price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True) + recommended_retail_price = factory.Faker( + "pydecimal", left_digits=3, right_digits=2, positive=True + ) + discount_percentage = factory.Faker( + "pydecimal", left_digits=2, right_digits=2, positive=True + ) + supplier = factory.Faker("word") + is_marketplace = factory.Faker("boolean") + availability = factory.Faker("word") + + +class ProductWithMultipleStock(ProductFactory): + stock = factory.RelatedFactoryList(StockFactory, "product", size=10) diff --git a/products/tests/views/__init__.py b/products/tests/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/products/tests/views/test_label.py b/products/tests/views/test_label.py new file mode 100644 index 00000000..cb33e7ad --- /dev/null +++ b/products/tests/views/test_label.py @@ -0,0 +1,156 @@ +from django.test import TestCase +from django.urls import reverse + +from products.models import Label +from products.serializers import ( + LabelSerializer, + ProductLabelSerializer, +) +from products.tests.factories.products import LabelFactory, ProductFactory + + +class LabelTestCase(TestCase): + def test_label_list(self): + [LabelFactory() for _ in range(10)] + expected_labels = Label.objects.all() + response = self.client.get(reverse("products:labels-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 10) + self.assertListEqual( + [label["id"] for label in response.data["results"]], + [label.id for label in expected_labels], + ) + + def test_label_list_no_result(self): + response = self.client.get(reverse("products:labels-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + + def test_label_detail(self): + label = LabelFactory() + response = self.client.get(reverse("products:labels-detail", args=[label.id])) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, LabelSerializer(label).data) + + def test_label_detail_not_found(self): + response = self.client.get(reverse("products:labels-detail", args=[1])) + self.assertEqual(response.status_code, 404) + + def test_label_create(self): + label = LabelFactory.stub().__dict__ + response = self.client.post(reverse("products:labels-list"), label) + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + response.data, + LabelSerializer(Label.objects.get(pk=response.data["id"])).data, + ) + + def test_label_create_invalid_data(self): + label = LabelFactory.stub().__dict__ + label["name"] = "" + response = self.client.post(reverse("products:labels-list"), label) + self.assertEqual(response.status_code, 400) + self.assertEqual(Label.objects.count(), 0) + + def test_label_update(self): + label = LabelFactory() + response = self.client.patch( + reverse("products:labels-detail", args=[label.id]), + {"name": "new name"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(Label.objects.get(pk=label.id).name, "new name") + + def test_label_update_invalid_data(self): + label = LabelFactory() + response = self.client.patch( + reverse("products:labels-detail", args=[label.id]), + {"name": ""}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(Label.objects.get(pk=label.id).name, label.name) + + def test_label_delete(self): + label = LabelFactory() + response = self.client.delete( + reverse("products:labels-detail", args=[label.id]) + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(Label.objects.count(), 0) + + +class ProductLabelTestcase(TestCase): + def test_label_list_from_product(self): + """ + Test that a label can be retrieved from the tied product + """ + product = ProductFactory() + label = LabelFactory() + product.labels.add(label) + response = self.client.get( + reverse("products:product-labels-list", args=[product.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertDictEqual( + response.data["results"][0], ProductLabelSerializer(label).data + ) + + def test_label_list_from_product_not_found(self): + response = self.client.get(reverse("products:product-labels-list", args=[1])) + self.assertEqual(response.status_code, 404) + + def test_label_create_with_product(self): + """ + Test that a label can be tied to a product + """ + product = ProductFactory() + label = LabelFactory() + response = self.client.post( + reverse("products:product-labels-list", args=[product.id]), + {"label_id": label.id}, + ) + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + response.data, + ProductLabelSerializer( + Label.objects.get(pk=response.data["label_id"]) + ).data, + ) + self.assertEqual(product.labels.count(), 1) + self.assertEqual(product.labels.first().id, response.data["label_id"]) + + def test_label_create_with_product_not_found(self): + response = self.client.post( + reverse("products:product-labels-list", args=[1111]), {"label_id": 1} + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(Label.objects.count(), 0) + + def test_label_create_with_product_invalid_data(self): + product = ProductFactory() + response = self.client.post( + reverse("products:product-labels-list", args=[product.id]), {"name": "test"} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(product.labels.count(), 0) + self.assertEqual(Label.objects.count(), 0) + + def test_delete_label_with_tied_product(self): + """ + Test that a label can be removed from a product + """ + product = ProductFactory() + label = LabelFactory() + product.labels.add(label) + response = self.client.delete( + reverse("products:labels-detail", args=[label.id]) + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(product.labels.count(), 0) + self.assertEqual(Label.objects.count(), 0) + + def test_delete_label_with_tied_product_not_found(self): + response = self.client.delete(reverse("products:labels-detail", args=[1])) + self.assertEqual(response.status_code, 404) diff --git a/products/tests/views/test_product.py b/products/tests/views/test_product.py new file mode 100644 index 00000000..d280639f --- /dev/null +++ b/products/tests/views/test_product.py @@ -0,0 +1,153 @@ +from django.test import TestCase +from rest_framework.reverse import reverse +from products.serializers import PriceSerializer +from products.tests.factories.products import ( + ProductWithMultipleStock, + ProductWithSingleStockFactory, +) +from products.models import Product +from django.db.models import Max, Min + + +class ProductTestCase(TestCase): + def test_no_result(self): + response = self.client.get(reverse("products:products-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + + def test_result_single_page(self): + [ProductWithSingleStockFactory() for _ in range(10)] + expected_products = ( + Product.objects.annotate(min_stock_price=Min("stock__price")) + .order_by("min_stock_price") + .all() + ) + response = self.client.get(reverse("products:products-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 10) + self.assertListEqual( + [product["id"] for product in response.data["results"]], + [product.id for product in expected_products], + ) + + def test_result_multiple_pages(self): + [ProductWithSingleStockFactory() for _ in range(25)] + expected_products_total = ( + Product.objects.annotate(min_stock_price=Min("stock__price")) + .order_by("min_stock_price") + .all() + ) + for page in range(3): + with self.subTest(page=page): + response = self.client.get( + reverse("products:products-list"), {"page": page + 1} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + len(response.data["results"]), + len(expected_products_total[page * 10 : (page + 1) * 10]), + ) + self.assertListEqual( + [product["id"] for product in response.data["results"]], + [ + product.id + for product in expected_products_total[page * 10 : (page + 1) * 10] + ], + ) + + def test_product_detail_not_found(self): + response = self.client.get(reverse("products:products-detail", args=[1])) + self.assertEqual(response.status_code, 404) + + def test_product_detail(self): + product = ProductWithSingleStockFactory() + response = self.client.get( + reverse("products:products-detail", args=[product.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], product.id) + + def test_product_create(self): + response = self.client.post( + reverse("products:products-list"), + { + "product_id": 1, + "style": "style", + "name": "name", + "relative_url": "/relative/url", + "image": "http://image.com", + "delivery": "delivery", + "online": True, + "active": True, + "is_customizable": True, + "paid_print": True, + "is_exclusive": True, + "currency": "EUR", + "url": "http://url.com", + "attributes": '{"key": "value"}', + }, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Product.objects.count(), 1) + self.assertEqual(response.data["product_id"], 1) + + def test_product_create_invalid(self): + response = self.client.post(reverse("products:products-list"), {}) + self.assertEqual(response.status_code, 400) + + def test_product_update(self): + product = ProductWithSingleStockFactory() + response = self.client.patch( + reverse("products:products-detail", args=[product.id]), + {"name": "new name"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(Product.objects.count(), 1) + self.assertEqual(response.data["name"], "new name") + + def test_product_update_invalid(self): + product = ProductWithSingleStockFactory() + response = self.client.patch( + reverse("products:products-detail", args=[product.id]), + {"style": None}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + + def test_product_update_not_found(self): + response = self.client.patch( + reverse("products:products-detail", args=[1]), + {"name": "new name"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 404) + + def test_product_delete(self): + product = ProductWithSingleStockFactory() + response = self.client.delete( + reverse("products:products-detail", args=[product.id]) + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(Product.objects.count(), 0) + + def test_product_delete_not_found(self): + response = self.client.delete(reverse("products:products-detail", args=[1])) + self.assertEqual(response.status_code, 404) + + def test_product_price_info(self): + """ + Test that the price calculations based on the tied stock are correct + """ + product = ProductWithMultipleStock() + price_info = product.stock.aggregate( + min_price=Min("price"), + max_price=Max("price"), + reccomended_retail_price=Max("recommended_retail_price"), + discount_percentage=Max("discount_percentage"), + ) + price_info["currency"] = product.currency + response = self.client.get( + reverse("products:products-detail", args=[product.id]) + ) + self.assertDictEqual(response.data["prices"], PriceSerializer(price_info).data) diff --git a/products/tests/views/test_stock.py b/products/tests/views/test_stock.py new file mode 100644 index 00000000..aaf9fcd2 --- /dev/null +++ b/products/tests/views/test_stock.py @@ -0,0 +1,122 @@ +from django.test import TestCase +from django.urls import reverse + +from products.models import Stock +from products.serializers import StockSerializer +from products.tests.factories.products import ( + ProductFactory, + ProductWithMultipleStock, + ProductWithSingleStockFactory, + StockFactory, +) + + +class StockTestCase(TestCase): + def test_result_single_stock(self): + product = ProductWithSingleStockFactory() + response = self.client.get( + reverse("products:product-stock-list", args=[product.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertListEqual( + [stock["id"] for stock in response.data["results"]], + [stock.id for stock in product.stock.all()], + ) + + def test_result_multiple_stock(self): + """ + Test that the stock list for a specific product returns its stock ordered by size + """ + product = ProductWithMultipleStock() + expected_stock_total = Stock.objects.filter(product=product).order_by("size_id") + response = self.client.get( + reverse("products:product-stock-list", args=[product.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertListEqual( + [stock["id"] for stock in response.data["results"]], + [stock.id for stock in expected_stock_total], + ) + + def test_stock_list_product_not_found(self): + response = self.client.get(reverse("products:product-stock-list", args=[1])) + self.assertEqual(response.status_code, 404) + + def test_stock_detail_not_found(self): + response = self.client.get( + reverse("products:product-stock-detail", args=[1, 1]) + ) + self.assertEqual(response.status_code, 404) + + def test_stock_detail(self): + stock = StockFactory() + response = self.client.get( + reverse("products:product-stock-detail", args=[stock.product.id, stock.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(response.data, StockSerializer(stock).data) + + def test_stock_create(self): + product = ProductFactory() + stock = StockFactory.stub().__dict__ + del stock["product"] + response = self.client.post( + reverse("products:product-stock-list", args=[product.id]), stock + ) + self.assertEqual(response.status_code, 201) + self.assertDictEqual(response.data, StockSerializer(Stock.objects.first()).data) + self.assertEqual(product.stock.count(), 1) + self.assertEqual(product.stock.first().id, response.data["id"]) + + def test_stock_create_invalid(self): + product = ProductFactory() + response = self.client.post( + reverse("products:product-stock-list", args=[product.id]), {} + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(Stock.objects.count(), 0) + + def test_stock_update(self): + stock = StockFactory() + response = self.client.patch( + reverse( + "products:product-stock-detail", + args=[stock.product.id, stock.id], + ), + {"barcode": "new-barcode"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + stock.refresh_from_db() + self.assertEqual(stock.barcode, "new-barcode") + + def test_stock_update_invalid(self): + stock = StockFactory() + response = self.client.patch( + reverse( + "products:product-stock-detail", + args=[stock.product.id, stock.id], + ), + {"barcode": None}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + stock.refresh_from_db() + + def test_stock_delete(self): + stock = StockFactory() + response = self.client.delete( + reverse( + "products:product-stock-detail", + args=[stock.product.id, stock.id], + ) + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(Stock.objects.count(), 0) + + def test_stock_delete_not_found(self): + response = self.client.delete( + reverse("products:product-stock-detail", args=[1, 1]) + ) + self.assertEqual(response.status_code, 404) diff --git a/products/urls.py b/products/urls.py new file mode 100644 index 00000000..c509694b --- /dev/null +++ b/products/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from products.views import ( + ProductLabelViewset, + ProductViewset, + StockViewset, + LabelViewset, +) +from rest_framework_nested import routers +from django.urls import include + +router = routers.DefaultRouter() +router.register(r"products", ProductViewset, basename="products") +router.register(r"labels", LabelViewset, basename="labels") + +products_router = routers.NestedSimpleRouter(router, r"products", lookup="product") +products_router.register(r"labels", ProductLabelViewset, basename="product-labels") +products_router.register(r"stock", StockViewset, basename="product-stock") + +app_name = "products" +urlpatterns = [ + path("", include(router.urls)), + path("", include(products_router.urls)), +] diff --git a/products/views/__init__.py b/products/views/__init__.py new file mode 100644 index 00000000..6815c68e --- /dev/null +++ b/products/views/__init__.py @@ -0,0 +1,12 @@ +from products.views.product_view import * # noqa +from products.views.product_view import __all__ as product_view_all +from products.views.label_view import * # noqa +from products.views.label_view import __all__ as label_view_all +from products.views.stock_view import * # noqa +from products.views.stock_view import __all__ as stock_view_all + +__all__ = [ + *product_view_all, + *label_view_all, + *stock_view_all, +] diff --git a/products/views/label_view.py b/products/views/label_view.py new file mode 100644 index 00000000..a8896c08 --- /dev/null +++ b/products/views/label_view.py @@ -0,0 +1,81 @@ +from django.db import transaction +from rest_framework import mixins, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.views import status +from products.models import Product, Label +from products.serializers import ( + ProductLabelSerializer, + LabelSerializer, +) +from django.db.models import Min + +__all__ = ["LabelViewset", "ProductLabelViewset"] + + +# Used to retrieve and manipulate all labels +class LabelViewset(viewsets.ModelViewSet): + serializer_class = LabelSerializer + + def get_queryset(self): + return Label.objects.all() + + @transaction.atomic + def create(self, request, *args, **kwargs): + serializer = LabelSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + label = Label.objects.create(**serializer.data) + return Response(LabelSerializer(label).data, status=status.HTTP_201_CREATED) + + +# Used to retrieve the labels tied to a specific product +class ProductLabelViewset( + viewsets.mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + serializer_class = ProductLabelSerializer + + def get_queryset(self): + try: + product = Product.objects.get(pk=int(self.kwargs["product_pk"])) + return Label.objects.filter(products__id=product.id) + except Product.DoesNotExist: + raise NotFound(detail="Product not found") + + @transaction.atomic + def create(self, request, *args, **kwargs): + """ + Adds the label specified in the request to the product specified in the URL + """ + serializer = ProductLabelSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + label = Label.objects.get(pk=serializer.data["label_id"]) + except Label.DoesNotExist: + raise NotFound(detail="Label not found") + try: + product = Product.objects.get(pk=int(self.kwargs["product_pk"])) + product.labels.add(label) + except Product.DoesNotExist: + raise NotFound(detail="Product not found") + return Response( + ProductLabelSerializer(label).data, status=status.HTTP_201_CREATED + ) + + def destroy(self, request, *args, **kwargs): + """ + Removes the label specified in the URL from the product specified in the URL + """ + try: + product = Product.objects.get(pk=int(self.kwargs["product_pk"])) + except Product.DoesNotExist: + raise NotFound(detail="Product not found") + try: + label = Label.objects.get(pk=int(self.kwargs["pk"])) + except Label.DoesNotExist: + raise NotFound(detail="Label not found") + product.labels.remove(label) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/products/views/product_view.py b/products/views/product_view.py new file mode 100644 index 00000000..55acc625 --- /dev/null +++ b/products/views/product_view.py @@ -0,0 +1,15 @@ +from rest_framework import viewsets +from products.models import Product +from products.serializers import ProductSerializer +from django.db.models import Min + +__all__ = ["ProductViewset"] + + +class ProductViewset(viewsets.ModelViewSet): + serializer_class = ProductSerializer + + def get_queryset(self): + return Product.objects.annotate(min_stock_price=Min("stock__price")).order_by( + "min_stock_price" + ) diff --git a/products/views/stock_view.py b/products/views/stock_view.py new file mode 100644 index 00000000..8098b00a --- /dev/null +++ b/products/views/stock_view.py @@ -0,0 +1,36 @@ +from rest_framework import viewsets +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.views import status +from products.models import Product, Stock, Label +from products.serializers import ( + StockSerializer, +) +from django.db.models import Min + +__all__ = ["StockViewset"] + + +# Used to retrieve and manipulate the stock of a specific product +class StockViewset(viewsets.ModelViewSet): + serializer_class = StockSerializer + + def get_queryset(self): + try: + product = Product.objects.get(pk=int(self.kwargs["product_pk"])) + return Stock.objects.filter(product=product).order_by("size_id") + except Product.DoesNotExist: + raise NotFound(detail="Product not found") + + def create(self, request, *args, **kwargs): + """ + Creates a new stock item for the product specified in the URL + """ + serializer = StockSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + product = Product.objects.get(pk=int(self.kwargs["product_pk"])) + except Product.DoesNotExist: + raise NotFound(detail="Product not found") + stock = Stock.objects.create(**serializer.data, product=product) + return Response(StockSerializer(stock).data, status=status.HTTP_201_CREATED) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c40a5f67 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +Django==5.0.6 +django-extensions==3.2.3 +djangorestframework==3.15.1 +drf-nested-routers==0.94.1 +drf-spectacular==0.27.2 +factory-boy==3.3.0 +Faker==25.2.0 +pydantic==2.7.1 +pydantic_core==2.18.2 +requests==2.31.0 diff --git a/schema.yml b/schema.yml new file mode 100644 index 00000000..b5380e66 --- /dev/null +++ b/schema.yml @@ -0,0 +1,1143 @@ +openapi: 3.0.3 +info: + title: Unisport Assignment API + version: 1.0.0 + description: Unisport assignment webservice +paths: + /labels/: + get: + operationId: labels_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + tags: + - labels + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedLabelList' + description: '' + post: + operationId: labels_create + tags: + - labels + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Label' + multipart/form-data: + schema: + $ref: '#/components/schemas/Label' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + description: '' + /labels/{id}/: + get: + operationId: labels_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this label. + required: true + tags: + - labels + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + description: '' + put: + operationId: labels_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this label. + required: true + tags: + - labels + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Label' + multipart/form-data: + schema: + $ref: '#/components/schemas/Label' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + description: '' + patch: + operationId: labels_partial_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this label. + required: true + tags: + - labels + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedLabel' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedLabel' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedLabel' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Label' + description: '' + delete: + operationId: labels_destroy + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this label. + required: true + tags: + - labels + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body + /products/: + get: + operationId: products_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedProductList' + description: '' + post: + operationId: products_create + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Product' + multipart/form-data: + schema: + $ref: '#/components/schemas/Product' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + description: '' + /products/{id}/: + get: + operationId: products_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this product. + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + description: '' + put: + operationId: products_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this product. + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Product' + multipart/form-data: + schema: + $ref: '#/components/schemas/Product' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + description: '' + patch: + operationId: products_partial_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this product. + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedProduct' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedProduct' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedProduct' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + description: '' + delete: + operationId: products_destroy + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this product. + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body + /products/{product_pk}/labels/: + get: + operationId: products_labels_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedProductLabelList' + description: '' + post: + operationId: products_labels_create + description: Adds the label specified in the request to the product specified + in the URL + parameters: + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProductLabel' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ProductLabel' + multipart/form-data: + schema: + $ref: '#/components/schemas/ProductLabel' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ProductLabel' + description: '' + /products/{product_pk}/labels/{id}/: + get: + operationId: products_labels_retrieve + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ProductLabel' + description: '' + delete: + operationId: products_labels_destroy + description: Removes the label specified in the URL from the product specified + in the URL + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body + /products/{product_pk}/stock/: + get: + operationId: products_stock_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedStockList' + description: '' + post: + operationId: products_stock_create + description: Creates a new stock item for the product specified in the URL + parameters: + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Stock' + multipart/form-data: + schema: + $ref: '#/components/schemas/Stock' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + description: '' + /products/{product_pk}/stock/{id}/: + get: + operationId: products_stock_retrieve + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + description: '' + put: + operationId: products_stock_update + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Stock' + multipart/form-data: + schema: + $ref: '#/components/schemas/Stock' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + description: '' + patch: + operationId: products_stock_partial_update + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedStock' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedStock' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedStock' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Stock' + description: '' + delete: + operationId: products_stock_destroy + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: product_pk + schema: + type: string + required: true + tags: + - products + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body +components: + schemas: + Label: + type: object + properties: + name: + type: string + maxLength: 255 + priority: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + color: + type: string + maxLength: 7 + background_color: + type: string + maxLength: 7 + active: + type: boolean + id: + type: integer + readOnly: true + required: + - active + - background_color + - color + - id + - name + - priority + Nested: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + maxLength: 255 + priority: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + color: + type: string + maxLength: 7 + background_color: + type: string + maxLength: 7 + active: + type: boolean + required: + - active + - background_color + - color + - id + - name + - priority + PaginatedLabelList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Label' + PaginatedProductLabelList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ProductLabel' + PaginatedProductList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Product' + PaginatedStockList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Stock' + PatchedLabel: + type: object + properties: + name: + type: string + maxLength: 255 + priority: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + color: + type: string + maxLength: 7 + background_color: + type: string + maxLength: 7 + active: + type: boolean + id: + type: integer + readOnly: true + PatchedProduct: + type: object + properties: + product_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + style: + type: string + maxLength: 50 + name: + type: string + maxLength: 255 + relative_url: + type: string + maxLength: 255 + image: + type: string + format: uri + maxLength: 500 + delivery: + type: string + maxLength: 50 + online: + type: boolean + active: + type: boolean + labels: + type: array + items: + $ref: '#/components/schemas/Nested' + readOnly: true + is_customizable: + type: boolean + paid_print: + type: boolean + is_exclusive: + type: boolean + customization_template_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + nullable: true + currency: + type: string + maxLength: 10 + url: + type: string + format: uri + maxLength: 500 + attributes: {} + id: + type: integer + readOnly: true + stock: + type: array + items: + $ref: '#/components/schemas/Nested' + readOnly: true + prices: + type: object + properties: + min_price: + type: string + max_price: + type: string + currency: + type: string + reccomended_retail_price: + type: string + discount_percentage: + type: string + required: + - currency + - discount_percentage + - max_price + - min_price + - reccomended_retail_price + description: Get the prices of the product based on the related stock + readOnly: true + PatchedStock: + type: object + properties: + sku_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + size_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + barcode: + type: string + maxLength: 20 + order_by: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + name: + type: string + maxLength: 50 + name_short: + type: string + maxLength: 50 + stock_info: + type: string + nullable: true + maxLength: 255 + price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + recommended_retail_price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + discount_percentage: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + supplier: + type: string + maxLength: 50 + is_marketplace: + type: boolean + availability: + type: string + maxLength: 50 + id: + type: integer + readOnly: true + product: + type: integer + readOnly: true + Product: + type: object + properties: + product_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + style: + type: string + maxLength: 50 + name: + type: string + maxLength: 255 + relative_url: + type: string + maxLength: 255 + image: + type: string + format: uri + maxLength: 500 + delivery: + type: string + maxLength: 50 + online: + type: boolean + active: + type: boolean + labels: + type: array + items: + $ref: '#/components/schemas/Nested' + readOnly: true + is_customizable: + type: boolean + paid_print: + type: boolean + is_exclusive: + type: boolean + customization_template_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + nullable: true + currency: + type: string + maxLength: 10 + url: + type: string + format: uri + maxLength: 500 + attributes: {} + id: + type: integer + readOnly: true + stock: + type: array + items: + $ref: '#/components/schemas/Nested' + readOnly: true + prices: + type: object + properties: + min_price: + type: string + max_price: + type: string + currency: + type: string + reccomended_retail_price: + type: string + discount_percentage: + type: string + required: + - currency + - discount_percentage + - max_price + - min_price + - reccomended_retail_price + description: Get the prices of the product based on the related stock + readOnly: true + required: + - active + - attributes + - currency + - delivery + - id + - image + - is_customizable + - is_exclusive + - labels + - name + - online + - paid_print + - prices + - product_id + - relative_url + - stock + - style + - url + ProductLabel: + type: object + properties: + label_id: + type: integer + name: + type: string + readOnly: true + priority: + type: integer + readOnly: true + color: + type: string + readOnly: true + background_color: + type: string + readOnly: true + active: + type: boolean + readOnly: true + required: + - active + - background_color + - color + - label_id + - name + - priority + Stock: + type: object + properties: + sku_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + size_id: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + barcode: + type: string + maxLength: 20 + order_by: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + name: + type: string + maxLength: 50 + name_short: + type: string + maxLength: 50 + stock_info: + type: string + nullable: true + maxLength: 255 + price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + recommended_retail_price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + discount_percentage: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + supplier: + type: string + maxLength: 50 + is_marketplace: + type: boolean + availability: + type: string + maxLength: 50 + id: + type: integer + readOnly: true + product: + type: integer + readOnly: true + required: + - availability + - barcode + - discount_percentage + - id + - is_marketplace + - name + - name_short + - order_by + - price + - product + - recommended_retail_price + - size_id + - sku_id + - supplier + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid