From 0c02dc07e62143043da1331bc4fbd7fece997f31 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 09:36:04 +0100 Subject: [PATCH 01/24] CHORE[Initial django project setup] --- app/app/asgi.py | 2 +- app/app/settings.py | 76 ++--- app/app/urls.py | 3 +- app/app/wsgi.py | 2 +- app/core/apps.py | 4 +- app/core/migrations/0001_initial.py | 66 +++- app/core/models.py | 12 +- app/core/tests/__init__.py | 2 +- app/core/tests/test_models.py | 20 +- app/manage.py | 4 +- e_comm/__init__.py | 0 e_comm/asgi.py | 16 + e_comm/settings.py | 123 +++++++ e_comm/urls.py | 23 ++ e_comm/wsgi.py | 16 + manage.py | 22 ++ poetry.lock | 479 ++++++++++++++++++++++++++++ pyproject.toml | 27 ++ 18 files changed, 821 insertions(+), 76 deletions(-) create mode 100644 e_comm/__init__.py create mode 100644 e_comm/asgi.py create mode 100644 e_comm/settings.py create mode 100644 e_comm/urls.py create mode 100644 e_comm/wsgi.py create mode 100644 manage.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/app/app/asgi.py b/app/app/asgi.py index 3163a3a..410ea53 100644 --- a/app/app/asgi.py +++ b/app/app/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_asgi_application() diff --git a/app/app/settings.py b/app/app/settings.py index 5356348..744aa83 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -20,7 +20,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-h-h69cr05lmc*w4vtkf+5qltg8#(v9j9oxs-*-^#vjd' +SECRET_KEY = "django-insecure-h-h69cr05lmc*w4vtkf+5qltg8#(v9j9oxs-*-^#vjd" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -31,53 +31,53 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'core' + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "core", ] 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', + "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 = 'app.urls' +ROOT_URLCONF = "app.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', + "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 = 'app.wsgi.application' +WSGI_APPLICATION = "app.wsgi.application" # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -87,16 +87,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -104,9 +104,9 @@ # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -116,11 +116,11 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -AUTH_USER_MODEL = 'core.User' \ No newline at end of file +AUTH_USER_MODEL = "core.User" diff --git a/app/app/urls.py b/app/app/urls.py index 7c49775..19bd31b 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -13,9 +13,10 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), ] diff --git a/app/app/wsgi.py b/app/app/wsgi.py index 0efb709..f2c7150 100644 --- a/app/app/wsgi.py +++ b/app/app/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_wsgi_application() diff --git a/app/core/apps.py b/app/core/apps.py index 8115ae6..c0ce093 100644 --- a/app/core/apps.py +++ b/app/core/apps.py @@ -2,5 +2,5 @@ class CoreConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'core' + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/app/core/migrations/0001_initial.py b/app/core/migrations/0001_initial.py index f533e94..7ab5dbd 100644 --- a/app/core/migrations/0001_initial.py +++ b/app/core/migrations/0001_initial.py @@ -8,26 +8,66 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('email', models.EmailField(max_length=255, unique=True)), - ('name', models.CharField(max_length=255)), - ('is_active', models.BooleanField(default=True)), - ('is_staff', models.BooleanField(default=False)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ("email", models.EmailField(max_length=255, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/app/core/models.py b/app/core/models.py index a417f39..082e395 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -1,8 +1,12 @@ """Create and manage app models and methods.""" from django.db import models -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, \ - PermissionsMixin +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) + # Create your models here. @@ -12,7 +16,7 @@ class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): """Create_user method creates and saves new user objects.""" if not email: - raise ValueError('User must have valid email address') + raise ValueError("User must have valid email address") user = self.model(email=self.normalize_email(email), **extra_fields) user.set_password(password) @@ -39,4 +43,4 @@ class User(AbstractBaseUser, PermissionsMixin): objects = UserManager() - USERNAME_FIELD = 'email' + USERNAME_FIELD = "email" diff --git a/app/core/tests/__init__.py b/app/core/tests/__init__.py index 269a2e3..75cd3ae 100644 --- a/app/core/tests/__init__.py +++ b/app/core/tests/__init__.py @@ -1,2 +1,2 @@ """TESTS MODULE FOR APP TESTING. -""" \ No newline at end of file +""" diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 20448eb..62fe415 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -9,34 +9,28 @@ class ModelTests(TestCase): def test_create_user_with_email_successful(self): """Test creating a new user with an email is successful.""" - email = 'test@test.com' - password = 'testpassword123' - user = get_user_model().objects.create_user( - email=email, - password=password - ) + email = "test@test.com" + password = "testpassword123" + user = get_user_model().objects.create_user(email=email, password=password) self.assertEqual(user.email, email) self.assertTrue(user.check_password(password)) def test_new_user_email_normalized(self): """Test the email for a new uer is normalized.""" - email = 'test@TEST.com' - user = get_user_model().objects.create_user(email, 'testpassword123') + email = "test@TEST.com" + user = get_user_model().objects.create_user(email, "testpassword123") self.assertEqual(user.email, email.lower()) def test_new_user_invalid_email(self): """Test creating user with no email raises an error.""" with self.assertRaises(ValueError): - get_user_model().objects.create_user(None, 'test123') + get_user_model().objects.create_user(None, "test123") def test_create_new_superuser(self): """Test can create superuser.""" - user = get_user_model().objects.create_superuser( - 'test@TEST.com', - 'test123' - ) + user = get_user_model().objects.create_superuser("test@TEST.com", "test123") self.assertTrue(user.is_superuser) self.assertTrue(user.is_staff) diff --git a/app/manage.py b/app/manage.py index 4931389..1a64b14 100755 --- a/app/manage.py +++ b/app/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/e_comm/__init__.py b/e_comm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/e_comm/asgi.py b/e_comm/asgi.py new file mode 100644 index 0000000..57f5695 --- /dev/null +++ b/e_comm/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for e_comm 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.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "e_comm.settings") + +application = get_asgi_application() diff --git a/e_comm/settings.py b/e_comm/settings.py new file mode 100644 index 0000000..28f15b2 --- /dev/null +++ b/e_comm/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for e_comm project. + +Generated by 'django-admin startproject' using Django 4.2.11. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-qp!r3d@sil*q(e%_7!n$8c!scoqalz^cjvozr36t*sgtpnsunx" + +# SECURITY WARNING: don't run with debug turned on in production! +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", +] + +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 = "e_comm.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 = "e_comm.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/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.2/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.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/e_comm/urls.py b/e_comm/urls.py new file mode 100644 index 0000000..d7210f7 --- /dev/null +++ b/e_comm/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for e_comm project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/e_comm/wsgi.py b/e_comm/wsgi.py new file mode 100644 index 0000000..125e9af --- /dev/null +++ b/e_comm/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for e_comm 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.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "e_comm.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..225a9de --- /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", "e_comm.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/poetry.lock b/poetry.lock new file mode 100644 index 0000000..36f9308 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,479 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "black" +version = "24.4.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436"}, + {file = "black-24.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf"}, + {file = "black-24.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad"}, + {file = "black-24.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb"}, + {file = "black-24.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8"}, + {file = "black-24.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745"}, + {file = "black-24.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070"}, + {file = "black-24.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397"}, + {file = "black-24.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"}, + {file = "black-24.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33"}, + {file = "black-24.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965"}, + {file = "black-24.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd"}, + {file = "black-24.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1"}, + {file = "black-24.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8"}, + {file = "black-24.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d"}, + {file = "black-24.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3"}, + {file = "black-24.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665"}, + {file = "black-24.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6"}, + {file = "black-24.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e"}, + {file = "black-24.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702"}, + {file = "black-24.4.0-py3-none-any.whl", hash = "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e"}, + {file = "black-24.4.0.tar.gz", hash = "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "django" +version = "4.2.11" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.11-py3-none-any.whl", hash = "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"}, + {file = "Django-4.2.11.tar.gz", hash = "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-cors-headers" +version = "4.3.1" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django-cors-headers-4.3.1.tar.gz", hash = "sha256:0bf65ef45e606aff1994d35503e6b677c0b26cafff6506f8fd7187f3be840207"}, + {file = "django_cors_headers-4.3.1-py3-none-any.whl", hash = "sha256:0b1fd19297e37417fc9f835d39e45c8c642938ddba1acce0c1753d3edef04f36"}, +] + +[package.dependencies] +asgiref = ">=3.6" +Django = ">=3.2" + +[[package]] +name = "djangorestframework" +version = "3.15.1" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.6" +files = [ + {file = "djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6"}, + {file = "djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1"}, +] + +[package.dependencies] +django = ">=3.0" + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.3.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, +] + +[package.dependencies] +django = ">=3.2" +djangorestframework = ">=3.12" +pyjwt = ">=1.7.1,<3" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["flake8", "isort", "pep8"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + +[[package]] +name = "flake8" +version = "7.0.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, + {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.2.0,<3.3.0" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, +] + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pyjwt" +version = "2.8.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pytest" +version = "8.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.8.0" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, + {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "sqlparse" +version = "0.5.1" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, + {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "098f813778a97073a27ec923701042a8c784a2e394e3f63fcb3cd49d7d4ecb6f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dfa50a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.poetry] +name = "backend-test-" +version = "0.1.0" +description = "Eccommerce backend web services" +authors = ["Ayobami Alaran A. "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.12" +Django = "4.2.11" +django-cors-headers = "4.3.1" +djangorestframework = "3.15.1" +djangorestframework-simplejwt = "5.3.1" +python-dotenv = "1.0.1" +psycopg2-binary = "2.9.9" + + +[tool.poetry.dev-dependencies] +black = "24.4.0" +flake8 = "7.0.0" +pytest = "8.1.1" +pytest-django = "4.8.0" +pytest-mock = "3.14.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" From 16b4a8e90a4b719569519df89757d50eacf42693 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 09:45:56 +0100 Subject: [PATCH 02/24] CHORE[Add third-party dependencies to django installed apps] --- Makefile | 41 +++++++++++++++++++++++++++++++++++++++++ e_comm/settings.py | 7 ++++++- requirements.txt | 30 ++++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f73bc15 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: run +run: + @python manage.py runserver $(port) + +install: + @pip install $(package) + +csu: + @python manage.py createsuperuser + +mms: + @python manage.py makemigrations + +migrate: + @python manage.py migrate + +shell: + @python manage.py shell + +dbshell: + @python manage.py dbshell + +test: + @pytest + +clean: + @Get-ChildItem -Path . -Recurse -Directory -Filter '__pycache__' | ForEach-Object { Remove-Item -Recurse -Force $_.FullName } + + + +help: + @echo "Usage: make [command]" + @echo "Available commands" + @echo "run - run django server" + @echo "install - install python package" + @echo "csu - createsuperuser" + @echo "mms - makemigrations" + @echo "migrate - migrate migrations" + @echo "shell - Open shell" + @echo "dbshell - Open dbshell" + @echo "test - run test" diff --git a/e_comm/settings.py b/e_comm/settings.py index 28f15b2..5b55e50 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -25,7 +25,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["*"] # Application definition @@ -37,6 +37,10 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # third-party apps + "rest_framework", + "corsheaders", + # local apps ] MIDDLEWARE = [ @@ -47,6 +51,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", ] ROOT_URLCONF = "e_comm.urls" diff --git a/requirements.txt b/requirements.txt index 843358c..b967544 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,26 @@ -Django>=3.0,<4.0.2 -djangorestframework==3.13.1 - -# flake8>=3.6.0,<3.7.0 \ No newline at end of file +asgiref==3.8.1 +black==24.4.0 +click==8.1.7 +colorama==0.4.6 +Django==4.2.11 +django-cors-headers==4.3.1 +djangorestframework==3.15.1 +djangorestframework-simplejwt==5.3.1 +flake8==7.0.0 +iniconfig==2.0.0 +mccabe==0.7.0 +mypy-extensions==1.0.0 +packaging==24.1 +pathspec==0.12.1 +platformdirs==4.2.2 +pluggy==1.5.0 +psycopg2-binary==2.9.9 +pycodestyle==2.11.1 +pyflakes==3.2.0 +PyJWT==2.8.0 +pytest==8.1.1 +pytest-django==4.8.0 +pytest-mock==3.14.0 +python-dotenv==1.0.1 +sqlparse==0.5.1 +tzdata==2024.1 From 5572551f47dd96899bac35d0d6581d8b243a9619 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 10:05:31 +0100 Subject: [PATCH 03/24] CHORE[Set up logging] --- .env.sample | 1 + e_comm/settings.py | 71 ++++++++++++++++++++++++++++++++++++++++++++-- utils/__init__.py | 0 utils/utils.py | 17 +++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .env.sample create mode 100644 utils/__init__.py create mode 100644 utils/utils.py diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..77bf4c3 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +SECRET_KEY= \ No newline at end of file diff --git a/e_comm/settings.py b/e_comm/settings.py index 5b55e50..7fea8cb 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -11,6 +11,13 @@ """ from pathlib import Path +import logging +import logging.config +from typing import Dict +from django.utils.log import DEFAULT_LOGGING +from datetime import timedelta +from utils.utils import get_env + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,7 +27,7 @@ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-qp!r3d@sil*q(e%_7!n$8c!scoqalz^cjvozr36t*sgtpnsunx" +SECRET_KEY = get_env("SECRET_KEY", "secret") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -110,19 +117,77 @@ LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = "Africa/Lagos" USE_I18N = True -USE_TZ = True +USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "static" + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +logger = logging.getLogger(__name__) + +LOG_LEVEL = "INFO" + +try: + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + }, + "file": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + }, + "django.server": DEFAULT_LOGGING["formatters"]["django.server"], + }, + "handlers": { + "console": { + "level": LOG_LEVEL, + "class": "logging.StreamHandler", + "formatter": "console", + }, + "file": { + "level": LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "formatter": "file", + "filename": BASE_DIR / "logs" / "django.log", + "maxBytes": 1024 * 1024 * 10, + "backupCount": 5, + }, + "django.server": DEFAULT_LOGGING["handlers"]["django.server"], + }, + "loggers": { + "": { + "level": LOG_LEVEL, + "handlers": ["console", "file"], + "propagate": False, + }, + "apps": { + "level": LOG_LEVEL, + "handlers": ["console", "file"], + "propagate": False, + }, + }, + "django.server": DEFAULT_LOGGING["loggers"]["django.server"], + } + ) + +except Exception: + pass diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/utils.py b/utils/utils.py new file mode 100644 index 0000000..28672b8 --- /dev/null +++ b/utils/utils.py @@ -0,0 +1,17 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +def get_env(key: str, fallback: str) -> str: + """get environment variable value from .env + + Args: + key (str): variable key + fallback (str): fallback value if none + + Returns: + str: value of environment variable + """ + return os.getenv(key, fallback) From 1c1ed2e58bc07b41013c081a250dbebe679a4ecf Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 10:16:04 +0100 Subject: [PATCH 04/24] CHORE[Set up postgres db] --- .gitignore | 1 + e_comm/settings.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b6e4761..1401d88 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ coverage.xml local_settings.py db.sqlite3 db.sqlite3-journal +migrations/ # Flask stuff: instance/ diff --git a/e_comm/settings.py b/e_comm/settings.py index 7fea8cb..aac592f 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -87,8 +87,12 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.postgresql", + "NAME": get_env("DB_NAME", "e-comm"), + "USER": get_env("DB_USER", "e-comm"), + "PASSWORD": get_env("DB_PASSWD", "password"), + "HOST": get_env("DB_HOST", "localhost"), + "PORT": int(get_env("DB_PORT", "5432")), } } From 60e729f0766c5c38caa179ca2ecb0787f8f7907d Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 10:33:32 +0100 Subject: [PATCH 05/24] TEST[Set pytest for testing&test get_env util method] --- e_comm/test_settings.py | 10 ++++++++++ pytest.ini | 4 ++++ utils/test_utils.py | 10 ++++++++++ 3 files changed, 24 insertions(+) create mode 100644 e_comm/test_settings.py create mode 100644 pytest.ini create mode 100644 utils/test_utils.py diff --git a/e_comm/test_settings.py b/e_comm/test_settings.py new file mode 100644 index 0000000..9d2d132 --- /dev/null +++ b/e_comm/test_settings.py @@ -0,0 +1,10 @@ +from .settings import * + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + +MIGRATION_MODULES = {app: None for app in INSTALLED_APPS} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..35c69cd --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = e_comm.test_settings +python_files = tests.py test_*.py *_test.py *.py +norecursedirs = app diff --git a/utils/test_utils.py b/utils/test_utils.py new file mode 100644 index 0000000..1cb55ee --- /dev/null +++ b/utils/test_utils.py @@ -0,0 +1,10 @@ +import pytest + + +def test_get_env(): + from utils.utils import get_env + + assert get_env("TEST_KEY", "default_value") == "test_value" + assert get_env("NON_EXISTENT_KEY", "default_value") == "default_value" + val = get_env("TEST_KEY", "default_value") + assert isinstance(val, str) From f46ce719b2f608efe8d3b3e60c38f59a4648ee44 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 10:40:07 +0100 Subject: [PATCH 06/24] CHORE[Add rest_framework settings&jwt settings] --- e_comm/settings.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/e_comm/settings.py b/e_comm/settings.py index aac592f..1df7704 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -13,7 +13,6 @@ from pathlib import Path import logging import logging.config -from typing import Dict from django.utils.log import DEFAULT_LOGGING from datetime import timedelta from utils.utils import get_env @@ -95,7 +94,30 @@ "PORT": int(get_env("DB_PORT", "5432")), } } - +# restframework settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 15, +} +# cors settings +CORS_ALLOW_ALL_ORIGINS = True + +# jwt settings +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=1440), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, +} # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators From 79020f39b7d83f6122acccac8b74d49d93d1900a Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 11:12:17 +0100 Subject: [PATCH 07/24] FEAT[Add user model] --- e_comm/settings.py | 3 ++ poetry.lock | 31 +++++++++++++++- pyproject.toml | 2 + users/__init__.py | 0 users/admin.py | 3 ++ users/apps.py | 6 +++ users/models.py | 92 ++++++++++++++++++++++++++++++++++++++++++++++ users/tests.py | 3 ++ users/views.py | 3 ++ 9 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/models.py create mode 100644 users/tests.py create mode 100644 users/views.py diff --git a/e_comm/settings.py b/e_comm/settings.py index 1df7704..44b3088 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -46,7 +46,9 @@ # third-party apps "rest_framework", "corsheaders", + "phonenumber_field", # local apps + "users", ] MIDDLEWARE = [ @@ -80,6 +82,7 @@ WSGI_APPLICATION = "e_comm.wsgi.application" +AUTH_USER_MODEL = "users.User" # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases diff --git a/poetry.lock b/poetry.lock index 36f9308..998a733 100644 --- a/poetry.lock +++ b/poetry.lock @@ -118,6 +118,24 @@ files = [ asgiref = ">=3.6" Django = ">=3.2" +[[package]] +name = "django-phonenumber-field" +version = "7.3.0" +description = "An international phone number field for django models." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django-phonenumber-field-7.3.0.tar.gz", hash = "sha256:f9cdb3de085f99c249328293a3b93d4e5fa440c0c8e3b99eb0d0f54748629797"}, + {file = "django_phonenumber_field-7.3.0-py3-none-any.whl", hash = "sha256:bc6eaa49d1f9d870944f5280258db511e3a1ba5e2fbbed255488dceacae45d06"}, +] + +[package.dependencies] +Django = ">=3.2" + +[package.extras] +phonenumbers = ["phonenumbers (>=7.0.2)"] +phonenumberslite = ["phonenumberslite (>=7.0.2)"] + [[package]] name = "djangorestframework" version = "3.15.1" @@ -227,6 +245,17 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "phonenumbers" +version = "8.13.35" +description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." +optional = false +python-versions = "*" +files = [ + {file = "phonenumbers-8.13.35-py2.py3-none-any.whl", hash = "sha256:58286a8e617bd75f541e04313b28c36398be6d4443a778c85e9617a93c391310"}, + {file = "phonenumbers-8.13.35.tar.gz", hash = "sha256:64f061a967dcdae11e1c59f3688649e697b897110a33bb74d5a69c3e35321245"}, +] + [[package]] name = "platformdirs" version = "4.2.2" @@ -476,4 +505,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "098f813778a97073a27ec923701042a8c784a2e394e3f63fcb3cd49d7d4ecb6f" +content-hash = "dc6dac7437ef14ed741bf730e584d4b00b78c94435dc761ea2a382fd431ebd76" diff --git a/pyproject.toml b/pyproject.toml index dfa50a1..5f6bf14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ djangorestframework = "3.15.1" djangorestframework-simplejwt = "5.3.1" python-dotenv = "1.0.1" psycopg2-binary = "2.9.9" +django-phonenumber-field = "7.3.0" +phonenumbers = "8.13.35" [tool.poetry.dev-dependencies] diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..c1e0c6e --- /dev/null +++ b/users/models.py @@ -0,0 +1,92 @@ +from django.db import models +from django.core.validators import validate_email +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import AbstractUser, UserManager +from django.core.exceptions import ValidationError +import logging +from django.contrib.auth.models import PermissionsMixin +import traceback +from typing import Any +from django.db import transaction +from phonenumber_field.modelfields import PhoneNumberField + +logger = logging.getLogger(__name__) + + +class CustomUserManager(UserManager): + + # email validator static method + @staticmethod + def validate_email(email: str) -> None: + """ + This method validates the email address of a user. + """ + try: + validate_email(email) + except ValidationError: + logger.error(traceback.format_exc()) + raise ValidationError({"email": _("Please enter a valid email address.")}) + + # create user method + def create_user( + self, email: str, username: str, password: str = "", **extra_fields: Any + ) -> "User": + """ + This method creates a user with the specified email address, username, and password. + """ + if not email: + logger.error(traceback.format_exc()) + raise ValueError("Users must have an email address.") + if not username: + logger.error(traceback.format_exc()) + raise ValueError("Users must have a username.") + with transaction.atomic(): + user = self.model(email=email, username=username, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + +class User(AbstractUser, PermissionsMixin): + """ + This class maps to users table in database. + """ + + objects = CustomUserManager() + email = models.EmailField(verbose_name=_("Email address"), unique=True) + username = models.CharField( + verbose_name=_("Username"), + max_length=30, + unique=True, + help_text=_( + "Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only." + ), + ) + first_name = models.CharField( + verbose_name=_("First name"), max_length=30, null=True, blank=True + ) + last_name = models.CharField( + verbose_name=_("Last name"), max_length=30, null=True, blank=True + ) + phone_number = PhoneNumberField(blank=True, null=True) + + class Meta: + verbose_name = _("User") + verbose_name_plural = _("Users") + db_table = "users" + + def __str__(self) -> str: + return f"{self.username} - {self.email}" + + @property + def full_name(self) -> str: + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + else: + return f"{self.username}" + + @full_name.setter + def full_name(self, first_name: str, last_name: str) -> None: + self.first_name = first_name + self.last_name = last_name + self.save() diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/users/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 3ea1b6ce896a2a80ae7b34f9264d64126828392a Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 11:54:59 +0100 Subject: [PATCH 08/24] FEAT[Implement register endpoint] --- e_comm/urls.py | 3 +- users/admin.py | 9 +++++- users/serializers.py | 77 ++++++++++++++++++++++++++++++++++++++++++++ users/urls.py | 4 +++ users/views.py | 29 +++++++++++++++++ utils/exceptions.py | 18 +++++++++++ utils/response.py | 20 ++++++++++++ 7 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 users/serializers.py create mode 100644 users/urls.py create mode 100644 utils/exceptions.py create mode 100644 utils/response.py diff --git a/e_comm/urls.py b/e_comm/urls.py index d7210f7..5e2b13b 100644 --- a/e_comm/urls.py +++ b/e_comm/urls.py @@ -16,8 +16,9 @@ """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), + path("api/v1/", include("users.urls")), ] diff --git a/users/admin.py b/users/admin.py index 8c38f3f..01f260d 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,3 +1,10 @@ from django.contrib import admin +from .models import User -# Register your models here. + +class UserAdmin(admin.ModelAdmin): + list_display = ["username", "email"] + search_fields = ["username", "email"] + + +admin.site.register(User, UserAdmin) diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..bf27572 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,77 @@ +import re +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model + + +User = get_user_model() + + +class CreateUserSerializer(ModelSerializer): + email = serializers.EmailField(required=True) + password1 = serializers.CharField(write_only=True, required=True, min_length=8) + password2 = serializers.CharField(write_only=True, required=True, min_length=8) + + class Meta: + model = User + fields = ( + "id", + "email", + "username", + "password1", + "password2", + ) + + def validate_password1(self, password1): + """Validate user's inputed password on signup + + Args: + password1 (str): user's password + + Raises: + serializers.ValidationError: raise password requirements + + Returns: + str: returns the validated password + """ + # Regex pattern to match at least one digit, one uppercase letter, + # one lowercase letter, one special character, and length >= 8 + regex_pattern = ( + r"^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$" + ) + if not re.match(regex_pattern, password1): + raise serializers.ValidationError( + "Password must contain at least one digit, " + "one uppercase letter, one lowercase letter, " + "one special character, and length >= 8" + ) + return password1 + + def validate(self, data): + """Validate user's inputed data on signup""" + errors = {} + email = data.get("email") + username = data.get("username") + password1 = data.get("password1") + password2 = data.get("password2") + if User.objects.filter(email__iexact=email): + errors["error"] = _("The email address is already in use") + raise serializers.ValidationError(errors) + if User.objects.filter(username__iexact=username): + errors["error"] = _("Username is taken!") + raise serializers.ValidationError(errors) + if password1 != password2: + errors["error"] = _("The two password fields didn't match.") + raise serializers.ValidationError(errors) + return data + + def create(self, validated_data): + """Create a new user with the given validated data""" + email = validated_data.get("email") + password = validated_data.get("password1") + username = validated_data.get("username") + user = User.objects.create_user( + email=email, username=username, password=password + ) + return user diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..b92eca4 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,4 @@ +from django.urls import path +from .views import CreateUserAPIView + +urlpatterns = [path("register", CreateUserAPIView.as_view(), name="register")] diff --git a/users/views.py b/users/views.py index 91ea44a..3ba9c26 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,32 @@ from django.shortcuts import render +from users.serializers import CreateUserSerializer +from utils.response import service_response +from utils.exceptions import handle_internal_server_exception +from rest_framework.views import APIView # Create your views here. + + +class CreateUserAPIView(APIView): + """Create a new user""" + + serializer_class = CreateUserSerializer + + def post(self, request, *args, **kwargs): + """Register a new user post handler""" + try: + serializer: CreateUserSerializer = self.serializer_class(data=request.data) + + if serializer.is_valid(): + # save serializer + serializer.save() + return service_response( + status="success", + message="Registration Successful", + status_code=201, + ) + return service_response( + status="error", message=serializer.errors, status_code=400 + ) + except Exception: + return handle_internal_server_exception() diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..2eda3a4 --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,18 @@ +import logging +import traceback +from rest_framework.response import Response + +logger = logging.getLogger(__name__) + + +def handle_internal_server_exception() -> Response: + """Handles internal Server error exception + + Returns: + Response: Http response + """ + traceback.print_exc() + logger.error(traceback.format_exc()) + return Response( + {"status": "error", "message": "Don't Panic!, This is from us"}, status=500 + ) diff --git a/utils/response.py b/utils/response.py new file mode 100644 index 0000000..569297a --- /dev/null +++ b/utils/response.py @@ -0,0 +1,20 @@ +from rest_framework.response import Response + + +def service_response( + status: str = "success", data: dict = {}, message: str = "", status_code=200 +) -> Response: + """Api response utils functions + + Args: + status (str, optional): request status Defaults to "success". + data (dict, optional): response data. Defaults to None. + message (str, optional): response message. Defaults to None. + status (int, optional): response status code. Defaults to 200. + + Returns: + Response: Http response + """ + return Response( + {"status": status, "message": message, "data": data}, status=status_code + ) From 871c11d49ef65b8335cee6d3014187ede592c8d4 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 12:15:20 +0100 Subject: [PATCH 09/24] FEAT[Implement login feature] --- e_comm/urls.py | 3 +++ users/serializers.py | 33 +++++++++++++++++++++++++++++ users/urls.py | 7 +++++-- users/views.py | 50 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/e_comm/urls.py b/e_comm/urls.py index 5e2b13b..1b55631 100644 --- a/e_comm/urls.py +++ b/e_comm/urls.py @@ -18,7 +18,10 @@ from django.contrib import admin from django.urls import path, include +from users.views import RootPage + urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/", include("users.urls")), + path("", RootPage.as_view(), name="root"), ] diff --git a/users/serializers.py b/users/serializers.py index bf27572..5138087 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -75,3 +75,36 @@ def create(self, validated_data): email=email, username=username, password=password ) return user + + +class LoginSerializer(ModelSerializer): + email = serializers.EmailField(required=False) + username = serializers.CharField(label="Username", required=False) + password = serializers.CharField( + label="Password", + style={"input_type": "password"}, + trim_whitespace=False, + ) + + def validate(self, attrs): + email = attrs.get("email") + password = attrs.get("password") + username = attrs.get("username") + if email: + user = User.objects.filter(email__iexact=email).first() + if user and user.check_password(password): + attrs["user"] = user + return attrs + else: + raise serializers.ValidationError({"error": "Invalid credentials"}) + else: + user = User.objects.filter(username__iexact=username).first() + if user and user.check_password(password): + attrs["user"] = user + return attrs + else: + raise serializers.ValidationError({"error": "Invalid credentials"}) + + class Meta: + model = User + fields = ("email", "username", "password") diff --git a/users/urls.py b/users/urls.py index b92eca4..5492a67 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,4 +1,7 @@ from django.urls import path -from .views import CreateUserAPIView +from .views import CreateUserAPIView, LoginUserAPIView -urlpatterns = [path("register", CreateUserAPIView.as_view(), name="register")] +urlpatterns = [ + path("register", CreateUserAPIView.as_view(), name="register"), + path("login", LoginUserAPIView.as_view(), name="login"), +] diff --git a/users/views.py b/users/views.py index 3ba9c26..23e09d0 100644 --- a/users/views.py +++ b/users/views.py @@ -1,12 +1,18 @@ from django.shortcuts import render -from users.serializers import CreateUserSerializer +from users.serializers import CreateUserSerializer, LoginSerializer from utils.response import service_response from utils.exceptions import handle_internal_server_exception from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth import get_user_model +from typing import Any # Create your views here. +User = get_user_model() + + class CreateUserAPIView(APIView): """Create a new user""" @@ -30,3 +36,45 @@ def post(self, request, *args, **kwargs): ) except Exception: return handle_internal_server_exception() + + +class RootPage(APIView): + def get(self, request, format=None): + return service_response( + status="success", message="Great, Welcome all good!", status_code=200 + ) + + +class LoginUserAPIView(APIView): + """Generates user access token and refresh token""" + + serializer_class = LoginSerializer + + def post(self, request, *args, **kwargs): + """Login user post handler""" + try: + serializer: LoginSerializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + # get the user from the serializer validated data + user: Any = serializer.validated_data.get("user") + # get the token + tokens: Any = RefreshToken.for_user(user) + access_token: str = str(tokens.access_token) + refresh_token: str = str(tokens) + token_data: dict = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": tokens.access_token.lifetime.total_seconds(), + } + return service_response( + status="success", + data=token_data, + message="Login Successful", + status_code=200, + ) + return service_response( + status="error", message=serializer.errors["error"][0], status_code=400 + ) + + except Exception: + return handle_internal_server_exception() From 88c92e2f5f55937725d8762dfd89bba0289eec22 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sun, 21 Jul 2024 12:47:58 +0100 Subject: [PATCH 10/24] TEST[Add test for login and register endpoint] --- users/test_views.py | 152 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 users/test_views.py diff --git a/users/test_views.py b/users/test_views.py new file mode 100644 index 0000000..21a5837 --- /dev/null +++ b/users/test_views.py @@ -0,0 +1,152 @@ +import pytest +from rest_framework.test import APIClient +from rest_framework import status +from users.views import CreateUserAPIView +from users.serializers import CreateUserSerializer + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def url(): + return "/api/v1/register" + + +@pytest.fixture +def serializer_class(): + return CreateUserSerializer + + +@pytest.mark.django_db +def test_create_user_with_invalid_data_returns_400(api_client, url, serializer_class): + # Test with invalid data + invalid_data = { + "username": "", + "email": "invalid_email", + "password": "short", + } + response = api_client.post(url, invalid_data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_user_with_missing_username_returns_400( + api_client, url, serializer_class +): + # Test with missing username + data = { + "email": "test@example.com", + "password": "testpassword", + } + response = api_client.post(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_user_with_missing_email_returns_400(api_client, url, serializer_class): + # Test with missing email + data = { + "username": "testuser", + "password": "testpassword", + } + response = api_client.post(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_user_with_missing_password_returns_400( + api_client, url, serializer_class +): + # Test with missing password + data = { + "username": "testuser", + "email": "test@example.com", + } + response = api_client.post(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_user_with_existing_email_returns_400(api_client, url, serializer_class): + # Test with existing email + existing_user = { + "username": "existinguser", + "email": "existing@example.com", + "password1": "Testpassword@123", + "password2": "Testpassword@123", + } + api_client.post(url, existing_user, format="json") # Create existing user + + data = { + "username": "newuser", + "email": "existing@example.com", + "password1": "Testpassword@123", + "password2": "Testpassword@123", + } + response = api_client.post(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_user_success_returns_201(api_client, url): + # Test with valid data + data = { + "username": "testuser1", + "email": "test2@example.com", + "password1": "Testpassword@123", + "password2": "Testpassword@123", + } + response = api_client.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + +# Test for login endpoint + + +@pytest.fixture +def login_url(): + return "/api/v1/login" + + +@pytest.mark.django_db +def test_login_user_valid_data_returns_200(api_client, login_url, url): + data = { + "username": "testuser", + "email": "test@example.com", + "password1": "Testpassword@123", + "password2": "Testpassword@123", + } + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + login_data = { + "email": "test@example.com", + "password": "Testpassword@123", + } + login_response = api_client.post(login_url, login_data, format="json") + assert login_response.status_code == status.HTTP_200_OK + assert login_response.data["data"]["access_token"] is not None + + +@pytest.mark.django_db +def test_login_user_bad_data_returns_400(api_client, login_url, url): + """Test user login endpoint with invalid credentials""" + data = { + "username": "testuser", + "email": "test@example.com", + "password1": "Testpassword@123", + "password2": "Testpassword@123", + } + api_client.post(url, data, format="json") + + login_data = { + "email": "test@example.com", + "password": "Testpassword@", + } + login_response = api_client.post(login_url, login_data, format="json") + assert login_response.status_code == status.HTTP_400_BAD_REQUEST + assert "Invalid credentials" in login_response.data["message"] From 2c06b2520cd646b3ff77f385d757a68c987665f6 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 08:06:08 +0100 Subject: [PATCH 11/24] FEAT[Add products&category models] --- e_comm/settings.py | 1 + products/__init__.py | 0 products/admin.py | 3 +++ products/apps.py | 6 ++++++ products/models.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ products/tests.py | 3 +++ products/views.py | 3 +++ 7 files changed, 61 insertions(+) create mode 100644 products/__init__.py create mode 100644 products/admin.py create mode 100644 products/apps.py create mode 100644 products/models.py create mode 100644 products/tests.py create mode 100644 products/views.py diff --git a/e_comm/settings.py b/e_comm/settings.py index 44b3088..bcfdec9 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -49,6 +49,7 @@ "phonenumber_field", # local apps "users", + "products", ] MIDDLEWARE = [ diff --git a/products/__init__.py b/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/products/admin.py b/products/admin.py new file mode 100644 index 0000000..8c38f3f --- /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 0000000..7b0ca83 --- /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/models.py b/products/models.py new file mode 100644 index 0000000..15fae4e --- /dev/null +++ b/products/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +# Create your models here. + + +class Category(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Product Category" + verbose_name_plural = "Product Categories" + + +class Product(models.Model): + name = models.CharField(max_length=100) + price = models.DecimalField(max_digits=10, decimal_places=2) + discount_price = models.DecimalField( + max_digits=10, decimal_places=2, null=True, blank=True + ) + description = models.TextField( + null=True, blank=True, help_text=_("Package Description") + ) + category = models.ForeignKey( + Category, + on_delete=models.SET_NULL, + related_name="products", + blank=True, + null=True, + ) + available_quantity = models.IntegerField(default=0) + date_created = models.DateTimeField(default=timezone.now) + date_updated = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f"{self.name} {self.price}" + + class Meta: + verbose_name = "Product" + verbose_name_plural = "Products" + db_table = "products" diff --git a/products/tests.py b/products/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/products/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/products/views.py b/products/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/products/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 45f682785a884cc7c01f5bcb61f451ccc9b66963 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 08:57:50 +0100 Subject: [PATCH 12/24] FEAT[Implement product list endpoint] --- e_comm/urls.py | 1 + products/admin.py | 11 +++++++++ products/serializers.py | 17 +++++++++++++ products/urls.py | 12 +++++++++ products/views.py | 54 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 products/serializers.py create mode 100644 products/urls.py diff --git a/e_comm/urls.py b/e_comm/urls.py index 1b55631..a758d78 100644 --- a/e_comm/urls.py +++ b/e_comm/urls.py @@ -23,5 +23,6 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/", include("users.urls")), + path("api/v1/", include("products.urls")), path("", RootPage.as_view(), name="root"), ] diff --git a/products/admin.py b/products/admin.py index 8c38f3f..51d2119 100644 --- a/products/admin.py +++ b/products/admin.py @@ -1,3 +1,14 @@ from django.contrib import admin +from products.models import Category, Product + # Register your models here. + + +class ProductAdmin(admin.ModelAdmin): + list_display = ["name", "price", "available_quantity"] + search_fields = ["name", "price"] + + +admin.site.register(Product, ProductAdmin) +admin.site.register(Category) diff --git a/products/serializers.py b/products/serializers.py new file mode 100644 index 0000000..d8c6cb0 --- /dev/null +++ b/products/serializers.py @@ -0,0 +1,17 @@ +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers + +from products.models import Product + + +class ProductSerializer(ModelSerializer): + class Meta: + model = Product + fields = ( + "id", + "name", + "price", + "description", + "discount_price", + "available_quantity", + ) diff --git a/products/urls.py b/products/urls.py new file mode 100644 index 0000000..b798c5c --- /dev/null +++ b/products/urls.py @@ -0,0 +1,12 @@ +from rest_framework import routers +from django.urls import path, include +from .views import ProductViewSet + +router = routers.DefaultRouter() + +router.register(r"products", ProductViewSet, basename="products") + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/products/views.py b/products/views.py index 91ea44a..d02815e 100644 --- a/products/views.py +++ b/products/views.py @@ -1,3 +1,57 @@ from django.shortcuts import render +from rest_framework import viewsets +from django.db.models import Q +from products.models import Product +from products.serializers import ProductSerializer +from utils.response import service_response +from utils.exceptions import handle_internal_server_exception +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger # Create your views here. + + +class ProductViewSet(viewsets.ModelViewSet): + """Product REST Viewset""" + + queryset = Product.objects.all() + serializer_class = ProductSerializer + + def list(self, request, *args, **kwargs): + """List all products""" + try: + # TODO: Implement search for products + search_query = request.query_params.get("search", None) + category = request.query_params.get("category", None) + if search_query: + products = Product.objects.filter( + Q(name__icontains=search_query) + | Q(description__icontains=search_query) + ) + # TODO: Filter by category + elif category: + products = Product.objects.filter(category=int(category)) + else: + products = Product.objects.all() + # TODO: Implement pagination + + paginator = Paginator(products, 30) + page = request.query_params.get("page", 1) + try: + products_page = paginator.page(page) + except PageNotAnInteger: + products_page = paginator.page(1) + except EmptyPage: + products_page = paginator.page(paginator.num_pages) + + total_pages = paginator.num_pages + serialized_products = self.get_serializer(products_page, many=True) + data = { + "products": serialized_products.data, + "total_pages": total_pages, + "current_page": int(page), + } + return service_response( + status="success", data=data, message="Fetch Successful", status_code=200 + ) + except Exception: + return handle_internal_server_exception() From 329e4ab1ad1585908e59cad562e60460252cc20d Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 09:51:18 +0100 Subject: [PATCH 13/24] FEAT[Implement products basic crud endpoints] --- products/serializers.py | 26 +++++++++++++- products/urls.py | 2 +- products/views.py | 80 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/products/serializers.py b/products/serializers.py index d8c6cb0..c6c7916 100644 --- a/products/serializers.py +++ b/products/serializers.py @@ -1,10 +1,33 @@ from rest_framework.serializers import ModelSerializer from rest_framework import serializers -from products.models import Product +from products.models import Product, Category class ProductSerializer(ModelSerializer): + self_url = serializers.SerializerMethodField() + + class Meta: + model = Product + fields = ( + "id", + "name", + "price", + "description", + "discount_price", + "available_quantity", + "self_url", + ) + + def get_self_url(self, obj): + request = self.context.get("request") + base_url = request.build_absolute_uri("/")[:-1] + return f"{base_url}/products/{obj.pk}/" + + +class CreateProductSerializer(ModelSerializer): + category = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all()) + class Meta: model = Product fields = ( @@ -14,4 +37,5 @@ class Meta: "description", "discount_price", "available_quantity", + "category", ) diff --git a/products/urls.py b/products/urls.py index b798c5c..75afb71 100644 --- a/products/urls.py +++ b/products/urls.py @@ -2,8 +2,8 @@ from django.urls import path, include from .views import ProductViewSet -router = routers.DefaultRouter() +router = routers.SimpleRouter(trailing_slash=False) router.register(r"products", ProductViewSet, basename="products") diff --git a/products/views.py b/products/views.py index d02815e..243e6ae 100644 --- a/products/views.py +++ b/products/views.py @@ -2,10 +2,12 @@ from rest_framework import viewsets from django.db.models import Q from products.models import Product -from products.serializers import ProductSerializer +from products.serializers import ProductSerializer, CreateProductSerializer from utils.response import service_response from utils.exceptions import handle_internal_server_exception from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import MethodNotAllowed # Create your views here. @@ -55,3 +57,79 @@ def list(self, request, *args, **kwargs): ) except Exception: return handle_internal_server_exception() + + def retrieve(self, request, *args, **kwargs): + """Retrieve a single product""" + try: + instance = self.get_object() + serializer = self.get_serializer(instance) + return service_response( + status="success", + data=serializer.data, + message="Fetch Successful", + status_code=200, + ) + except Exception: + return handle_internal_server_exception() + + def create(self, request, *args, **kwargs): + """Create a new product""" + try: + serializer = CreateProductSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return service_response( + status="success", + data=serializer.data, + message="Product created successfully", + status_code=201, + ) + return service_response( + status="error", message=serializer.errors, status_code=400 + ) + except Exception: + return handle_internal_server_exception() + + def get_permissions(self): + # TODO: remove the create from this + unsecure_actions = ["list", "retrieve", "create"] + if self.action in unsecure_actions: + return [AllowAny()] + return [IsAuthenticated()] + + def update(self, request, *args, **kwargs): + """Update an existing product""" + try: + instance = self.get_object() + serializer = CreateProductSerializer(instance=instance, data=request.data) + if serializer.is_valid(): + serializer.save() + return service_response( + status="success", + data=serializer.data, + message="Product updated successfully", + status_code=200, + ) + return service_response( + status="error", message=serializer.errors, status_code=400 + ) + except Exception: + return handle_internal_server_exception() + + def destroy(self, request, *args, **kwargs): + """Delete an existing product""" + try: + instance = self.get_object() + instance.delete() + return service_response( + status="success", + data=None, + message="Product deleted successfully", + status_code=204, + ) + except Exception: + return handle_internal_server_exception() + + # restrict patch method + def patch(self, request, *args, **kwargs): + raise MethodNotAllowed(request.method) From 333daa0b0e1739441e94de33bbd9e32b7a6b0d52 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 10:32:44 +0100 Subject: [PATCH 14/24] FEAT[Add order&orderpoduct models] --- constants/__init__.py | 0 constants/constant.py | 6 +++++ e_comm/settings.py | 1 + orders/__init__.py | 0 orders/admin.py | 3 +++ orders/apps.py | 6 +++++ orders/models.py | 55 +++++++++++++++++++++++++++++++++++++++++++ orders/tests.py | 3 +++ orders/views.py | 3 +++ utils/utils.py | 8 +++++++ 10 files changed, 85 insertions(+) create mode 100644 constants/__init__.py create mode 100644 constants/constant.py create mode 100644 orders/__init__.py create mode 100644 orders/admin.py create mode 100644 orders/apps.py create mode 100644 orders/models.py create mode 100644 orders/tests.py create mode 100644 orders/views.py diff --git a/constants/__init__.py b/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/constants/constant.py b/constants/constant.py new file mode 100644 index 0000000..66bfaeb --- /dev/null +++ b/constants/constant.py @@ -0,0 +1,6 @@ +order_status = ( + ("Pending", "Pending"), + ("On Delivery", "On Delivery"), + ("Delivered", "Delivered"), + ("Cancelled", "Cancelled"), +) diff --git a/e_comm/settings.py b/e_comm/settings.py index bcfdec9..9bd4d72 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -50,6 +50,7 @@ # local apps "users", "products", + "orders", ] MIDDLEWARE = [ diff --git a/orders/__init__.py b/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/admin.py b/orders/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/orders/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/orders/apps.py b/orders/apps.py new file mode 100644 index 0000000..2e5018e --- /dev/null +++ b/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "orders" diff --git a/orders/models.py b/orders/models.py new file mode 100644 index 0000000..6943e57 --- /dev/null +++ b/orders/models.py @@ -0,0 +1,55 @@ +from django.db import models +from django.contrib.auth import get_user_model +from .models import Product +from django.utils import timezone +from constants.constant import order_status +from utils.utils import generate_ref + + +User = get_user_model() + + +class OrderProduct(models.Model): + product = models.ForeignKey( + Product, related_name="order_products", on_delete=models.CASCADE + ) + quantity = models.PositiveIntegerField(default=1) + subtotal = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"{self.product.name} x {self.quantity}" + + class Meta: + verbose_name = "Order Product" + verbose_name_plural = "Order Products" + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + self.subtotal = self.quantity * self.product.price + super().save(*args, **kwargs) + + +class Order(models.Model): + user = models.ForeignKey( + User, related_name="orders", on_delete=models.CASCADE, editable=False + ) + products = models.ManyToManyField(Product, through="OrderProduct") + status = models.CharField(max_length=50, choices=order_status, default="Pending") + total_amount = models.FloatField(null=True, blank=True) + order_ref = models.CharField(max_length=100, blank=True, null=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f"Order #{self.order_ref} - {self.user.username}" + + class Meta: + verbose_name = "Order" + verbose_name_plural = "Orders" + ordering = ["-created_at"] + db_table = "orders" + + def save(self, *args, **kwargs): + self.order_ref = generate_ref() + self.total_amount = sum(p.subtotal for p in self.products.all()) + super().save(*args, **kwargs) diff --git a/orders/tests.py b/orders/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/orders/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/orders/views.py b/orders/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/orders/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/utils/utils.py b/utils/utils.py index 28672b8..bf036e4 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -1,5 +1,7 @@ import os from dotenv import load_dotenv +import random +import string load_dotenv() @@ -15,3 +17,9 @@ def get_env(key: str, fallback: str) -> str: str: value of environment variable """ return os.getenv(key, fallback) + + +def generate_ref() -> str: + """generate unique reference code""" + code = "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + return code.upper() From 2820f5e00a6bf9b7a2b1e937d70944c4e70d6ae6 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 11:21:10 +0100 Subject: [PATCH 15/24] FEAT[Implement place order endpoint] --- e_comm/urls.py | 1 + orders/admin.py | 20 ++++++++++++++++++ orders/models.py | 48 ++++++++++++++++++++++--------------------- orders/serializers.py | 33 +++++++++++++++++++++++++++++ orders/urls.py | 4 ++++ orders/views.py | 33 ++++++++++++++++++++++++++++- 6 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 orders/serializers.py create mode 100644 orders/urls.py diff --git a/e_comm/urls.py b/e_comm/urls.py index a758d78..ca3076f 100644 --- a/e_comm/urls.py +++ b/e_comm/urls.py @@ -24,5 +24,6 @@ path("admin/", admin.site.urls), path("api/v1/", include("users.urls")), path("api/v1/", include("products.urls")), + path("api/v1/", include("orders.urls")), path("", RootPage.as_view(), name="root"), ] diff --git a/orders/admin.py b/orders/admin.py index 8c38f3f..1457a99 100644 --- a/orders/admin.py +++ b/orders/admin.py @@ -1,3 +1,23 @@ from django.contrib import admin +from orders.models import Order + # Register your models here. + + +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "user", + "total_amount", + "status", + "created_at", + "order_ref", + ) + list_filter = ["status"] + search_fields = ( + "user__username", + "order_ref", + ) + + +admin.site.register(Order, OrderAdmin) diff --git a/orders/models.py b/orders/models.py index 6943e57..8ba3d49 100644 --- a/orders/models.py +++ b/orders/models.py @@ -1,6 +1,6 @@ from django.db import models from django.contrib.auth import get_user_model -from .models import Product +from products.models import Product from django.utils import timezone from constants.constant import order_status from utils.utils import generate_ref @@ -9,26 +9,6 @@ User = get_user_model() -class OrderProduct(models.Model): - product = models.ForeignKey( - Product, related_name="order_products", on_delete=models.CASCADE - ) - quantity = models.PositiveIntegerField(default=1) - subtotal = models.DecimalField(max_digits=10, decimal_places=2) - - def __str__(self): - return f"{self.product.name} x {self.quantity}" - - class Meta: - verbose_name = "Order Product" - verbose_name_plural = "Order Products" - ordering = ["-created_at"] - - def save(self, *args, **kwargs): - self.subtotal = self.quantity * self.product.price - super().save(*args, **kwargs) - - class Order(models.Model): user = models.ForeignKey( User, related_name="orders", on_delete=models.CASCADE, editable=False @@ -46,10 +26,32 @@ def __str__(self): class Meta: verbose_name = "Order" verbose_name_plural = "Orders" - ordering = ["-created_at"] db_table = "orders" def save(self, *args, **kwargs): self.order_ref = generate_ref() - self.total_amount = sum(p.subtotal for p in self.products.all()) + super().save(*args, **kwargs) + + def calculate_total_amount(self): + self.total_amount = sum(p.subtotal for p in self.order_products.all()) + self.save() + + +class OrderProduct(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE) + order = models.ForeignKey( + Order, on_delete=models.CASCADE, related_name="order_products" + ) + quantity = models.PositiveIntegerField(default=1) + subtotal = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"{self.product.name} x {self.quantity}" + + class Meta: + verbose_name = "Order Product" + verbose_name_plural = "Order Products" + + def save(self, *args, **kwargs): + self.subtotal = self.quantity * self.product.price super().save(*args, **kwargs) diff --git a/orders/serializers.py b/orders/serializers.py new file mode 100644 index 0000000..058b504 --- /dev/null +++ b/orders/serializers.py @@ -0,0 +1,33 @@ +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers +from .models import Order, OrderProduct +from products.models import Product + + +class OrderProductSerializer(serializers.ModelSerializer): + product_id = serializers.IntegerField() + + class Meta: + model = OrderProduct + fields = ["product_id", "quantity"] + + +class OrderSerializer(serializers.ModelSerializer): + products = OrderProductSerializer(many=True) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Order + fields = ["user", "products", "created_at", "status", "order_ref"] + + def create(self, validated_data): + products_data = validated_data.pop("products") + order = Order.objects.create(**validated_data) + print(products_data) + for product_data in products_data: + product = Product.objects.get(id=int(product_data["product_id"])) + OrderProduct.objects.create( + order=order, product=product, quantity=product_data["quantity"] + ) + order.calculate_total_amount() + return order diff --git a/orders/urls.py b/orders/urls.py new file mode 100644 index 0000000..61dd7c9 --- /dev/null +++ b/orders/urls.py @@ -0,0 +1,4 @@ +from django.urls import path +from .views import PlaceOrderAPIView + +urlpatterns = [path("orders/place", PlaceOrderAPIView.as_view(), name="place-order")] diff --git a/orders/views.py b/orders/views.py index 91ea44a..8af7d29 100644 --- a/orders/views.py +++ b/orders/views.py @@ -1,3 +1,34 @@ from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from orders.serializers import OrderSerializer +from utils.response import service_response +from utils.exceptions import handle_internal_server_exception -# Create your views here. + +class PlaceOrderAPIView(APIView): + """Place order api view""" + + permission_classes = [IsAuthenticated] + serializer_class = OrderSerializer + + def post(self, request, *args, **kwargs): + """Place order post handler""" + try: + serializer = self.serializer_class( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + serializer.save() + return service_response( + status="success", + message="Order placed successfully", + status_code=201, + ) + return service_response( + status="error", + message=serializer.errors, + status_code=400, + ) + except Exception: + return handle_internal_server_exception() From ff2f5bb17476e4bbd3395b6723cb7d27346cb23f Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 12:12:24 +0100 Subject: [PATCH 16/24] FEAT[Implement order history api endpoint] --- orders/models.py | 2 +- orders/serializers.py | 31 +++++++++++++++++++++++++++++++ orders/urls.py | 7 +++++-- orders/views.py | 24 +++++++++++++++++++++++- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/orders/models.py b/orders/models.py index 8ba3d49..374044e 100644 --- a/orders/models.py +++ b/orders/models.py @@ -38,7 +38,7 @@ def calculate_total_amount(self): class OrderProduct(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="product_orders") order = models.ForeignKey( Order, on_delete=models.CASCADE, related_name="order_products" ) diff --git a/orders/serializers.py b/orders/serializers.py index 058b504..4eac6f7 100644 --- a/orders/serializers.py +++ b/orders/serializers.py @@ -31,3 +31,34 @@ def create(self, validated_data): ) order.calculate_total_amount() return order + + +class OrderProductListSerializer(ModelSerializer): + product_id = serializers.IntegerField(source="product.id") + product_name = serializers.CharField(source="product.name", read_only=True) + product_price = serializers.DecimalField( + source="product.price", read_only=True, max_digits=10, decimal_places=2 + ) + # quantity = serializers.IntegerField() + subtotal = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) + + class Meta: + model = OrderProduct + fields = ["product_id", "product_name", "product_price", "quantity", "subtotal"] + read_only_fields = ["subtotal"] + + +class OrderListSerializer(ModelSerializer): + order_products = OrderProductListSerializer(many=True) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Order + fields = [ + "user", + "order_products", + "created_at", + "status", + "order_ref", + "total_amount", + ] diff --git a/orders/urls.py b/orders/urls.py index 61dd7c9..8fdc20f 100644 --- a/orders/urls.py +++ b/orders/urls.py @@ -1,4 +1,7 @@ from django.urls import path -from .views import PlaceOrderAPIView +from .views import OrderListAPIView, PlaceOrderAPIView -urlpatterns = [path("orders/place", PlaceOrderAPIView.as_view(), name="place-order")] +urlpatterns = [ + path("orders/place", PlaceOrderAPIView.as_view(), name="place-order"), + path("orders", OrderListAPIView.as_view(), name="order-history"), +] diff --git a/orders/views.py b/orders/views.py index 8af7d29..833c88b 100644 --- a/orders/views.py +++ b/orders/views.py @@ -1,9 +1,10 @@ from django.shortcuts import render from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated -from orders.serializers import OrderSerializer +from orders.serializers import OrderListSerializer, OrderSerializer from utils.response import service_response from utils.exceptions import handle_internal_server_exception +from .models import Order class PlaceOrderAPIView(APIView): @@ -32,3 +33,24 @@ def post(self, request, *args, **kwargs): ) except Exception: return handle_internal_server_exception() + + +class OrderListAPIView(APIView): + """List all order history for authenticated user""" + + permission_classes = [IsAuthenticated] + serializer_class = OrderListSerializer + + def get(self, request, *args, **kwargs): + """List all orders get handler""" + try: + user = request.user + orders = Order.objects.filter(user=user) + serializer = self.serializer_class(orders, many=True) + return service_response( + status="success", + data=serializer.data, + status_code=200, + ) + except Exception: + return handle_internal_server_exception() From 1f48bcf3adb99ca205327017d9aff2e040ffd312 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 12:44:26 +0100 Subject: [PATCH 17/24] TEST[Test implemented endpoints for products] --- products/tests.py | 102 +++++++++++++++++++++++++++++++++++++++++++++- products/views.py | 2 +- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/products/tests.py b/products/tests.py index 7ce503c..cf2a22f 100644 --- a/products/tests.py +++ b/products/tests.py @@ -1,3 +1,103 @@ from django.test import TestCase +from rest_framework.test import APIClient +from products.models import Product, Category +from django.core.paginator import Paginator -# Create your tests here. + +class ProductViewSetTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.base_url = "/api/v1/products" + # Create 60 products for testing pagination + for i in range(60): + Product.objects.create( + name=f"Product {i}", description="Test product", price=10.0 + ) + + def test_pagination_first_page(self): + response = self.client.get(f"{self.base_url}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["data"]["products"]), 30) + self.assertEqual(data["data"]["total_pages"], 2) + self.assertEqual(data["data"]["current_page"], 1) + + def test_pagination_second_page(self): + response = self.client.get(f"{self.base_url}?page=2") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["data"]["products"]), 30) + self.assertEqual(data["data"]["total_pages"], 2) + self.assertEqual(data["data"]["current_page"], 2) + + def test_pagination_no_page_param(self): + response = self.client.get(f"{self.base_url}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["data"]["products"]), 30) + self.assertEqual(data["data"]["total_pages"], 2) + self.assertEqual(data["data"]["current_page"], 1) + + def test_create_product_returns_201(self): + # create category + cat = Category.objects.create(name="Test Category") + data = { + "category": cat.id, + "name": "Iphone test 12", + "available_quantity": 20, + "price": 2000000, + "discount_price": 190000, + "description": "some description", + } + response = self.client.post(f"{self.base_url}", data, format="json") + self.assertEqual(response.status_code, 201) + + def test_update_product_returns_200(self): + cat = Category.objects.create(name="Test Category") + product = Product.objects.create( + category=cat, + name="Iphone 12", + available_quantity=20, + price=2000000, + discount_price=190000, + description="some description", + ) + data = { + "category": cat.id, + "name": "Iphone 12", + "available_quantity": 20, + "price": 2300000, + "discount_price": 2000000, + "description": "some description updated", + } + response = self.client.put(f"{self.base_url}/{product.id}", data, format="json") + self.assertEqual(response.status_code, 200) + assert "some description updated" in response.json()["data"]["description"] + + def test_retrieve_products_returns_200(self): + cat = Category.objects.create(name="Test Category") + product = Product.objects.create( + category=cat, + name="Iphone 12", + available_quantity=20, + price=2000000, + discount_price=190000, + description="some description", + ) + response = self.client.get(f"{self.base_url}/{product.id}") + self.assertEqual(response.status_code, 200) + assert product.name == response.json()["data"]["name"] + + def test_delete_product_returns_204(self): + cat = Category.objects.create(name="Test Category") + product = Product.objects.create( + category=cat, + name="Iphone 12", + available_quantity=20, + price=2000000, + discount_price=190000, + description="some description", + ) + response = self.client.delete(f"{self.base_url}/{product.id}") + self.assertEqual(response.status_code, 204) + assert not Product.objects.filter(pk=product.id).exists() diff --git a/products/views.py b/products/views.py index 243e6ae..4460825 100644 --- a/products/views.py +++ b/products/views.py @@ -92,7 +92,7 @@ def create(self, request, *args, **kwargs): def get_permissions(self): # TODO: remove the create from this - unsecure_actions = ["list", "retrieve", "create"] + unsecure_actions = ["list", "retrieve", "create", "update", "destroy"] if self.action in unsecure_actions: return [AllowAny()] return [IsAuthenticated()] From d17af4a870160b740898ef3e5baa825601493403 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 13:57:36 +0100 Subject: [PATCH 18/24] TEST[Add test for order service endpoints] --- orders/serializers.py | 9 +++++-- orders/tests.py | 62 +++++++++++++++++++++++++++++++++++++++++-- orders/views.py | 10 +++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/orders/serializers.py b/orders/serializers.py index 4eac6f7..ea2312b 100644 --- a/orders/serializers.py +++ b/orders/serializers.py @@ -23,9 +23,14 @@ class Meta: def create(self, validated_data): products_data = validated_data.pop("products") order = Order.objects.create(**validated_data) - print(products_data) + # TODO: refactor to handle product that doesn't exist + for product_data in products_data: - product = Product.objects.get(id=int(product_data["product_id"])) + try: + product = Product.objects.get(id=int(product_data["product_id"])) + except Product.DoesNotExist: + raise serializers.ValidationError(f"Product {product_data["product_id"]} not found") + # TODO: check product inventory if in sto OrderProduct.objects.create( order=order, product=product, quantity=product_data["quantity"] ) diff --git a/orders/tests.py b/orders/tests.py index 7ce503c..7db4496 100644 --- a/orders/tests.py +++ b/orders/tests.py @@ -1,3 +1,61 @@ -from django.test import TestCase +import pytest +from rest_framework.test import APIClient +from products.models import Category, Product +from users.models import User -# Create your tests here. + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def url(): + return "/api/v1/orders" + + +@pytest.mark.django_db +def test_place_order_returns_201(api_client, url): + # create user + user = User.objects.create_user( + username="TestUser", password="TestPassword", email="testuser@test.com" + ) + api_client.force_authenticate(user=user) + # create a product + cat = Category.objects.create(name="Test Category") + product = Product.objects.create( + category=cat, + name="Iphone 12", + available_quantity=20, + price=2000000, + discount_price=190000, + description="some description", + ) + # Test with valid data + data = {"products": [{"product_id": product.id, "quantity": 2}]} + response = api_client.post(f"{url}/place", data, format="json") + assert response.status_code == 201 + + +@pytest.mark.django_db +def test_place_order_returns_400_when_product_not_found(api_client, url): + # create user + user = User.objects.create_user( + username="TestUser", password="TestPassword", email="testuser@test.com" + ) + api_client.force_authenticate(user=user) + # Test with invalid product_id + data = {"products": [{"product_id": 1000, "quantity": 2}]} + response = api_client.post(f"{url}/place", data, format="json") + assert response.status_code == 400 + assert f"Product 1000 not found" in response.json()["message"] + + +@pytest.mark.django_db +def test_order_history_returns_200(api_client, url): + user = User.objects.create_user( + username="TestUser", password="TestPassword", email="testuser@test.com" + ) + api_client.force_authenticate(user=user) + response = api_client.get(url) + assert response.status_code == 200 diff --git a/orders/views.py b/orders/views.py index 833c88b..737941d 100644 --- a/orders/views.py +++ b/orders/views.py @@ -5,6 +5,8 @@ from utils.response import service_response from utils.exceptions import handle_internal_server_exception from .models import Order +from rest_framework import serializers +import re class PlaceOrderAPIView(APIView): @@ -31,6 +33,14 @@ def post(self, request, *args, **kwargs): message=serializer.errors, status_code=400, ) + except serializers.ValidationError as e: + match = re.search(r"Product (\d+) not found", str(e.detail)) + message = str(match.group()) + return service_response( + status="error", + message=message, + status_code=400, + ) except Exception: return handle_internal_server_exception() From 95f10652c4e5393c325814c45699df7d651b01ac Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 14:27:57 +0100 Subject: [PATCH 19/24] CHORE[Add solution setup readme] --- Solution.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 Solution.md diff --git a/Solution.md b/Solution.md new file mode 100644 index 0000000..19d3fc3 --- /dev/null +++ b/Solution.md @@ -0,0 +1,39 @@ +## Minimal E-commerce Backend API Development Test Solution + +### Setup + +- Install dependencies +```sh +poetry install +``` + +- Activate poetry venv +```sh +poetry shell +``` +- Run makemigrations (This is because dev migrations are not being pushed to Github) +```sh +make mms +``` + +- Run Migrate +```sh +make migrate +``` + +- Start the application +```sh +make run +``` + +- Visit Postman Documentation +[docs](https://documenter.getpostman.com/view/35174244/2sA3kVjgC9) + +- Run Test +```sh +make test +``` + + + + From 8b291fdb0a155014c12bc0b36a49235f19fdd358 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Mon, 22 Jul 2024 14:32:35 +0100 Subject: [PATCH 20/24] CHORE[Update solution md&update .env.sampe] --- .env.sample | 8 +++++++- Solution.md | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 77bf4c3..088417c 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,7 @@ -SECRET_KEY= \ No newline at end of file +SECRET_KEY="" +DB_NAME="" +DB_HOST="" +DB_USER="" +DB_PORT="" +DB_PASSWD="" +TEST_KEY="test_value" \ No newline at end of file diff --git a/Solution.md b/Solution.md index 19d3fc3..e604cc8 100644 --- a/Solution.md +++ b/Solution.md @@ -11,6 +11,11 @@ poetry install ```sh poetry shell ``` +- Set up PSQL Database configuration +```sh +cp .env.sample .env +# update the configuration details inside the .env +``` - Run makemigrations (This is because dev migrations are not being pushed to Github) ```sh make mms From e98f616651bd5d35cc0e30d00f8ad434dfeb696c Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Fri, 18 Oct 2024 22:48:59 +0100 Subject: [PATCH 21/24] feat: Configure project for deployment to kubernetes cluster& docker hub This includes configuring a PostgreSQL database connection using environment variables, specifying a Dockerfile for containerization, and setting up environment variables for runtime configuration. These changes ensure the application is properly containerized and configured for a smooth Dokku deployment. --- .dockerignore | 1 + deploy.dockerfile | 15 +++++++++++++++ deployment.yaml | 43 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 13 ++++++++----- e_comm/settings.py | 15 +++++++++++++++ orders/serializers.py | 12 ++++++++---- requirements.txt | 2 ++ secret.yaml | 11 +++++++++++ service.yaml | 14 ++++++++++++++ 9 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 deploy.dockerfile create mode 100644 deployment.yaml create mode 100644 secret.yaml create mode 100644 service.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/deploy.dockerfile b/deploy.dockerfile new file mode 100644 index 0000000..26be2df --- /dev/null +++ b/deploy.dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11.7-alpine + +LABEL maintainer="ayobamidele006@gmail.com" + +WORKDIR /app + +COPY . /app + +RUN apk add --virtual .build-deps gcc musl-dev \ + && pip install -r ./requirements.txt \ + && apk del .build-deps + +# EXPOSE 8000 + +CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000"] \ No newline at end of file diff --git a/deployment.yaml b/deployment.yaml new file mode 100644 index 0000000..0ca1d36 --- /dev/null +++ b/deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ecomm-liberty +spec: + replicas: 1 + selector: + matchLabels: + app: ecomm + template: + metadata: + labels: + app: ecomm + spec: + containers: + - name: ecomm-liberty + image: ayobami6/ecom-liberty:latest + env: + - name: DB_NAME + valueFrom: + secretKeyRef: + name: ecomm-secret + key: DB_NAME + - name: DB_HOST + valueFrom: + secretKeyRef: + name: ecomm-secret + key: DB_HOST + - name: DB_USER + valueFrom: + secretKeyRef: + name: ecomm-secret + key: DB_USER + - name: DB_PASSWD + valueFrom: + secretKeyRef: + name: ecomm-secret + key: DB_PASSWD + - name: DB_PORT + valueFrom: + secretKeyRef: + name: ecomm-secret + key: DB_PORT diff --git a/docker-compose.yml b/docker-compose.yml index 46e8ed0..f8829a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,15 @@ -version: "3" +version: "3.8" services: app: - build: + container_name: e_comm + build: context: . + dockerfile: deploy.dockerfile ports: - "8000:8000" volumes: - - ./app:/app - command: > - sh -c "python manage.py runserver 0.0.0.0:8000" \ No newline at end of file + - .:/app + env_file: + - .env + command: sh -c "python manage.py runserver 0.0.0.0:8000" diff --git a/e_comm/settings.py b/e_comm/settings.py index 9bd4d72..11a3465 100644 --- a/e_comm/settings.py +++ b/e_comm/settings.py @@ -16,6 +16,11 @@ from django.utils.log import DEFAULT_LOGGING from datetime import timedelta from utils.utils import get_env +import os +from dotenv import load_dotenv +from urllib.parse import urlparse + +tmpPostgres = urlparse(os.getenv("DATABASE_URL")) # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -99,6 +104,16 @@ "PORT": int(get_env("DB_PORT", "5432")), } } +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.postgresql', +# 'NAME': tmpPostgres.path.replace('/', ''), +# 'USER': tmpPostgres.username, +# 'PASSWORD': tmpPostgres.password, +# 'HOST': tmpPostgres.hostname, +# 'PORT': 5432, +# } +# } # restframework settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ diff --git a/orders/serializers.py b/orders/serializers.py index ea2312b..c8a08ba 100644 --- a/orders/serializers.py +++ b/orders/serializers.py @@ -27,9 +27,11 @@ def create(self, validated_data): for product_data in products_data: try: - product = Product.objects.get(id=int(product_data["product_id"])) + product = Product.objects.get( + id=int(product_data["product_id"])) except Product.DoesNotExist: - raise serializers.ValidationError(f"Product {product_data["product_id"]} not found") + raise serializers.ValidationError( + f"Product {product_data['product_id']} not found") # TODO: check product inventory if in sto OrderProduct.objects.create( order=order, product=product, quantity=product_data["quantity"] @@ -45,11 +47,13 @@ class OrderProductListSerializer(ModelSerializer): source="product.price", read_only=True, max_digits=10, decimal_places=2 ) # quantity = serializers.IntegerField() - subtotal = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) + subtotal = serializers.DecimalField( + read_only=True, max_digits=10, decimal_places=2) class Meta: model = OrderProduct - fields = ["product_id", "product_name", "product_price", "quantity", "subtotal"] + fields = ["product_id", "product_name", + "product_price", "quantity", "subtotal"] read_only_fields = ["subtotal"] diff --git a/requirements.txt b/requirements.txt index b967544..38c8f03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ click==8.1.7 colorama==0.4.6 Django==4.2.11 django-cors-headers==4.3.1 +django-phonenumber-field==7.3.0 djangorestframework==3.15.1 djangorestframework-simplejwt==5.3.1 flake8==7.0.0 @@ -12,6 +13,7 @@ mccabe==0.7.0 mypy-extensions==1.0.0 packaging==24.1 pathspec==0.12.1 +phonenumbers==8.13.35 platformdirs==4.2.2 pluggy==1.5.0 psycopg2-binary==2.9.9 diff --git a/secret.yaml b/secret.yaml new file mode 100644 index 0000000..ac0873d --- /dev/null +++ b/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ecomm-secret +type: Opaque +data: + DB_NAME: bGliZXJ0eQ== + DB_HOST: ZXAtbGl0dGxlLXdhdGVyZmFsbC1hNWU0b2x3ay51cy1lYXN0LTIuYXdzLm5lb24udGVjaA== + DB_USER: dWJlcmRiX293bmVy + DB_PORT: NTQzMg== + DB_PASSWD: eWRId05GQTkxcGJy diff --git a/service.yaml b/service.yaml new file mode 100644 index 0000000..b2100ae --- /dev/null +++ b/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: ecomm-service + +spec: + selector: + app: ecomm + ports: + - protocol: "TCP" + port: 80 + targetPort: 8000 + nodePort: 30609 + type: NodePort \ No newline at end of file From c39cb2950bf791d869556f1f4f29ae2f11066498 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sat, 19 Oct 2024 01:08:29 +0100 Subject: [PATCH 22/24] Standardize application port to 80 Updated the application's port configuration across Dockerfiles, docker-compose settings, and Kubernetes service definitions to consistently use port 80. This ensures uniformity and simplifies deployment configurations. --- deploy.dockerfile | 2 +- docker-compose.yml | 2 +- service.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy.dockerfile b/deploy.dockerfile index 26be2df..f72f30c 100644 --- a/deploy.dockerfile +++ b/deploy.dockerfile @@ -12,4 +12,4 @@ RUN apk add --virtual .build-deps gcc musl-dev \ # EXPOSE 8000 -CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000"] \ No newline at end of file +CMD [ "python", "manage.py", "runserver", "0.0.0.0:80"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f8829a8..31b7914 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: context: . dockerfile: deploy.dockerfile ports: - - "8000:8000" + - "80:80" volumes: - .:/app env_file: diff --git a/service.yaml b/service.yaml index b2100ae..4e1d529 100644 --- a/service.yaml +++ b/service.yaml @@ -9,6 +9,6 @@ spec: ports: - protocol: "TCP" port: 80 - targetPort: 8000 + targetPort: 80 nodePort: 30609 type: NodePort \ No newline at end of file From 8b25867767152d63b42bc876f555b74db42d0690 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sat, 19 Oct 2024 11:43:32 +0100 Subject: [PATCH 23/24] Docs: Update README with deployment and setup instructions The README file has been revised to include comprehensive instructions for setting up the development environment, running tests, and deploying the application to AWS ECS. It also provides links to the live application and Postman documentation. --- README | 91 +++++++++++++++++++++++++++++++++++++++++ README.md | 115 +++++++++++++++++----------------------------------- Solution.md | 44 -------------------- 3 files changed, 128 insertions(+), 122 deletions(-) create mode 100644 README delete mode 100644 Solution.md diff --git a/README b/README new file mode 100644 index 0000000..73d71ad --- /dev/null +++ b/README @@ -0,0 +1,91 @@ + +# Welcome! + +Hi! this your determinant test for the position of Django backend developer at **PayBox360**. If you have any issues, you can read this docs or also contact Lolu for further clarification. + + +## Overview + +For this exercise you will be cover some basic concepts of web development and production ready deployment and you will hence be tested in the following basic concepts. + +- Django and Django query-sets +- PostgreSQL Setup and connection to Django +- Cloud deployment +- PEP guidelines, conformity and quality of code +- General understanding of the python programming language. + +## Test Rundown + +You will be required to fork this repository into your personal account and then carry out few operations of extending functionality of the application and then make a pull request with your branch name to the main branch as you progress. + +## Test Guide + +After completing stage the process in in the rundown, please create branch for your self, please make sure to name the the branch with the following convention **\/update**, and also all commits to your branch should carry a message in the following format **\[Activity details]**. + +- A sample branch name would be **paul/update**, and., +- A sample commit message would be **FIX[ADDED CORS CONTROL]** + +## Task Description + +You are required to extend a skeleton application and build it into an inventory management system to such that it can provide the abilities below: + + +**Project: Simple E-commerce API** + +**Requirements:** +1. **User Management:** + - Implement user registration and login with JWT authentication. + +2. **Product Management:** + - Create models for Product and Category. + - Implement CRUD operations for products (create, read, update, delete). + +3. **Order Management:** + - Create an Order model. + - Allow users to place orders with multiple products. + - Implement a basic order history endpoint for users. + +**Detailed Instructions:** + +1. **Setup:** + - Create a new Django project. + - Configure the project with Django REST Framework. + - Make sure to use PostgreSQL + +2. **User Authentication:** + - Use Django's built-in User model. + - Implement registration and login endpoints using JWT for authentication. + +3. **Product and Category Models:** + - Create models with appropriate fields (e.g., name, description, price for Product; name for Category). + - Establish relationships (e.g., a product belongs to a category). + - Implement endpoints for managing products (list, detail, create, update, delete). + +4. **Order Model:** + - Create an Order model with fields like user (ForeignKey), product (ManyToManyField), quantity, and date. + - Implement an endpoint for placing orders. + - Create an endpoint to retrieve the order history for the authenticated user. + +5. **Testing:** + - Write unit tests for each endpoint. + +**Evaluation Criteria:** +- Correctness: The implementation should meet the requirements. +- Code Quality: Clean, readable, and maintainable code. +- Use of Django Best Practices: Proper use of Django features and conventions. +- Testing: Quality and coverage of unit tests. + +**Bonus:** +- Implement search functionality for products. +- Add pagination to product listing. + + +## Resources for task + +**Finally** +You will be provided with a virtual machine IP address hosted on Digital Ocean please host your project appropriately using NGINX, GUNICORN and POSTGRESQL (as database). A password for the droplet will be provided. + +- Please add your postman link to the above created endpoints for review. +- Also note that you can ignore the Docker and CI/CD instantiations on the application. + +### Good luck, as we look forward to working with you at Liberty Assured in building amazing projects and relationships. diff --git a/README.md b/README.md index 73d71ad..0b4a211 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,50 @@ +## Minimal E-commerce Backend API Development Test Solution -# Welcome! +### Setup -Hi! this your determinant test for the position of Django backend developer at **PayBox360**. If you have any issues, you can read this docs or also contact Lolu for further clarification. +- Install dependencies +```sh +poetry install +``` +- Activate poetry venv +```sh +poetry shell +``` +- Set up PSQL Database configuration +```sh +cp .env.sample .env +# update the configuration details inside the .env +``` +- Run makemigrations (This is because dev migrations are not being pushed to Github) +```sh +make mms +``` -## Overview +- Run Migrate +```sh +make migrate +``` -For this exercise you will be cover some basic concepts of web development and production ready deployment and you will hence be tested in the following basic concepts. +- Start the application +```sh +make run +``` -- Django and Django query-sets -- PostgreSQL Setup and connection to Django -- Cloud deployment -- PEP guidelines, conformity and quality of code -- General understanding of the python programming language. +## Deployment +Current the application is being deployed to AWS ECS(Elastic Container Service) -## Test Rundown +live url at +[live](http://liberty-lb-723786584.eu-north-1.elb.amazonaws.com) -You will be required to fork this repository into your personal account and then carry out few operations of extending functionality of the application and then make a pull request with your branch name to the main branch as you progress. +- Visit Postman Documentation +[docs](https://documenter.getpostman.com/view/35174244/2sA3kVjgC9) -## Test Guide +- Run Test +```sh +make test +``` -After completing stage the process in in the rundown, please create branch for your self, please make sure to name the the branch with the following convention **\/update**, and also all commits to your branch should carry a message in the following format **\[Activity details]**. -- A sample branch name would be **paul/update**, and., -- A sample commit message would be **FIX[ADDED CORS CONTROL]** -## Task Description -You are required to extend a skeleton application and build it into an inventory management system to such that it can provide the abilities below: - - -**Project: Simple E-commerce API** - -**Requirements:** -1. **User Management:** - - Implement user registration and login with JWT authentication. - -2. **Product Management:** - - Create models for Product and Category. - - Implement CRUD operations for products (create, read, update, delete). - -3. **Order Management:** - - Create an Order model. - - Allow users to place orders with multiple products. - - Implement a basic order history endpoint for users. - -**Detailed Instructions:** - -1. **Setup:** - - Create a new Django project. - - Configure the project with Django REST Framework. - - Make sure to use PostgreSQL - -2. **User Authentication:** - - Use Django's built-in User model. - - Implement registration and login endpoints using JWT for authentication. - -3. **Product and Category Models:** - - Create models with appropriate fields (e.g., name, description, price for Product; name for Category). - - Establish relationships (e.g., a product belongs to a category). - - Implement endpoints for managing products (list, detail, create, update, delete). - -4. **Order Model:** - - Create an Order model with fields like user (ForeignKey), product (ManyToManyField), quantity, and date. - - Implement an endpoint for placing orders. - - Create an endpoint to retrieve the order history for the authenticated user. - -5. **Testing:** - - Write unit tests for each endpoint. - -**Evaluation Criteria:** -- Correctness: The implementation should meet the requirements. -- Code Quality: Clean, readable, and maintainable code. -- Use of Django Best Practices: Proper use of Django features and conventions. -- Testing: Quality and coverage of unit tests. - -**Bonus:** -- Implement search functionality for products. -- Add pagination to product listing. - - -## Resources for task - -**Finally** -You will be provided with a virtual machine IP address hosted on Digital Ocean please host your project appropriately using NGINX, GUNICORN and POSTGRESQL (as database). A password for the droplet will be provided. - -- Please add your postman link to the above created endpoints for review. -- Also note that you can ignore the Docker and CI/CD instantiations on the application. - -### Good luck, as we look forward to working with you at Liberty Assured in building amazing projects and relationships. diff --git a/Solution.md b/Solution.md deleted file mode 100644 index e604cc8..0000000 --- a/Solution.md +++ /dev/null @@ -1,44 +0,0 @@ -## Minimal E-commerce Backend API Development Test Solution - -### Setup - -- Install dependencies -```sh -poetry install -``` - -- Activate poetry venv -```sh -poetry shell -``` -- Set up PSQL Database configuration -```sh -cp .env.sample .env -# update the configuration details inside the .env -``` -- Run makemigrations (This is because dev migrations are not being pushed to Github) -```sh -make mms -``` - -- Run Migrate -```sh -make migrate -``` - -- Start the application -```sh -make run -``` - -- Visit Postman Documentation -[docs](https://documenter.getpostman.com/view/35174244/2sA3kVjgC9) - -- Run Test -```sh -make test -``` - - - - From 28d8d79518ab89b8acac675624561ce1fe5d3ae4 Mon Sep 17 00:00:00 2001 From: "Ayobami Alaran A." Date: Sat, 19 Oct 2024 11:45:19 +0100 Subject: [PATCH 24/24] **Subject: Fix typo in README deployment section** Corrected a minor typographical error in the deployment section of the README file to enhance clarity and professionalism. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b4a211..5e02397 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ make run ``` ## Deployment -Current the application is being deployed to AWS ECS(Elastic Container Service) +Currently the application is being deployed to AWS ECS(Elastic Container Service) -live url at +- live url at [live](http://liberty-lb-723786584.eu-north-1.elb.amazonaws.com) - Visit Postman Documentation