From 61f101087144d424dff9b698bc471c1bddb0fa72 Mon Sep 17 00:00:00 2001 From: raph-7 Date: Sat, 30 Dec 2023 14:53:39 +0300 Subject: [PATCH] DRF COURSE REFERENCE MATERIAL. Has steps on: How to create a Django Rest Framework Authentication Api. How to create and launch a django project on docker. How to create tests for the api. --- backend/core/__init__.py | 0 backend/core/admin.py | 7 + backend/core/apps.py | 6 + backend/core/migrations/0001_initial.py | 33 +++++ backend/core/migrations/__init__.py | 0 backend/core/models.py | 22 +++ backend/core/serializers.py | 19 +++ backend/core/tests.py | 93 ++++++++++++ backend/core/views.py | 37 +++++ backend/docker/docker_files/Dockerfile | 4 +- backend/docker/docker_files/Dockerfile_app | 2 +- backend/drf_course/__init__.py | 0 backend/drf_course/asgi.py | 16 +++ backend/drf_course/settings.py | 142 +++++++++++++++++++ backend/drf_course/urls.py | 19 +++ backend/drf_course/wsgi.py | 16 +++ backend/ecommerce/__init__.py | 0 backend/ecommerce/admin.py | 12 ++ backend/ecommerce/apps.py | 9 ++ backend/ecommerce/migrations/0001_initial.py | 59 ++++++++ backend/ecommerce/migrations/__init__.py | 0 backend/ecommerce/models.py | 85 +++++++++++ backend/ecommerce/serializers.py | 50 +++++++ backend/ecommerce/signals.py | 9 ++ backend/ecommerce/tests.py | 122 ++++++++++++++++ backend/ecommerce/views.py | 59 ++++++++ backend/manage.py | 22 +++ backend/utils/__init__.py | 0 backend/utils/model_abstracts.py | 9 ++ 29 files changed, 849 insertions(+), 3 deletions(-) create mode 100644 backend/core/__init__.py create mode 100644 backend/core/admin.py create mode 100644 backend/core/apps.py create mode 100644 backend/core/migrations/0001_initial.py create mode 100644 backend/core/migrations/__init__.py create mode 100644 backend/core/models.py create mode 100644 backend/core/serializers.py create mode 100644 backend/core/tests.py create mode 100644 backend/core/views.py create mode 100644 backend/drf_course/__init__.py create mode 100644 backend/drf_course/asgi.py create mode 100644 backend/drf_course/settings.py create mode 100644 backend/drf_course/urls.py create mode 100644 backend/drf_course/wsgi.py create mode 100644 backend/ecommerce/__init__.py create mode 100644 backend/ecommerce/admin.py create mode 100644 backend/ecommerce/apps.py create mode 100644 backend/ecommerce/migrations/0001_initial.py create mode 100644 backend/ecommerce/migrations/__init__.py create mode 100644 backend/ecommerce/models.py create mode 100644 backend/ecommerce/serializers.py create mode 100644 backend/ecommerce/signals.py create mode 100644 backend/ecommerce/tests.py create mode 100644 backend/ecommerce/views.py create mode 100644 backend/manage.py create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/model_abstracts.py diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/core/admin.py b/backend/core/admin.py new file mode 100644 index 00000000..a49473ac --- /dev/null +++ b/backend/core/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import Contact + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'description', 'email') \ No newline at end of file diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 00000000..8115ae60 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py new file mode 100644 index 00000000..69a7ac13 --- /dev/null +++ b/backend/core/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.3 on 2023-12-29 20:32 + +from django.db import migrations, models +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('status', models.IntegerField(choices=[(0, 'Inactive'), (1, 'Active')], default=1, verbose_name='status')), + ('activate_date', models.DateTimeField(blank=True, help_text='keep empty for an immediate activation', null=True)), + ('deactivate_date', models.DateTimeField(blank=True, help_text='keep empty for indefinite activation', null=True)), + ('email', models.EmailField(max_length=254, verbose_name='Email')), + ], + options={ + 'verbose_name_plural': 'Contacts', + }, + ), + ] diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/core/models.py b/backend/core/models.py new file mode 100644 index 00000000..8bf71c0f --- /dev/null +++ b/backend/core/models.py @@ -0,0 +1,22 @@ +from django.db import models +from utils.model_abstracts import Model +from django_extensions.db.models import ( + TimeStampedModel, # fields like created + ActivatorModel, # status field, activated date and deactivated date + TitleDescriptionModel # 2 text fields (char) +) + +class Contact( + TimeStampedModel, + ActivatorModel, + TitleDescriptionModel, + Model + ): + + class Meta: + verbose_name_plural = "Contacts" + + email = models.EmailField(verbose_name="Email") #email field + + def __str__(self): + return f'{self.title}' \ No newline at end of file diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 00000000..6fbe195c --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,19 @@ +from . import models +from rest_framework import serializers +from rest_framework.fields import CharField, EmailField + + + +class ContactSerializer(serializers.ModelSerializer): + + name = CharField(source="title", required=True) + message = CharField(source="description", required=True) + email = EmailField(required=True) + + class Meta: + model = models.Contact + fields = ( + 'name', + 'email', + 'message' + ) \ No newline at end of file diff --git a/backend/core/tests.py b/backend/core/tests.py new file mode 100644 index 00000000..c8d1c01e --- /dev/null +++ b/backend/core/tests.py @@ -0,0 +1,93 @@ +from . models import Contact +from rest_framework.test import APIClient +from rest_framework.test import APITestCase +from rest_framework import status + + + +class ContactTestCase(APITestCase): + + """ + Test suite for Contact + """ + def setUp(self): + self.client = APIClient() + self.data = { + "name": "Billy Smith", + "message": "This is a test message", + "email": "billysmith@test.com" + } + self.url = "/contact/" + + def test_create_contact(self): + ''' + test ContactViewSet create method + ''' + data = self.data + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Contact.objects.count(), 1) + self.assertEqual(Contact.objects.get().title, "Billy Smith") + + def test_create_contact_without_name(self): + ''' + test ContactViewSet create method when name is not in data + ''' + data = self.data + data.pop("name") + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_when_name_equals_blank(self): + ''' + test ContactViewSet create method when name is blank + ''' + data = self.data + data["name"] = "" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_without_message(self): + ''' + test ContactViewSet create method when message is not in data + ''' + data = self.data + data.pop("message") + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_when_message_equals_blank(self): + ''' + test ContactViewSet create method when message is blank + ''' + data = self.data + data["message"] = "" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_without_email(self): + ''' + test ContactViewSet create method when email is not in data + ''' + data = self.data + data.pop("email") + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_when_email_equals_blank(self): + ''' + test ContactViewSet create method when email is blank + ''' + data = self.data + data["email"] = "" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_contact_when_email_equals_non_email(self): + ''' + test ContactViewSet create method when email is not email + ''' + data = self.data + data["email"] = "test" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/backend/core/views.py b/backend/core/views.py new file mode 100644 index 00000000..73b29398 --- /dev/null +++ b/backend/core/views.py @@ -0,0 +1,37 @@ +from json import JSONDecodeError +from django.http import JsonResponse +from .serializers import ContactSerializer +from rest_framework.parsers import JSONParser +from rest_framework import views, status +from rest_framework.response import Response + + + +class ContactAPIView(views.APIView): + """ + A simple APIView for creating contact entires. + """ + serializer_class = ContactSerializer + + def get_serializer_context(self): + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return self.serializer_class(*args, **kwargs) + + def post(self, request): + try: + data = JSONParser().parse(request) + serializer = ContactSerializer(data=data) + if serializer.is_valid(raise_exception=True): + serializer.save() + return Response(serializer.data) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except JSONDecodeError: + return JsonResponse({"result": "error","message": "Json decoding error"}, status= 400) diff --git a/backend/docker/docker_files/Dockerfile b/backend/docker/docker_files/Dockerfile index 3615f0ef..0e82a204 100644 --- a/backend/docker/docker_files/Dockerfile +++ b/backend/docker/docker_files/Dockerfile @@ -5,7 +5,7 @@ ENV PYTHONDONTWRITEBYTECODE 1 RUN set -e; \ apt-get update ;\ - apt-get -y install netcat ;\ + apt-get -y install netcat-traditional ;\ apt-get -y install gettext ; RUN mkdir /code @@ -15,7 +15,7 @@ WORKDIR /code RUN set -e; \ /usr/local/bin/python -m pip install --upgrade pip ;\ python -m pip install -r /code/requirements.txt ;\ - chmod +x /code/docker/entrypoints/entrypoint.sh ; + chmod +r,+x /code/docker/entrypoints/entrypoint.sh ; EXPOSE 8000 ENTRYPOINT ["/code/docker/entrypoints/entrypoint.sh"] diff --git a/backend/docker/docker_files/Dockerfile_app b/backend/docker/docker_files/Dockerfile_app index 51cf7818..d5e6afc0 100644 --- a/backend/docker/docker_files/Dockerfile_app +++ b/backend/docker/docker_files/Dockerfile_app @@ -5,7 +5,7 @@ ENV PYTHONDONTWRITEBYTECODE 1 RUN set -e; \ apt-get update ;\ - apt-get -y install netcat ;\ + apt-get -y install netcat-traditional ;\ apt-get -y install gettext ;\ apt-get -y install httpie; \ pip install --upgrade pip \ diff --git a/backend/drf_course/__init__.py b/backend/drf_course/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/drf_course/asgi.py b/backend/drf_course/asgi.py new file mode 100644 index 00000000..0bb087d3 --- /dev/null +++ b/backend/drf_course/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for drf_course 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/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drf_course.settings') + +application = get_asgi_application() diff --git a/backend/drf_course/settings.py b/backend/drf_course/settings.py new file mode 100644 index 00000000..e384dc56 --- /dev/null +++ b/backend/drf_course/settings.py @@ -0,0 +1,142 @@ +from pathlib import Path +from dotenv import load_dotenv +import os +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Getting secret key, debug and allowed hosts from .env file +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +SECRET_KEY = os.environ.get("SECRET_KEY") +DEBUG = int(os.environ.get("DEBUG", default=0)) +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ") + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_extensions', + 'django_filters', + 'rest_framework', + 'rest_framework.authtoken', #Used to enable token authentication + 'core', + 'ecommerce', #New app +] + +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 = 'drf_course.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 = 'drf_course.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +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 +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework_json_api.parsers.JSONParser', + ), + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework_json_api.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer' + ), + 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.QueryParameterValidationFilter', + 'rest_framework_json_api.filters.OrderingFilter', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + ), + 'SEARCH_PARAM': 'filter[search]', + 'TEST_REQUEST_RENDERER_CLASSES': ( + 'rest_framework_json_api.renderers.JSONRenderer', + ), + 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' +} \ No newline at end of file diff --git a/backend/drf_course/urls.py b/backend/drf_course/urls.py new file mode 100644 index 00000000..65d1ac4f --- /dev/null +++ b/backend/drf_course/urls.py @@ -0,0 +1,19 @@ +from django.urls import path +from django.contrib import admin +from core import views as core_views +from ecommerce import views as ecommerce_views +from rest_framework import routers +from rest_framework.authtoken.views import obtain_auth_token + + +router = routers.DefaultRouter() +router.register(r'item', ecommerce_views.ItemViewSet, basename='item') +router.register(r'order', ecommerce_views.OrderViewSet, basename='order') + +urlpatterns = router.urls + +urlpatterns += [ + path('admin/', admin.site.urls), + path('contact/', core_views.ContactAPIView.as_view()), + path('api-token-auth/', obtain_auth_token), +] \ No newline at end of file diff --git a/backend/drf_course/wsgi.py b/backend/drf_course/wsgi.py new file mode 100644 index 00000000..66fab8d1 --- /dev/null +++ b/backend/drf_course/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for drf_course 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/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drf_course.settings') + +application = get_wsgi_application() diff --git a/backend/ecommerce/__init__.py b/backend/ecommerce/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ecommerce/admin.py b/backend/ecommerce/admin.py new file mode 100644 index 00000000..9716fe9c --- /dev/null +++ b/backend/ecommerce/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from . import models + + +@admin.register(models.Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ('id', 'title') + + +@admin.register(models.Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ('id', 'item') \ No newline at end of file diff --git a/backend/ecommerce/apps.py b/backend/ecommerce/apps.py new file mode 100644 index 00000000..65fa4bb0 --- /dev/null +++ b/backend/ecommerce/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class EcommerceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ecommerce' + + def ready(self): + import ecommerce.signals diff --git a/backend/ecommerce/migrations/0001_initial.py b/backend/ecommerce/migrations/0001_initial.py new file mode 100644 index 00000000..e9f1b485 --- /dev/null +++ b/backend/ecommerce/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 4.1.3 on 2023-12-30 03:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from='title', verbose_name='slug')), + ('status', models.IntegerField(choices=[(0, 'Inactive'), (1, 'Active')], default=1, verbose_name='status')), + ('activate_date', models.DateTimeField(blank=True, help_text='keep empty for an immediate activation', null=True)), + ('deactivate_date', models.DateTimeField(blank=True, help_text='keep empty for indefinite activation', null=True)), + ('stock', models.IntegerField(default=1)), + ('price', models.IntegerField(default=0)), + ], + options={ + 'verbose_name': 'Item', + 'verbose_name_plural': 'Items', + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('status', models.IntegerField(choices=[(0, 'Inactive'), (1, 'Active')], default=1, verbose_name='status')), + ('activate_date', models.DateTimeField(blank=True, help_text='keep empty for an immediate activation', null=True)), + ('deactivate_date', models.DateTimeField(blank=True, help_text='keep empty for indefinite activation', null=True)), + ('quantity', models.IntegerField(default=0)), + ('item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='ecommerce.item')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Order', + 'verbose_name_plural': 'Orders', + 'ordering': ['id'], + }, + ), + ] diff --git a/backend/ecommerce/migrations/__init__.py b/backend/ecommerce/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ecommerce/models.py b/backend/ecommerce/models.py new file mode 100644 index 00000000..1fb2e988 --- /dev/null +++ b/backend/ecommerce/models.py @@ -0,0 +1,85 @@ +from django.db import models +from django.contrib.auth.models import User +from utils.model_abstracts import Model +from django_extensions.db.models import ( + TimeStampedModel, + ActivatorModel, + TitleSlugDescriptionModel +) + + +class Item( + TimeStampedModel, + ActivatorModel , + TitleSlugDescriptionModel, + Model): + + """ + ecommerce.Item + Stores a single item entry for our shop + """ + + class Meta: + verbose_name = 'Item' + verbose_name_plural = 'Items' + ordering = ["id"] + + def __str__(self): + return self.title + + stock = models.IntegerField(default=1) + price = models.IntegerField(default=0) + + def amount(self): + #converts price from pence to pounds + amount = float(self.price / 100) + return amount + + def manage_stock(self, qty): + #used to reduce Item stock + new_stock = self.stock - int(qty) + self.stock = new_stock + self.save() + + + def check_stock(self, qty): + #used to check if order quantity exceeds stock levels + if int(qty) > self.stock: + return False + return True + + def place_order(self, user, qty): + #used to place an order + if self.check_stock(qty): + order = Order.objects.create( + item = self, + quantity = qty, + user= user) + self.manage_stock(qty) + return order + else: + return None + + + + +class Order( + TimeStampedModel, + ActivatorModel , + Model): + """ + ecommerce.Order + Stores a single order entry, related to :model:`ecommerce.Item` and + :model:`auth.User`. + """ + class Meta: + verbose_name = 'Order' + verbose_name_plural = 'Orders' + ordering = ["id"] + + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + item = models.ForeignKey(Item, null=True, blank=True, on_delete=models.CASCADE) + quantity = models.IntegerField(default=0) + + def __str__(self): + return f'{self.user.username} - {self.item.title}' \ No newline at end of file diff --git a/backend/ecommerce/serializers.py b/backend/ecommerce/serializers.py new file mode 100644 index 00000000..1cfac59c --- /dev/null +++ b/backend/ecommerce/serializers.py @@ -0,0 +1,50 @@ +from collections import OrderedDict +from .models import Item , Order +from rest_framework_json_api import serializers +from rest_framework import status +from rest_framework.exceptions import APIException + + + + +class NotEnoughStockException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'There is not enough stock' + default_code = 'invalid' + + + + +class ItemSerializer(serializers.ModelSerializer): + + class Meta: + model = Item + fields = ( + 'title', + 'stock', + 'price', + ) + + + + +class OrderSerializer(serializers.ModelSerializer): + + item = serializers.PrimaryKeyRelatedField(queryset = Item.objects.all(), many=False) + + class Meta: + model = Order + fields = ( + 'item', + 'quantity', + ) + + def validate(self, res: OrderedDict): + ''' + Used to validate Item stock levels + ''' + item = res.get("item") + quantity = res.get("quantity") + if not item.check_stock(quantity): + raise NotEnoughStockException + return res \ No newline at end of file diff --git a/backend/ecommerce/signals.py b/backend/ecommerce/signals.py new file mode 100644 index 00000000..60180549 --- /dev/null +++ b/backend/ecommerce/signals.py @@ -0,0 +1,9 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User +from rest_framework.authtoken.models import Token + +@receiver(post_save, sender=User, weak=False) +def report_uploaded(sender, instance, created, **kwargs): + if created: + Token.objects.create(user=instance) \ No newline at end of file diff --git a/backend/ecommerce/tests.py b/backend/ecommerce/tests.py new file mode 100644 index 00000000..c86c3ccb --- /dev/null +++ b/backend/ecommerce/tests.py @@ -0,0 +1,122 @@ +from django.contrib.auth.models import User +from ecommerce.models import Item, Order +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient +from rest_framework.test import APITestCase +from rest_framework import status + + +class EcommerceTestCase(APITestCase): + """ + Test suite for Items and Orders + """ + def setUp(self): + + Item.objects.create(title= "Demo item 1",description= "This is a description for demo 1",price= 500,stock= 20) + Item.objects.create(title= "Demo item 2",description= "This is a description for demo 2",price= 700,stock= 15) + Item.objects.create(title= "Demo item 3",description= "This is a description for demo 3",price= 300,stock= 18) + Item.objects.create(title= "Demo item 4",description= "This is a description for demo 4",price= 400,stock= 14) + Item.objects.create(title= "Demo item 5",description= "This is a description for demo 5",price= 500,stock= 30) + self.items = Item.objects.all() + self.user = User.objects.create_user( + username='testuser1', + password='this_is_a_test', + email='testuser1@test.com' + ) + Order.objects.create(item = Item.objects.first(), user = User.objects.first(), quantity=1) + Order.objects.create(item = Item.objects.first(), user = User.objects.first(), quantity=2) + + #The app uses token authentication + self.token = Token.objects.get(user = self.user) + self.client = APIClient() + + #We pass the token in all calls to the API + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + + + def test_get_all_items(self): + ''' + test ItemsViewSet list method + ''' + self.assertEqual(self.items.count(), 5) + response = self.client.get('/item/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_one_item(self): + ''' + test ItemsViewSet retrieve method + ''' + for item in self.items: + response = self.client.get(f'/item/{item.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_order_is_more_than_stock(self): + ''' + test Item.check_stock when order.quantity > item.stock + ''' + for i in self.items: + current_stock = i.stock + self.assertEqual(i.check_stock(current_stock + 1), False) + + def test_order_equals_stock(self): + ''' + test Item.check_stock when order.quantity == item.stock + ''' + for i in self.items: + current_stock = i.stock + self.assertEqual(i.check_stock(current_stock), True) + + def test_order_is_less_than_stock(self): + ''' + test Item.check_stock when order.quantity < item.stock + ''' + for i in self.items: + current_stock = i.stock + self.assertTrue(i.check_stock(current_stock - 1), True) + + def test_create_order_with_more_than_stock(self): + ''' + test OrdersViewSet create method when order.quantity > item.stock + ''' + for i in self.items: + stock = i.stock + data = {"item": str(i.id), "quantity": str(stock+1)} + response = self.client.post(f'/order/', data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_order_with_less_than_stock(self): + ''' + test OrdersViewSet create method when order.quantity < item.stock + ''' + for i in self.items: + data = {"item": str(i.id), "quantity": 1} + response = self.client.post(f'/order/',data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_order_with_equal_stock(self): + ''' + test OrdersViewSet create method when order.quantity == item.stock + ''' + for i in self.items: + stock = i.stock + data = {"item": str(i.id), "quantity": str(stock)} + response = self.client.post(f'/order/',data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_get_all_orders(self): + ''' + test OrdersViewSet list method + ''' + self.assertEqual(Order.objects.count(), 2) + response = self.client.get('/order/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + + def test_get_one_order(self): + ''' + test OrdersViewSet retrieve method + ''' + orders = Order.objects.filter(user = self.user) + for o in orders: + response = self.client.get(f'/order/{o.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/ecommerce/views.py b/backend/ecommerce/views.py new file mode 100644 index 00000000..016663a0 --- /dev/null +++ b/backend/ecommerce/views.py @@ -0,0 +1,59 @@ +from json import JSONDecodeError +from django.http import JsonResponse +from .serializers import ItemSerializer, OrderSerializer +from .models import Item , Order +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.mixins import ListModelMixin,UpdateModelMixin,RetrieveModelMixin + + + +class ItemViewSet( + ListModelMixin, + RetrieveModelMixin, + viewsets.GenericViewSet + ): + """ + A simple ViewSet for listing or retrieving items. + """ + permission_classes = (IsAuthenticated,) + queryset = Item.objects.all() + serializer_class = ItemSerializer + + + + +class OrderViewSet( + ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + viewsets.GenericViewSet + ): + """ + A simple ViewSet for listing, retrieving and creating orders. + """ + permission_classes = (IsAuthenticated,) + serializer_class = OrderSerializer + + def get_queryset(self): + """ + This view should return a list of all the orders + for the currently authenticated user. + """ + user = self.request.user + return Order.objects.filter(user = user) + + def create(self, request): + try: + data = JSONParser().parse(request) + serializer = OrderSerializer(data=data) + if serializer.is_valid(raise_exception=True): + item = Item.objects.get(pk = data["item"]) + order = item.place_order(request.user, data["quantity"]) + return Response(OrderSerializer(order).data) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except JSONDecodeError: + return JsonResponse({"result": "error","message": "Json decoding error"}, status= 400) \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 00000000..4e3ceab4 --- /dev/null +++ b/backend/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', 'drf_course.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/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/utils/model_abstracts.py b/backend/utils/model_abstracts.py new file mode 100644 index 00000000..e548acd5 --- /dev/null +++ b/backend/utils/model_abstracts.py @@ -0,0 +1,9 @@ +import uuid +from django.db import models + + +class Model(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4) #primary key + + class Meta: + abstract = True \ No newline at end of file