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/.env.sample b/.env.sample new file mode 100644 index 0000000..088417c --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ +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/.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/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/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..5e02397 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 +Currently 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/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/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/deploy.dockerfile b/deploy.dockerfile new file mode 100644 index 0000000..f72f30c --- /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:80"] \ 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..31b7914 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" + - "80:80" 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/__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..11a3465 --- /dev/null +++ b/e_comm/settings.py @@ -0,0 +1,239 @@ +""" +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 +import logging +import logging.config +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'. +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 = get_env("SECRET_KEY", "secret") + +# 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", + # third-party apps + "rest_framework", + "corsheaders", + "phonenumber_field", + # local apps + "users", + "products", + "orders", +] + +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", + "corsheaders.middleware.CorsMiddleware", +] + +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" + +AUTH_USER_MODEL = "users.User" + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "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")), + } +} +# 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": [ + "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 + +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 = "Africa/Lagos" + +USE_I18N = 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/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/e_comm/urls.py b/e_comm/urls.py new file mode 100644 index 0000000..ca3076f --- /dev/null +++ b/e_comm/urls.py @@ -0,0 +1,29 @@ +""" +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, include + +from users.views import RootPage + +urlpatterns = [ + 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/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/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..1457a99 --- /dev/null +++ b/orders/admin.py @@ -0,0 +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/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..374044e --- /dev/null +++ b/orders/models.py @@ -0,0 +1,57 @@ +from django.db import models +from django.contrib.auth import get_user_model +from products.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 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" + db_table = "orders" + + def save(self, *args, **kwargs): + self.order_ref = generate_ref() + 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, related_name="product_orders") + 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..c8a08ba --- /dev/null +++ b/orders/serializers.py @@ -0,0 +1,73 @@ +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) + # TODO: refactor to handle product that doesn't exist + + for product_data in products_data: + 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"] + ) + 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/tests.py b/orders/tests.py new file mode 100644 index 0000000..7db4496 --- /dev/null +++ b/orders/tests.py @@ -0,0 +1,61 @@ +import pytest +from rest_framework.test import APIClient +from products.models import Category, Product +from users.models import User + + +@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/urls.py b/orders/urls.py new file mode 100644 index 0000000..8fdc20f --- /dev/null +++ b/orders/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import OrderListAPIView, PlaceOrderAPIView + +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 new file mode 100644 index 0000000..737941d --- /dev/null +++ b/orders/views.py @@ -0,0 +1,66 @@ +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from orders.serializers import OrderListSerializer, OrderSerializer +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): + """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 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() + + +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() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..998a733 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,508 @@ +# 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 = "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" +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 = "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" +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 = "dc6dac7437ef14ed741bf730e584d4b00b78c94435dc761ea2a382fd431ebd76" 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..51d2119 --- /dev/null +++ b/products/admin.py @@ -0,0 +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/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/serializers.py b/products/serializers.py new file mode 100644 index 0000000..c6c7916 --- /dev/null +++ b/products/serializers.py @@ -0,0 +1,41 @@ +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers + +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 = ( + "id", + "name", + "price", + "description", + "discount_price", + "available_quantity", + "category", + ) diff --git a/products/tests.py b/products/tests.py new file mode 100644 index 0000000..cf2a22f --- /dev/null +++ b/products/tests.py @@ -0,0 +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 + + +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/urls.py b/products/urls.py new file mode 100644 index 0000000..75afb71 --- /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.SimpleRouter(trailing_slash=False) +router.register(r"products", ProductViewSet, basename="products") + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/products/views.py b/products/views.py new file mode 100644 index 0000000..4460825 --- /dev/null +++ b/products/views.py @@ -0,0 +1,135 @@ +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, 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. + + +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() + + 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", "update", "destroy"] + 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5f6bf14 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[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" +django-phonenumber-field = "7.3.0" +phonenumbers = "8.13.35" + + +[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" 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/requirements.txt b/requirements.txt index 843358c..38c8f03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,28 @@ -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 +django-phonenumber-field==7.3.0 +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 +phonenumbers==8.13.35 +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 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..4e1d529 --- /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: 80 + nodePort: 30609 + type: NodePort \ No newline at end of file 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..01f260d --- /dev/null +++ b/users/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import User + + +class UserAdmin(admin.ModelAdmin): + list_display = ["username", "email"] + search_fields = ["username", "email"] + + +admin.site.register(User, UserAdmin) 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/serializers.py b/users/serializers.py new file mode 100644 index 0000000..5138087 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,110 @@ +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 + + +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/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"] 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/urls.py b/users/urls.py new file mode 100644 index 0000000..5492a67 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import CreateUserAPIView, LoginUserAPIView + +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 new file mode 100644 index 0000000..23e09d0 --- /dev/null +++ b/users/views.py @@ -0,0 +1,80 @@ +from django.shortcuts import render +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""" + + 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() + + +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() diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 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 + ) 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) diff --git a/utils/utils.py b/utils/utils.py new file mode 100644 index 0000000..bf036e4 --- /dev/null +++ b/utils/utils.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv +import random +import string + +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) + + +def generate_ref() -> str: + """generate unique reference code""" + code = "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + return code.upper()