From 204ef91091e65c4eb00eb9f252119c977bcd6db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 16:10:58 -0300 Subject: [PATCH 01/28] feat: init django server --- .gitignore | 174 ++++++++++++++++++++++++++++++++ coderockr/__init__.py | 0 coderockr/asgi.py | 16 +++ coderockr/settings.py | 128 +++++++++++++++++++++++ coderockr/urls.py | 21 ++++ coderockr/wsgi.py | 16 +++ core/__init__.py | 0 core/admin.py | 8 ++ core/apps.py | 6 ++ core/migrations/0001_initial.py | 44 ++++++++ core/migrations/__init__.py | 0 core/models.py | 7 ++ core/tests.py | 3 + core/views.py | 3 + manage.py | 22 ++++ poetry.lock | 60 +++++++++++ pyproject.toml | 15 +++ 17 files changed, 523 insertions(+) create mode 100644 .gitignore create mode 100644 coderockr/__init__.py create mode 100644 coderockr/asgi.py create mode 100644 coderockr/settings.py create mode 100644 coderockr/urls.py create mode 100644 coderockr/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/apps.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/tests.py create mode 100644 core/views.py create mode 100644 manage.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ca9e27cb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Created by https://www.toptal.com/developers/gitignore/api/django +# Edit at https://www.toptal.com/developers/gitignore?templates=django + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# End of https://www.toptal.com/developers/gitignore/api/django \ No newline at end of file diff --git a/coderockr/__init__.py b/coderockr/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/coderockr/asgi.py b/coderockr/asgi.py new file mode 100644 index 000000000..53ef72713 --- /dev/null +++ b/coderockr/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for coderockr project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'coderockr.settings') + +application = get_asgi_application() diff --git a/coderockr/settings.py b/coderockr/settings.py new file mode 100644 index 000000000..6b7c46dbb --- /dev/null +++ b/coderockr/settings.py @@ -0,0 +1,128 @@ +""" +Django settings for coderockr project. + +Generated by 'django-admin startproject' using Django 4.1. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-6jx9%%3v56-jsdhh%51rjs_u)*dug)#gl)t_i)(=3*ri&eayyz' + +# 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', + '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', +] + +ROOT_URLCONF = 'coderockr.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 = 'coderockr.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Custom User + +AUTH_USER_MODEL = 'core.User' \ No newline at end of file diff --git a/coderockr/urls.py b/coderockr/urls.py new file mode 100644 index 000000000..210595180 --- /dev/null +++ b/coderockr/urls.py @@ -0,0 +1,21 @@ +"""coderockr URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/coderockr/wsgi.py b/coderockr/wsgi.py new file mode 100644 index 000000000..c50b78fad --- /dev/null +++ b/coderockr/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for coderockr project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'coderockr.settings') + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 000000000..6085c907d --- /dev/null +++ b/core/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import User + + +# Register your models here. +admin.site.register(User, UserAdmin) diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 000000000..8115ae60b --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 000000000..e285d5b8b --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.1 on 2022-08-15 19:07 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + 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')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('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={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/models.py b/core/models.py new file mode 100644 index 000000000..569ffa9bb --- /dev/null +++ b/core/models.py @@ -0,0 +1,7 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser + + +# Create your models here. +class User(AbstractUser): + ... diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/views.py b/core/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100644 index 000000000..4fd4948fa --- /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', 'coderockr.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..fea28abaf --- /dev/null +++ b/poetry.lock @@ -0,0 +1,60 @@ +[[package]] +name = "asgiref" +version = "3.5.2" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "django" +version = "4.1" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +asgiref = ">=3.5.2,<4" +sqlparse = ">=0.2.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +bcrypt = ["bcrypt"] +argon2 = ["argon2-cffi (>=19.1.0)"] + +[[package]] +name = "sqlparse" +version = "0.4.2" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "tzdata" +version = "2022.2" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "1ee73de322b29371a6b77e51a789b09d5e43999b9739088161a7633be53dcbae" + +[metadata.files] +asgiref = [ + {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, + {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, +] +django = [] +sqlparse = [ + {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, + {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, +] +tzdata = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..fbb9d7e76 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "backend-test-coderockr" +version = "0.1.0" +description = "" +authors = ["João Prado "] + +[tool.poetry.dependencies] +python = "^3.10" +Django = "^4.1" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" From cc689752ef3c299f08d271ed77680b0a2419d8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 16:38:47 -0300 Subject: [PATCH 02/28] feat: configures db, app and debugpy through Docker --- .env.dev | 6 ++++++ .gitignore | 5 ++++- Dockerfile | 15 +++++++++++++++ coderockr/settings.py | 9 +++++++-- docker-compose.yml | 31 +++++++++++++++++++++++++++++++ docker-entrypoint.sh | 4 ++++ poetry.lock | 32 +++++++++++++++++++++++++++++++- pyproject.toml | 2 ++ 8 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 .env.dev create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh diff --git a/.env.dev b/.env.dev new file mode 100644 index 000000000..291817b55 --- /dev/null +++ b/.env.dev @@ -0,0 +1,6 @@ +SECRET_KEY=cj&61by%3&!7zlb04f4w9c8#@l@=)6h3@*@4n)5xlz3ikdz3k0 +DB_NAME=coderockr +DB_USER=coderockr +DB_PASSWORD=coderockr +DB_HOST=db +DB_PORT=5432 diff --git a/.gitignore b/.gitignore index ca9e27cb4..60d8461cb 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,7 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -# End of https://www.toptal.com/developers/gitignore/api/django \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/django + +# Docker +/data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..4fec387b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + curl wait-for-it + +WORKDIR /app + +COPY poetry.lock pyproject.toml /app/ +RUN pip3 install poetry +RUN poetry config virtualenvs.create false +RUN poetry config --list +RUN poetry install + +COPY . . \ No newline at end of file diff --git a/coderockr/settings.py b/coderockr/settings.py index 6b7c46dbb..e745e0426 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -76,8 +77,12 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DB_NAME'), + 'USER': os.getenv('DB_USER'), + 'PASSWORD': os.getenv('DB_PASSWORD'), + 'HOST': os.getenv('DB_HOST'), + 'PORT': os.getenv('DB_PORT'), } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..c5514e1d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + app: + build: + context: . + entrypoint: + - ./docker-entrypoint.sh + env_file: + - ./.env.dev + volumes: + - .:/app + ports: + - 8000:8000 + - 5678:5678 + depends_on: + - db + db: + image: postgres:12.2-alpine + restart: unless-stopped + volumes: + - ./data/postgres-data:/var/lib/postgresql/data + ports: + - 5432:5432 + environment: + - POSTGRES_USER=coderockr + - POSTGRES_DB=coderockr + - POSTGRES_PASSWORD=coderockr + healthcheck: + test: 'pg_isready -U coderockr -d coderockr' + interval: 10s + timeout: 3s + retries: 3 \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..9c9c61bf8 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +python -m manage migrate +python -m manage runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index fea28abaf..bf5f4cb8f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -9,6 +9,14 @@ python-versions = ">=3.7" [package.extras] tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +[[package]] +name = "debugpy" +version = "1.6.2" +description = "An implementation of the Debug Adapter Protocol for Python" +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "django" version = "4.1" @@ -26,6 +34,14 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} bcrypt = ["bcrypt"] argon2 = ["argon2-cffi (>=19.1.0)"] +[[package]] +name = "psycopg2" +version = "2.9.3" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "sqlparse" version = "0.4.2" @@ -45,14 +61,28 @@ python-versions = ">=2" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "1ee73de322b29371a6b77e51a789b09d5e43999b9739088161a7633be53dcbae" +content-hash = "d6e38ef88e0a95999955e22034cda415df4dc62f06960959b3cddb1186ebfd21" [metadata.files] asgiref = [ {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, ] +debugpy = [] django = [] +psycopg2 = [ + {file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"}, + {file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"}, + {file = "psycopg2-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56"}, + {file = "psycopg2-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305"}, + {file = "psycopg2-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2"}, + {file = "psycopg2-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461"}, + {file = "psycopg2-2.9.3-cp38-cp38-win32.whl", hash = "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7"}, + {file = "psycopg2-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf"}, + {file = "psycopg2-2.9.3-cp39-cp39-win32.whl", hash = "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126"}, + {file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"}, + {file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"}, +] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, diff --git a/pyproject.toml b/pyproject.toml index fbb9d7e76..3d88848dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ authors = ["João Prado "] [tool.poetry.dependencies] python = "^3.10" Django = "^4.1" +debugpy = "^1.6.2" +psycopg2 = "^2.9.3" [tool.poetry.dev-dependencies] From af9f609d122bbe934665ba17d34e103fb1dfad57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 16:54:14 -0300 Subject: [PATCH 03/28] feat: creates investment model --- core/migrations/0002_investment.py | 25 +++++++++++++++++++++++++ core/models.py | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 core/migrations/0002_investment.py diff --git a/core/migrations/0002_investment.py b/core/migrations/0002_investment.py new file mode 100644 index 000000000..d78027493 --- /dev/null +++ b/core/migrations/0002_investment.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1 on 2022-08-15 19:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Investment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/models.py b/core/models.py index 569ffa9bb..ae41fdfd2 100644 --- a/core/models.py +++ b/core/models.py @@ -1,7 +1,14 @@ from django.db import models from django.contrib.auth.models import AbstractUser - +from django.conf import settings # Create your models here. class User(AbstractUser): ... + + +class Investment(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + amount = models.FloatField() + active = models.BooleanField(default=True, null=False) + created_at = models.DateTimeField() \ No newline at end of file From 0b789a81833b12a48e5ec4e99b423fd6cf99ccbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 17:04:26 -0300 Subject: [PATCH 04/28] feat: configures pytest --- core/tests/test_pytest.py | 2 + poetry.lock | 194 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 6 ++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 core/tests/test_pytest.py diff --git a/core/tests/test_pytest.py b/core/tests/test_pytest.py new file mode 100644 index 000000000..b57456544 --- /dev/null +++ b/core/tests/test_pytest.py @@ -0,0 +1,2 @@ +def test_pytest(): + assert 1 == 1 \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index bf5f4cb8f..6b0ea2640 100644 --- a/poetry.lock +++ b/poetry.lock @@ -9,6 +9,36 @@ python-versions = ">=3.7" [package.extras] tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +tests_no_zope = ["cloudpickle", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"] +dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "debugpy" version = "1.6.2" @@ -34,6 +64,71 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} bcrypt = ["bcrypt"] argon2 = ["argon2-cffi (>=19.1.0)"] +[[package]] +name = "djangorestframework" +version = "3.13.1" +description = "Web APIs for Django, made easy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = ">=2.2" +pytz = "*" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "markdown" +version = "3.4.1" +description = "Python implementation of Markdown." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +testing = ["pyyaml", "coverage"] + +[[package]] +name = "model-bakery" +version = "1.7.0" +description = "Smart object creation facility for Django." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +django = ">=3.2" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "psycopg2" version = "2.9.3" @@ -42,6 +137,54 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytz" +version = "2022.2.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "sqlparse" version = "0.4.2" @@ -50,6 +193,14 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "tzdata" version = "2022.2" @@ -61,15 +212,39 @@ python-versions = ">=2" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "d6e38ef88e0a95999955e22034cda415df4dc62f06960959b3cddb1186ebfd21" +content-hash = "5df9a1a3c0ccf5f0728b1fe1c932afc8152feb5816c31d447eb39c137c0c0444" [metadata.files] asgiref = [ {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, ] +atomicwrites = [] +attrs = [] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] debugpy = [] django = [] +djangorestframework = [ + {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, + {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +markdown = [] +model-bakery = [] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] psycopg2 = [ {file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"}, {file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"}, @@ -83,8 +258,25 @@ psycopg2 = [ {file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"}, {file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"}, ] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +pytz = [] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, ] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] tzdata = [] diff --git a/pyproject.toml b/pyproject.toml index 3d88848dc..f84b55176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,15 @@ python = "^3.10" Django = "^4.1" debugpy = "^1.6.2" psycopg2 = "^2.9.3" +pytest = "^7.1.2" +model-bakery = "^1.7.0" [tool.poetry.dev-dependencies] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE="coderockr.settings" +python_files = ["test_*.py"] \ No newline at end of file From 37e02a8999f296b6f2ff802526e788d4926eb352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 18:46:53 -0300 Subject: [PATCH 05/28] feat: configures drf; fix: returns with debugpy --- coderockr/settings.py | 21 ++++++++++++++++++++- coderockr/urls.py | 5 ++++- docker-entrypoint.sh | 2 +- pyproject.toml | 2 ++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/coderockr/settings.py b/coderockr/settings.py index e745e0426..25d85898a 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -38,6 +38,8 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', 'core' ] @@ -130,4 +132,21 @@ # Custom User -AUTH_USER_MODEL = 'core.User' \ No newline at end of file +AUTH_USER_MODEL = 'core.User' + +# Rest Framework + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly', + ], + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ] +} + +LOGIN_REDIRECT_URL='/' \ No newline at end of file diff --git a/coderockr/urls.py b/coderockr/urls.py index 210595180..a09dc111e 100644 --- a/coderockr/urls.py +++ b/coderockr/urls.py @@ -14,8 +14,11 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +from rest_framework.authtoken.views import obtain_auth_token urlpatterns = [ path('admin/', admin.site.urls), + path(r'login/', obtain_auth_token), + path('', include('core.urls')) ] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9c9c61bf8..a253c15fe 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/bash python -m manage migrate -python -m manage runserver 0.0.0.0:8000 \ No newline at end of file +python -m debugpy --listen 0.0.0.0:5678 -m manage runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f84b55176..9246c1be0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ debugpy = "^1.6.2" psycopg2 = "^2.9.3" pytest = "^7.1.2" model-bakery = "^1.7.0" +djangorestframework = "^3.13.1" +Markdown = "^3.4.1" [tool.poetry.dev-dependencies] From 6536784b44885659339ec4457a569f3506f9e0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 18:47:27 -0300 Subject: [PATCH 06/28] feat: adds user CRUD --- core/permissions.py | 6 ++++++ core/serializers.py | 30 ++++++++++++++++++++++++++++++ core/urls.py | 11 +++++++++++ core/views.py | 13 ++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 core/permissions.py create mode 100644 core/serializers.py create mode 100644 core/urls.py diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 000000000..bc2e216f0 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsOwnProfile(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.id == request.user.id diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 000000000..bf3199e00 --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,30 @@ +from rest_framework import serializers +from .models import User + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'email', 'password') + extra_kwargs = { + 'password': { + 'write_only': True, + 'style': { + 'input_type': 'password' + } + } + } + + def create(self, validated_data): + user = User.objects.create_user( + email=validated_data['email'], + username=validated_data['email'], + password=validated_data['password'] + ) + + return user + + def update(self, instance, validated_data): + if 'password' in validated_data: + password = validated_data.pop('password') + instance.set_password(password) + return super().update(instance, validated_data) diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 000000000..c845a598a --- /dev/null +++ b/core/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import UserViewSet + + +router = DefaultRouter() +router.register('user', UserViewSet) + +urlpatterns = [ + path('', include(router.urls)) +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 91ea44a21..69711d4db 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,14 @@ -from django.shortcuts import render +from http import HTTPStatus +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +from rest_framework.decorators import action + +from .permissions import IsOwnProfile +from .serializers import UserSerializer +from .models import User # Create your views here. +class UserViewSet(ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = (IsOwnProfile,) From 5a8f3ff1e4b017f28a71df9580c06778e3572c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 19:24:12 -0300 Subject: [PATCH 07/28] feat: configures swagger through drf-yasg --- coderockr/openapi.py | 13 +++ coderockr/settings.py | 16 +++- coderockr/urls.py | 5 +- poetry.lock | 203 +++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 coderockr/openapi.py diff --git a/coderockr/openapi.py b/coderockr/openapi.py new file mode 100644 index 000000000..8684b5481 --- /dev/null +++ b/coderockr/openapi.py @@ -0,0 +1,13 @@ +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title='Investments API', + default_version='v1', + license=openapi.License(name='MIT License') + ), + public=True, + permission_classes=[permissions.AllowAny] +) \ No newline at end of file diff --git a/coderockr/settings.py b/coderockr/settings.py index 25d85898a..d4b1a1cc2 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -40,6 +40,7 @@ 'django.contrib.staticfiles', 'rest_framework', 'rest_framework.authtoken', + 'drf_yasg', 'core' ] @@ -149,4 +150,17 @@ ] } -LOGIN_REDIRECT_URL='/' \ No newline at end of file +LOGIN_REDIRECT_URL='/' +LOGIN_URL='/login' + +# Swagger + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header' + } + } +} \ No newline at end of file diff --git a/coderockr/urls.py b/coderockr/urls.py index a09dc111e..de563aab5 100644 --- a/coderockr/urls.py +++ b/coderockr/urls.py @@ -14,11 +14,14 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path, include +from django.urls import path, re_path, include from rest_framework.authtoken.views import obtain_auth_token +from . import openapi urlpatterns = [ path('admin/', admin.site.urls), path(r'login/', obtain_auth_token), + re_path(r'^swagger/$', openapi.schema_view.with_ui('swagger', cache_timeout=0), + name='swagger-ui'), path('', include('core.urls')) ] diff --git a/poetry.lock b/poetry.lock index 6b0ea2640..54466e204 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,6 +31,25 @@ tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900, docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"] dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +[[package]] +name = "certifi" +version = "2022.6.15" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "colorama" version = "0.4.5" @@ -39,6 +58,31 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +coreschema = "*" +itypes = "*" +requests = "*" +uritemplate = "*" + +[[package]] +name = "coreschema" +version = "0.0.4" +description = "Core Schema." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +jinja2 = "*" + [[package]] name = "debugpy" version = "1.6.2" @@ -76,6 +120,44 @@ python-versions = ">=3.6" django = ">=2.2" pytz = "*" +[[package]] +name = "drf-yasg" +version = "1.21.3" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coreapi = ">=2.3.3" +coreschema = ">=0.0.4" +django = ">=2.2.16" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +packaging = ">=21.0" +pytz = ">=2021.1" +"ruamel.yaml" = ">=0.16.13" +uritemplate = ">=3.0.0" + +[package.extras] +validation = ["swagger-spec-validator (>=2.1.0)"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "iniconfig" version = "1.1.1" @@ -84,6 +166,28 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "itypes" +version = "1.2.0" +description = "Simple immutable types for python." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "markdown" version = "3.4.1" @@ -95,6 +199,14 @@ python-versions = ">=3.7" [package.extras] testing = ["pyyaml", "coverage"] +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "model-bakery" version = "1.7.0" @@ -185,6 +297,47 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruamel.yaml" +version = "0.17.21" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "main" +optional = false +python-versions = ">=3" + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.6" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "sqlparse" version = "0.4.2" @@ -209,10 +362,31 @@ category = "main" optional = false python-versions = ">=2" +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.11" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "5df9a1a3c0ccf5f0728b1fe1c932afc8152feb5816c31d447eb39c137c0c0444" +content-hash = "412b1656b008bf159b938c028d03411d5fa6984074189ec56dbbbf9c1f25cd6c" [metadata.files] asgiref = [ @@ -221,21 +395,37 @@ asgiref = [ ] atomicwrites = [] attrs = [] +certifi = [ + {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, + {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, +] +charset-normalizer = [] colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] +coreapi = [] +coreschema = [] debugpy = [] django = [] djangorestframework = [ {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, ] +drf-yasg = [] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +inflection = [] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +itypes = [] +jinja2 = [] markdown = [] +markupsafe = [] model-bakery = [] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, @@ -271,6 +461,12 @@ pytest = [ {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, ] pytz = [] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +"ruamel.yaml" = [] +"ruamel.yaml.clib" = [] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, @@ -280,3 +476,8 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] tzdata = [] +uritemplate = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] +urllib3 = [] diff --git a/pyproject.toml b/pyproject.toml index 9246c1be0..3bf41d615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ pytest = "^7.1.2" model-bakery = "^1.7.0" djangorestframework = "^3.13.1" Markdown = "^3.4.1" +drf-yasg = "^1.21.3" [tool.poetry.dev-dependencies] From bc2bda0db9c9bda5c6e13e5cc7278a7511f2f8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 22:04:30 -0300 Subject: [PATCH 08/28] test: fixes pytest config --- poetry.lock | 84 ++++++++++++++++++++++++++++++++++++++++++-------- pyproject.toml | 11 ++++--- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index 54466e204..c9bfec48c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,7 +13,7 @@ tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] name = "atomicwrites" version = "1.4.1" description = "Atomic file writes." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -21,7 +21,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "attrs" version = "22.1.0" description = "Classes Without Boilerplate" -category = "main" +category = "dev" optional = false python-versions = ">=3.5" @@ -54,7 +54,7 @@ unicode_backport = ["unicodedata2"] name = "colorama" version = "0.4.5" description = "Cross-platform colored terminal text." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -85,9 +85,9 @@ jinja2 = "*" [[package]] name = "debugpy" -version = "1.6.2" +version = "1.6.3" description = "An implementation of the Debug Adapter Protocol for Python" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -142,6 +142,17 @@ uritemplate = ">=3.0.0" [package.extras] validation = ["swagger-spec-validator (>=2.1.0)"] +[[package]] +name = "execnet" +version = "1.9.0" +description = "execnet: rapid multi-Python deployment" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +testing = ["pre-commit"] + [[package]] name = "idna" version = "3.3" @@ -162,7 +173,7 @@ python-versions = ">=3.5" name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "main" +category = "dev" optional = false python-versions = "*" @@ -211,7 +222,7 @@ python-versions = ">=3.7" name = "model-bakery" version = "1.7.0" description = "Smart object creation facility for Django." -category = "main" +category = "dev" optional = false python-versions = "*" @@ -233,7 +244,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -253,7 +264,7 @@ python-versions = ">=3.6" name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -272,7 +283,7 @@ diagrams = ["railroad-diagrams", "jinja2"] name = "pytest" version = "7.1.2" description = "pytest: simple powerful testing with Python" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -289,6 +300,51 @@ tomli = ">=1.0.0" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-forked" +version = "1.4.0" +description = "run tests in isolated forked subprocesses" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +name = "pytest-xdist" +version = "2.5.0" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" +pytest-forked = "*" + +[package.extras] +testing = ["filelock"] +setproctitle = ["setproctitle"] +psutil = ["psutil (>=3.0)"] + [[package]] name = "pytz" version = "2022.2.1" @@ -350,7 +406,7 @@ python-versions = ">=3.5" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -386,7 +442,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "412b1656b008bf159b938c028d03411d5fa6984074189ec56dbbbf9c1f25cd6c" +content-hash = "ce1059c09834886f869259e27fd850a6595b3cbc95b8b160d5ab3d300fa12ffb" [metadata.files] asgiref = [ @@ -413,6 +469,7 @@ djangorestframework = [ {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, ] drf-yasg = [] +execnet = [] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, @@ -460,6 +517,9 @@ pytest = [ {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, ] +pytest-django = [] +pytest-forked = [] +pytest-xdist = [] pytz = [] requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, diff --git a/pyproject.toml b/pyproject.toml index 3bf41d615..0a933348a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,17 @@ authors = ["João Prado "] [tool.poetry.dependencies] python = "^3.10" Django = "^4.1" -debugpy = "^1.6.2" psycopg2 = "^2.9.3" -pytest = "^7.1.2" -model-bakery = "^1.7.0" djangorestframework = "^3.13.1" Markdown = "^3.4.1" drf-yasg = "^1.21.3" [tool.poetry.dev-dependencies] +pytest-django = "^4.5.2" +pytest-xdist = "^2.5.0" +model-bakery = "^1.7.0" +pytest = "^7.1.2" +debugpy = "^1.6.3" [build-system] requires = ["poetry-core>=1.0.0"] @@ -23,4 +25,5 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="coderockr.settings" -python_files = ["test_*.py"] \ No newline at end of file +python_files = ["test_*.py"] +addopts = "--reuse-db" \ No newline at end of file From 8a5261f9f999fb9c7dd33bf1f9b56339b7b8ecd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 22:05:11 -0300 Subject: [PATCH 09/28] test: tests for user --- core/admin.py | 2 +- core/migrations/0003_alter_user_email.py | 18 ++++++ core/models.py | 6 ++ core/serializers.py | 15 ++++- core/tests/__init__.py | 0 core/tests/conftest.py | 9 +++ core/tests/test_user.py | 79 ++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 core/migrations/0003_alter_user_email.py create mode 100644 core/tests/__init__.py create mode 100644 core/tests/conftest.py create mode 100644 core/tests/test_user.py diff --git a/core/admin.py b/core/admin.py index 6085c907d..917cf16e5 100644 --- a/core/admin.py +++ b/core/admin.py @@ -5,4 +5,4 @@ # Register your models here. -admin.site.register(User, UserAdmin) +admin.site.register(User) diff --git a/core/migrations/0003_alter_user_email.py b/core/migrations/0003_alter_user_email.py new file mode 100644 index 000000000..4ff75ee1e --- /dev/null +++ b/core/migrations/0003_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1 on 2022-08-16 00:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_investment'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, unique=True, verbose_name='email address'), + ), + ] diff --git a/core/models.py b/core/models.py index ae41fdfd2..dda565cd4 100644 --- a/core/models.py +++ b/core/models.py @@ -1,9 +1,15 @@ from django.db import models from django.contrib.auth.models import AbstractUser from django.conf import settings +from django.utils.translation import gettext_lazy as _ + # Create your models here. class User(AbstractUser): + email = models.EmailField(_('email address'), unique=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] ... diff --git a/core/serializers.py b/core/serializers.py index bf3199e00..9a8e0105a 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -1,11 +1,20 @@ from rest_framework import serializers +from rest_framework.validators import UniqueValidator from .models import User + class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ('id', 'email', 'password') extra_kwargs = { + 'email': { + 'validators': [ + UniqueValidator( + queryset=User.objects.all() + ) + ] + }, 'password': { 'write_only': True, 'style': { @@ -14,15 +23,17 @@ class Meta: } } + def create(self, validated_data): user = User.objects.create_user( email=validated_data['email'], - username=validated_data['email'], - password=validated_data['password'] + password=validated_data['password'], + username=validated_data['email'] ) return user + def update(self, instance, validated_data): if 'password' in validated_data: password = validated_data.pop('password') diff --git a/core/tests/__init__.py b/core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/tests/conftest.py b/core/tests/conftest.py new file mode 100644 index 000000000..284b4b09d --- /dev/null +++ b/core/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from rest_framework.authtoken.models import Token + +@pytest.fixture +def create_token_for_user(): + def wrapper(user): + return Token.objects.create(user=user) + + return wrapper diff --git a/core/tests/test_user.py b/core/tests/test_user.py new file mode 100644 index 000000000..4739ad1ce --- /dev/null +++ b/core/tests/test_user.py @@ -0,0 +1,79 @@ +from http import HTTPStatus +import json +from model_bakery import baker +from ..models import User + + +def test_create(db, client): + data = { 'email': 'jorge@gmail.com', 'password': 'jorge' } + + response = client.post('/user/', data=json.dumps(data), content_type='application/json') + response_data = response.json() + + assert response.status_code == HTTPStatus.CREATED + assert response_data['email'] == data['email'] + + +def test_create_duplicate(db, client): + data = { 'email': 'robson@gmail.com', 'password': 'jorge' } + + User.objects.create_user(**data, username=data['email']) + + response = client.post('/user/', data=json.dumps(data), content_type='application/json') + + print(User.objects.all()) + + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_update(db, client, create_token_for_user): + user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') + token = 'Token ' + create_token_for_user(user_jorge).key + data = { 'email': 'jorge@gmail.com' } + + response = client.patch( + '/user/1/', + data=json.dumps(data), + HTTP_AUTHORIZATION=token, + content_type='application/json' + ) + response_data = response.json() + + assert response.status_code == HTTPStatus.OK + assert response_data['email'] == data['email'] + + +def test_update_other_user(db, client, create_token_for_user): + user_jorge = baker.make('core.User', id=1, email='jorge@gmail.com') + baker.make('core.User', id=2, email='marcio@marcio.com') + token_jorge = 'Token ' + create_token_for_user(user_jorge).key + data = { 'email': 'marcio@gmail.com' } + + response = client.patch( + '/user/2/', + data=json.dumps(data), + HTTP_AUTHORIZATION=token_jorge, + content_type='application/json' + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + + +def test_delete(db, client, create_token_for_user): + user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') + token = 'Token ' + create_token_for_user(user_jorge).key + + response = client.delete('/user/1/', HTTP_AUTHORIZATION=token) + + assert response.status_code == HTTPStatus.NO_CONTENT + + +def test_delete_other_user(db, client, create_token_for_user): + user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') + baker.make('core.User', id=2, email='marcio@marcio.com') + + token = 'Token ' + create_token_for_user(user_jorge).key + + response = client.delete('/user/2/', HTTP_AUTHORIZATION=token) + + assert response.status_code == HTTPStatus.FORBIDDEN From dcdbb0d42b5554b5e4f9813dc51b32207a617c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Mon, 15 Aug 2022 22:40:35 -0300 Subject: [PATCH 10/28] feat: moves investment to its on app --- core/migrations/0004_delete_investment.py | 16 ++++++++++++++ core/models.py | 7 ------ investments/__init__.py | 0 investments/admin.py | 3 +++ investments/apps.py | 6 +++++ investments/migrations/0001_initial.py | 27 +++++++++++++++++++++++ investments/migrations/__init__.py | 0 investments/models.py | 10 +++++++++ investments/tests.py | 3 +++ investments/views.py | 3 +++ 10 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 core/migrations/0004_delete_investment.py create mode 100644 investments/__init__.py create mode 100644 investments/admin.py create mode 100644 investments/apps.py create mode 100644 investments/migrations/0001_initial.py create mode 100644 investments/migrations/__init__.py create mode 100644 investments/models.py create mode 100644 investments/tests.py create mode 100644 investments/views.py diff --git a/core/migrations/0004_delete_investment.py b/core/migrations/0004_delete_investment.py new file mode 100644 index 000000000..c054ca65c --- /dev/null +++ b/core/migrations/0004_delete_investment.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1 on 2022-08-16 02:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_alter_user_email'), + ] + + operations = [ + migrations.DeleteModel( + name='Investment', + ), + ] diff --git a/core/models.py b/core/models.py index dda565cd4..8104e7e5e 100644 --- a/core/models.py +++ b/core/models.py @@ -11,10 +11,3 @@ class User(AbstractUser): USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] ... - - -class Investment(models.Model): - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - amount = models.FloatField() - active = models.BooleanField(default=True, null=False) - created_at = models.DateTimeField() \ No newline at end of file diff --git a/investments/__init__.py b/investments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/investments/admin.py b/investments/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/investments/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/investments/apps.py b/investments/apps.py new file mode 100644 index 000000000..bec2521a0 --- /dev/null +++ b/investments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvestmentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'investments' diff --git a/investments/migrations/0001_initial.py b/investments/migrations/0001_initial.py new file mode 100644 index 000000000..1bd3a75c7 --- /dev/null +++ b/investments/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1 on 2022-08-16 02:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Investment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/investments/migrations/__init__.py b/investments/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/investments/models.py b/investments/models.py new file mode 100644 index 000000000..b8f3342a2 --- /dev/null +++ b/investments/models.py @@ -0,0 +1,10 @@ +from django.db import models +from django.conf import settings + + +# Create your models here. +class Investment(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + amount = models.FloatField() + active = models.BooleanField(default=True, null=False) + created_at = models.DateTimeField() diff --git a/investments/tests.py b/investments/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/investments/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/investments/views.py b/investments/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/investments/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From dec595ecb3032d6b84a368af6499663c2e36cc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 00:54:37 -0300 Subject: [PATCH 11/28] feat: basic http verbs for investments --- coderockr/settings.py | 3 ++- coderockr/urls.py | 5 ++--- investments/permissions.py | 6 ++++++ investments/serializers.py | 34 ++++++++++++++++++++++++++++++++++ investments/urls.py | 12 ++++++++++++ investments/validators.py | 8 ++++++++ investments/views.py | 32 +++++++++++++++++++++++++++++++- 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 investments/permissions.py create mode 100644 investments/serializers.py create mode 100644 investments/urls.py create mode 100644 investments/validators.py diff --git a/coderockr/settings.py b/coderockr/settings.py index d4b1a1cc2..a512bbfe4 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -41,7 +41,8 @@ 'rest_framework', 'rest_framework.authtoken', 'drf_yasg', - 'core' + 'core', + 'investments' ] MIDDLEWARE = [ diff --git a/coderockr/urls.py b/coderockr/urls.py index de563aab5..ac4766fc1 100644 --- a/coderockr/urls.py +++ b/coderockr/urls.py @@ -15,13 +15,12 @@ """ from django.contrib import admin from django.urls import path, re_path, include -from rest_framework.authtoken.views import obtain_auth_token from . import openapi urlpatterns = [ path('admin/', admin.site.urls), - path(r'login/', obtain_auth_token), re_path(r'^swagger/$', openapi.schema_view.with_ui('swagger', cache_timeout=0), name='swagger-ui'), - path('', include('core.urls')) + path('investments/', include('investments.urls')), + path('', include('core.urls')), ] diff --git a/investments/permissions.py b/investments/permissions.py new file mode 100644 index 000000000..5294fe3ab --- /dev/null +++ b/investments/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsOwnInvestment(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.owner.id == request.user.id \ No newline at end of file diff --git a/investments/serializers.py b/investments/serializers.py new file mode 100644 index 000000000..91091b1ba --- /dev/null +++ b/investments/serializers.py @@ -0,0 +1,34 @@ +from rest_framework.serializers import ModelSerializer +from rest_framework.fields import CurrentUserDefault +from .validators import NotFutureDateValidator +from .models import Investment + + +class InvestmentSerializer(ModelSerializer): + class Meta: + model = Investment + fields = ('id', 'owner', 'amount', 'active', 'created_at') + extra_kwargs = { + 'active': { + 'read_only': True + }, + 'created_at': { + 'validators': [ + NotFutureDateValidator() + ] + }, + 'owner': { + 'read_only': True + } + } + + def create(self, validated_data): + investment = Investment.objects.create( + amount=validated_data['amount'], + created_at=validated_data['created_at'], + owner=self.context.get("request").user + ) + return investment + + def update(self, instance, validated_data): + ... \ No newline at end of file diff --git a/investments/urls.py b/investments/urls.py new file mode 100644 index 000000000..acc5c7771 --- /dev/null +++ b/investments/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import SimpleRouter + +from .views import InvestmentViewSet + + +router = SimpleRouter() +router.register('', InvestmentViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/investments/validators.py b/investments/validators.py new file mode 100644 index 000000000..7f89e3e52 --- /dev/null +++ b/investments/validators.py @@ -0,0 +1,8 @@ +from django.utils.timezone import now +from rest_framework.validators import ValidationError + + +class NotFutureDateValidator: + def __call__(self, value): + if value > now(): + raise ValidationError('This field must not contain a future date.') diff --git a/investments/views.py b/investments/views.py index 91ea44a21..bb3349071 100644 --- a/investments/views.py +++ b/investments/views.py @@ -1,3 +1,33 @@ -from django.shortcuts import render +from rest_framework.viewsets import GenericViewSet +from rest_framework.mixins import (ListModelMixin, RetrieveModelMixin, + DestroyModelMixin) +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action + +from .models import Investment +from .serializers import InvestmentSerializer +from .permissions import IsOwnInvestment + # Create your views here. +class InvestmentViewSet(ListModelMixin, RetrieveModelMixin, + DestroyModelMixin, GenericViewSet): + queryset = Investment.objects.all() + serializer_class = InvestmentSerializer + permission_classes = (IsAuthenticated, IsOwnInvestment) + + def create(self, request): + serializer = self.get_serializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(owner=self.request.user) From 882b920f39cb4908521ddab12a520173870593e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 00:56:03 -0300 Subject: [PATCH 12/28] feat: improves user viewset --- core/urls.py | 8 +++++--- core/views.py | 24 ++++++++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/core/urls.py b/core/urls.py index c845a598a..1a2ff435d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,11 +1,13 @@ from django.urls import path, include -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter +from rest_framework.authtoken.views import obtain_auth_token from .views import UserViewSet -router = DefaultRouter() -router.register('user', UserViewSet) +router = SimpleRouter() +router.register('users', UserViewSet) urlpatterns = [ + path(r'login/', obtain_auth_token), path('', include(router.urls)) ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 69711d4db..aeff0ea7b 100644 --- a/core/views.py +++ b/core/views.py @@ -1,14 +1,30 @@ -from http import HTTPStatus -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet +from rest_framework.mixins import (CreateModelMixin, UpdateModelMixin, + DestroyModelMixin, RetrieveModelMixin) from rest_framework.decorators import action +from rest_framework.response import Response from .permissions import IsOwnProfile from .serializers import UserSerializer from .models import User # Create your views here. -class UserViewSet(ModelViewSet): +class UserViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, + GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer permission_classes = (IsOwnProfile,) + + def retrieve(self, request, *args, **kwargs): + if not request.user.id: + return Response() + serializer = self.get_serializer(request.user) + return Response(serializer.data) + + @action(methods=['get'], detail=False) + def whoami(self, request): + if not request.user.id: + return Response() + serializer = self.get_serializer(data=request.user) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) From a21a4f9abdf4576029d6478bdc1cb0b31a466e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 03:10:57 -0300 Subject: [PATCH 13/28] feat: list gains on investments list --- coderockr/settings.py | 10 ++++- core/views.py | 10 ++--- investments/serializers.py | 8 ++-- investments/services/interest_svc.py | 58 ++++++++++++++++++++++++++++ investments/views.py | 13 ++++--- 5 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 investments/services/interest_svc.py diff --git a/coderockr/settings.py b/coderockr/settings.py index a512bbfe4..140f2c26c 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -148,7 +148,9 @@ 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', - ] + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 10 } LOGIN_REDIRECT_URL='/' @@ -164,4 +166,8 @@ 'in': 'header' } } -} \ No newline at end of file +} + +# Domain specific constants + +CODEROCKR_INTEREST = 0.52 \ No newline at end of file diff --git a/core/views.py b/core/views.py index aeff0ea7b..516d98090 100644 --- a/core/views.py +++ b/core/views.py @@ -15,12 +15,6 @@ class UserViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, serializer_class = UserSerializer permission_classes = (IsOwnProfile,) - def retrieve(self, request, *args, **kwargs): - if not request.user.id: - return Response() - serializer = self.get_serializer(request.user) - return Response(serializer.data) - @action(methods=['get'], detail=False) def whoami(self, request): if not request.user.id: @@ -28,3 +22,7 @@ def whoami(self, request): serializer = self.get_serializer(data=request.user) serializer.is_valid(raise_exception=True) return Response(serializer.data) + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(pk=self.request.user.id) diff --git a/investments/serializers.py b/investments/serializers.py index 91091b1ba..533a65e72 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -1,13 +1,14 @@ -from rest_framework.serializers import ModelSerializer +from rest_framework.serializers import ModelSerializer, FloatField from rest_framework.fields import CurrentUserDefault from .validators import NotFutureDateValidator from .models import Investment class InvestmentSerializer(ModelSerializer): + balance = FloatField() class Meta: model = Investment - fields = ('id', 'owner', 'amount', 'active', 'created_at') + fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at') extra_kwargs = { 'active': { 'read_only': True @@ -29,6 +30,3 @@ def create(self, validated_data): owner=self.context.get("request").user ) return investment - - def update(self, instance, validated_data): - ... \ No newline at end of file diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py new file mode 100644 index 000000000..28c7a56b8 --- /dev/null +++ b/investments/services/interest_svc.py @@ -0,0 +1,58 @@ +from django.db.models import (ExpressionWrapper, FloatField, DateField, + F, Case, When, Value) +from django.db.models.lookups import GreaterThan +from django.db.models.functions import ExtractMonth, Now +from django.conf import settings + + +INTEREST = settings.CODEROCKR_INTEREST + + +def calculate_gain(qs): + qs = ( + qs.alias( + diff_date=ExpressionWrapper( + Now() - F('created_at'), + output_field=DateField() + ), + months=ExtractMonth('diff_date'), + ) + .annotate( + balance=Case( + When( + GreaterThan( + F('months'), + 0 + ), + then=_gain_formula() + ), + default=F('amount') + ) + ) + ) + + return qs + + + + + +def _gain_formula(): + return ExpressionWrapper( + F('amount') * (1+INTEREST) * F('months'), + output_field=FloatField() + ) + + +def calculate_tax(gains, age): + if age < 12: + return _apply_tax(22.5) + + if age < 24: + return _apply_tax(18.5) + + return _apply_tax(15) + + +def _apply_tax(gains, tax): + return gains - (gains*tax/100) \ No newline at end of file diff --git a/investments/views.py b/investments/views.py index bb3349071..565442d49 100644 --- a/investments/views.py +++ b/investments/views.py @@ -1,23 +1,25 @@ from rest_framework.viewsets import GenericViewSet from rest_framework.mixins import (ListModelMixin, RetrieveModelMixin, - DestroyModelMixin) + DestroyModelMixin, CreateModelMixin) from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import action from .models import Investment from .serializers import InvestmentSerializer from .permissions import IsOwnInvestment +from .services import interest_svc # Create your views here. class InvestmentViewSet(ListModelMixin, RetrieveModelMixin, - DestroyModelMixin, GenericViewSet): + DestroyModelMixin, CreateModelMixin, + GenericViewSet): queryset = Investment.objects.all() serializer_class = InvestmentSerializer permission_classes = (IsAuthenticated, IsOwnInvestment) + def create(self, request): serializer = self.get_serializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) @@ -25,9 +27,10 @@ def create(self, request): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) def get_queryset(self): qs = super().get_queryset() + + qs = interest_svc.calculate_gain(qs) + return qs.filter(owner=self.request.user) From aab907ab3899f9a8c66428e564ad87e0b14e7766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 14:34:34 -0300 Subject: [PATCH 14/28] feat: basic withdrawal action --- coderockr/settings.py | 2 +- .../0002_investment_withdrawn_at.py | 18 +++++++++ investments/models.py | 1 + investments/serializers.py | 37 ++++++++++++++++--- investments/services/interest_svc.py | 31 +++++++++++----- investments/validators.py | 6 +++ investments/views.py | 20 +++++++++- 7 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 investments/migrations/0002_investment_withdrawn_at.py diff --git a/coderockr/settings.py b/coderockr/settings.py index 140f2c26c..3a6919299 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -170,4 +170,4 @@ # Domain specific constants -CODEROCKR_INTEREST = 0.52 \ No newline at end of file +CODEROCKR_INTEREST = 0.0052 \ No newline at end of file diff --git a/investments/migrations/0002_investment_withdrawn_at.py b/investments/migrations/0002_investment_withdrawn_at.py new file mode 100644 index 000000000..ef6acbbc5 --- /dev/null +++ b/investments/migrations/0002_investment_withdrawn_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1 on 2022-08-16 06:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('investments', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='investment', + name='withdrawn_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/investments/models.py b/investments/models.py index b8f3342a2..aa7a62dcb 100644 --- a/investments/models.py +++ b/investments/models.py @@ -8,3 +8,4 @@ class Investment(models.Model): amount = models.FloatField() active = models.BooleanField(default=True, null=False) created_at = models.DateTimeField() + withdrawn_at = models.DateTimeField(null=True) diff --git a/investments/serializers.py b/investments/serializers.py index 533a65e72..a78b5c544 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -1,14 +1,14 @@ -from rest_framework.serializers import ModelSerializer, FloatField -from rest_framework.fields import CurrentUserDefault -from .validators import NotFutureDateValidator +from dataclasses import fields +from rest_framework import serializers +from .validators import FutureDateValidator, NotFutureDateValidator from .models import Investment -class InvestmentSerializer(ModelSerializer): - balance = FloatField() +class InvestmentSerializer(serializers.ModelSerializer): + balance = serializers.FloatField(read_only=True) class Meta: model = Investment - fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at') + fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at', 'withdrawn_at') extra_kwargs = { 'active': { 'read_only': True @@ -20,6 +20,9 @@ class Meta: }, 'owner': { 'read_only': True + }, + 'withdrawn_at': { + 'read_only': True } } @@ -30,3 +33,25 @@ def create(self, validated_data): owner=self.context.get("request").user ) return investment + + +class WithdrawalSerializer(serializers.ModelSerializer): + class Meta: + model = Investment + fields = ('withdrawn_at',) + extra_kwargs = { + 'withdrawn_at': { + 'validators': [ + FutureDateValidator() + ] + }, + } + + + def save(self, **kwargs): + self.instance.active = False + return super().save(**kwargs) + + + def to_representation(self, instance): + return InvestmentSerializer().to_representation(instance) diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index 28c7a56b8..e86ba235f 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,7 +1,7 @@ from django.db.models import (ExpressionWrapper, FloatField, DateField, - F, Case, When, Value) + F, Case, When, Q) from django.db.models.lookups import GreaterThan -from django.db.models.functions import ExtractMonth, Now +from django.db.models.functions import ExtractDay, Now from django.conf import settings @@ -11,13 +11,10 @@ def calculate_gain(qs): qs = ( qs.alias( - diff_date=ExpressionWrapper( - Now() - F('created_at'), - output_field=DateField() - ), - months=ExtractMonth('diff_date'), - ) - .annotate( + diff_date=_diff_date(), + days=ExtractDay('diff_date'), + ).annotate( + months=F('days')/30, balance=Case( When( GreaterThan( @@ -34,7 +31,23 @@ def calculate_gain(qs): return qs +def _diff_date(): + def _diff(final_date=None): + if not final_date: + final_date = Now() + return ExpressionWrapper( + final_date - F('created_at'), + output_field=DateField() + ) + + return Case( + When( + Q(withdrawn_at__isnull=True), + then=_diff() + ), + default=_diff(F('withdrawn_at')) + ) def _gain_formula(): diff --git a/investments/validators.py b/investments/validators.py index 7f89e3e52..a15afdd69 100644 --- a/investments/validators.py +++ b/investments/validators.py @@ -6,3 +6,9 @@ class NotFutureDateValidator: def __call__(self, value): if value > now(): raise ValidationError('This field must not contain a future date.') + + +class FutureDateValidator: + def __call__(self, value): + if value < now(): + raise ValidationError('This field must contain a future date.') diff --git a/investments/views.py b/investments/views.py index 565442d49..ea269e31c 100644 --- a/investments/views.py +++ b/investments/views.py @@ -4,9 +4,10 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action from .models import Investment -from .serializers import InvestmentSerializer +from .serializers import InvestmentSerializer, WithdrawalSerializer from .permissions import IsOwnInvestment from .services import interest_svc @@ -34,3 +35,20 @@ def get_queryset(self): qs = interest_svc.calculate_gain(qs) return qs.filter(owner=self.request.user) + + + @action( + methods=['post'], + detail=True, + serializer_class=WithdrawalSerializer + ) + def withdrawal(self, request, pk): + investment = self.get_queryset().get(pk=pk) + serializer = WithdrawalSerializer( + instance=investment, + data=request.POST.dict(), + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) From e378223a68ec1e41c20897cfba10c25670e4e17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 20:06:50 -0300 Subject: [PATCH 15/28] refact: moves common fixtures to conftest feat: moves balance logic to model property fix: changes success status code for withdrawn action test: implements tests for investment views --- conftest.py | 30 ++++++ core/tests/conftest.py | 9 -- core/tests/test_pytest.py | 2 - investments/models.py | 14 +++ investments/serializers.py | 18 ++-- investments/services/interest_svc.py | 58 ++--------- investments/tests/__init__.py | 0 investments/tests/test_interest.py | 143 +++++++++++++++++++++++++++ investments/utils.py | 2 + investments/views.py | 25 ++--- 10 files changed, 216 insertions(+), 85 deletions(-) create mode 100644 conftest.py delete mode 100644 core/tests/conftest.py delete mode 100644 core/tests/test_pytest.py create mode 100644 investments/tests/__init__.py create mode 100644 investments/tests/test_interest.py create mode 100644 investments/utils.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..d185bb71a --- /dev/null +++ b/conftest.py @@ -0,0 +1,30 @@ +import pytest +from model_bakery import baker +from rest_framework.authtoken.models import Token +from core.models import User + +@pytest.fixture +def create_token(): + def wrapper(user): + token = Token.objects.create(user=user).key + return f"Token {token}" + + return wrapper + + +@pytest.fixture +def user_jorge(): + return User.objects.create_user( + username="ains", + email="jorge@outlook.com", + password="jorge123" + ) + + +@pytest.fixture +def user_ains(): + return User.objects.create_user( + username="ains", + email="ains@nazarick.com", + password="AinsOoalGownRulesItAll" + ) diff --git a/core/tests/conftest.py b/core/tests/conftest.py deleted file mode 100644 index 284b4b09d..000000000 --- a/core/tests/conftest.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest -from rest_framework.authtoken.models import Token - -@pytest.fixture -def create_token_for_user(): - def wrapper(user): - return Token.objects.create(user=user) - - return wrapper diff --git a/core/tests/test_pytest.py b/core/tests/test_pytest.py deleted file mode 100644 index b57456544..000000000 --- a/core/tests/test_pytest.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_pytest(): - assert 1 == 1 \ No newline at end of file diff --git a/investments/models.py b/investments/models.py index aa7a62dcb..abe9aa6d7 100644 --- a/investments/models.py +++ b/investments/models.py @@ -1,5 +1,8 @@ from django.db import models from django.conf import settings +from django.utils.timezone import now +from .services import interest_svc +from .utils import diff_month # Create your models here. @@ -9,3 +12,14 @@ class Investment(models.Model): active = models.BooleanField(default=True, null=False) created_at = models.DateTimeField() withdrawn_at = models.DateTimeField(null=True) + + @property + def balance(self): + start_date = self.withdrawn_at if self.withdrawn_at else now() + age = diff_month(start_date, self.created_at) + gains = interest_svc.gain_formula(self.amount, age) + + if self.active: + return gains + + return interest_svc.calculate_tax(gains-self.amount, age) diff --git a/investments/serializers.py b/investments/serializers.py index a78b5c544..edb54765f 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -5,24 +5,16 @@ class InvestmentSerializer(serializers.ModelSerializer): - balance = serializers.FloatField(read_only=True) + balance = serializers.ReadOnlyField() class Meta: model = Investment - fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at', 'withdrawn_at') + fields = '__all__' + read_only_fields = ('active', 'owner', 'withdrawn_at') extra_kwargs = { - 'active': { - 'read_only': True - }, 'created_at': { 'validators': [ NotFutureDateValidator() ] - }, - 'owner': { - 'read_only': True - }, - 'withdrawn_at': { - 'read_only': True } } @@ -36,9 +28,11 @@ def create(self, validated_data): class WithdrawalSerializer(serializers.ModelSerializer): + balance = serializers.ReadOnlyField() class Meta: model = Investment - fields = ('withdrawn_at',) + fields = '__all__' + read_only_fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at') extra_kwargs = { 'withdrawn_at': { 'validators': [ diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index e86ba235f..3998d9881 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,6 +1,7 @@ +import math from django.db.models import (ExpressionWrapper, FloatField, DateField, F, Case, When, Q) -from django.db.models.lookups import GreaterThan +from django.db.models.lookups import GreaterThan, LessThan from django.db.models.functions import ExtractDay, Now from django.conf import settings @@ -8,63 +9,18 @@ INTEREST = settings.CODEROCKR_INTEREST -def calculate_gain(qs): - qs = ( - qs.alias( - diff_date=_diff_date(), - days=ExtractDay('diff_date'), - ).annotate( - months=F('days')/30, - balance=Case( - When( - GreaterThan( - F('months'), - 0 - ), - then=_gain_formula() - ), - default=F('amount') - ) - ) - ) - - return qs - - -def _diff_date(): - def _diff(final_date=None): - if not final_date: - final_date = Now() - - return ExpressionWrapper( - final_date - F('created_at'), - output_field=DateField() - ) - - return Case( - When( - Q(withdrawn_at__isnull=True), - then=_diff() - ), - default=_diff(F('withdrawn_at')) - ) - - -def _gain_formula(): - return ExpressionWrapper( - F('amount') * (1+INTEREST) * F('months'), - output_field=FloatField() - ) +def gain_formula(amount, months, total=False): + return amount * math.pow((1+INTEREST), months) def calculate_tax(gains, age): if age < 12: - return _apply_tax(22.5) + return _apply_tax(gains, 22.5) if age < 24: - return _apply_tax(18.5) + return _apply_tax(gains, 18.5) - return _apply_tax(15) + return _apply_tax(gains, 15) def _apply_tax(gains, tax): diff --git a/investments/tests/__init__.py b/investments/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py new file mode 100644 index 000000000..28bf06d93 --- /dev/null +++ b/investments/tests/test_interest.py @@ -0,0 +1,143 @@ +import json +from django.utils.timezone import now, timedelta +from model_bakery import baker +from rest_framework import status + +from ..serializers import InvestmentSerializer +from ..services import interest_svc + + +def test_create_investment(db, client, create_token, user_ains): + data = {'amount': 100, 'created_at': now().isoformat()} + token = create_token(user_ains) + + response = client.post('/investments/', + data=json.dumps(data), + content_type='application/json', + HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert response_data['amount'] == data['amount'] + + +def test_create_investment_future_date(db, client, create_token, user_ains): + created_at = now() + timedelta(days=5) + data = {'amount': 100, 'created_at': created_at.isoformat()} + token = create_token(user_ains) + + response = client.post('/investments/', + data=json.dumps(data), + content_type='application/json', + HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_investment_past_date(db, client, create_token, user_ains): + created_at = now() - timedelta(days=5) + data = {'amount': 100, 'created_at': created_at.isoformat()} + token = create_token(user_ains) + + response = client.post('/investments/', + data=json.dumps(data), + content_type='application/json', + HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert response_data['amount'] == data['amount'] + + +def test_withdrawn_investment(db, client, create_token, user_ains): + baker.make('investments.Investment', pk=1, amount=100, + created_at=now(), owner=user_ains) + withdrawn_at = now() + timedelta(days=365) # 12 meses + data = {'withdrawn_at': withdrawn_at.isoformat()} + token = create_token(user_ains) + + response = client.post('/investments/1/withdrawn/', + data=json.dumps(data), + content_type='application/json', + HTTP_AUTHORIZATION=token) + response_data = response.json() + + expected_gain = interest_svc.gain_formula(100, 12) + expected_gain_with_taxes = interest_svc._apply_tax(expected_gain, 18.5) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response_data['balance'] == expected_gain_with_taxes + + +def test_withdrawn_investment_past_date(db, client, create_token, user_ains): + baker.make('investments.Investment', pk=1, amount=100, + created_at=now(), owner=user_ains) + withdrawn_at = now() - timedelta(days=60) # 2 meses atrás + data = {'withdrawn_at': withdrawn_at.isoformat()} + token = create_token(user_ains) + + response = client.post('/investments/1/withdrawn/', + data=json.dumps(data), + content_type='application/json', + HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_get_investment(db, client, create_token, user_ains): + investment = baker.make('investments.Investment', pk=1, amount=100, + created_at=now(), owner=user_ains) + token = create_token(user_ains) + serialized_investment = InvestmentSerializer(investment).data + + response = client.get('/investments/1/', HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data == serialized_investment + + +def test_get_investment_other_owner(db, client, create_token, user_ains): + investment = baker.make('investments.Investment', pk=1, amount=100, + created_at=now()) + token = create_token(user_ains) + + response = client.get('/investments/1/', HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_list_investments(db, client, create_token, user_ains): + investments = baker.make('investments.Investment', amount=100, + created_at=now(), _quantity=10, + owner=user_ains) + token = create_token(user_ains) + + response = client.get('/investments/', HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + + response_investments_id = [ + investment['id'] + for investment in response_data['results'] + ] + + for investment in investments: + assert investment.id in response_investments_id + + +def test_list_investments(db, client, create_token, user_ains): + baker.make('investments.Investment', amount=100, + created_at=now(), _quantity=15, + owner=user_ains) + token = create_token(user_ains) + + response = client.get('/investments/', {'offset': 10}, + HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data['count'] == 15 + assert response_data['previous'] + assert not response_data['next'] diff --git a/investments/utils.py b/investments/utils.py new file mode 100644 index 000000000..6e646db1a --- /dev/null +++ b/investments/utils.py @@ -0,0 +1,2 @@ +def diff_month(d1, d2): + return (d1.year - d2.year) * 12 + d1.month - d2.month \ No newline at end of file diff --git a/investments/views.py b/investments/views.py index ea269e31c..2e16c37a1 100644 --- a/investments/views.py +++ b/investments/views.py @@ -1,6 +1,6 @@ from rest_framework.viewsets import GenericViewSet from rest_framework.mixins import (ListModelMixin, RetrieveModelMixin, - DestroyModelMixin, CreateModelMixin) + CreateModelMixin) from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -9,13 +9,11 @@ from .models import Investment from .serializers import InvestmentSerializer, WithdrawalSerializer from .permissions import IsOwnInvestment -from .services import interest_svc # Create your views here. class InvestmentViewSet(ListModelMixin, RetrieveModelMixin, - DestroyModelMixin, CreateModelMixin, - GenericViewSet): + CreateModelMixin, GenericViewSet): queryset = Investment.objects.all() serializer_class = InvestmentSerializer permission_classes = (IsAuthenticated, IsOwnInvestment) @@ -31,9 +29,6 @@ def create(self, request): def get_queryset(self): qs = super().get_queryset() - - qs = interest_svc.calculate_gain(qs) - return qs.filter(owner=self.request.user) @@ -42,13 +37,21 @@ def get_queryset(self): detail=True, serializer_class=WithdrawalSerializer ) - def withdrawal(self, request, pk): + def withdrawn(self, request, pk): investment = self.get_queryset().get(pk=pk) + + if not investment.active: + return Response(status=status.HTTP_400_BAD_REQUEST) + serializer = WithdrawalSerializer( instance=investment, - data=request.POST.dict(), + data=request.data, context={'request': request} ) serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) + investment = serializer.save() + + return Response( + WithdrawalSerializer().to_representation(investment), + status=status.HTTP_202_ACCEPTED + ) From b1fa1179a15b437f2514ec4576d802fd1330857b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 20:41:15 -0300 Subject: [PATCH 16/28] style: applies black formatter --- conftest.py | 29 ++-- core/apps.py | 4 +- core/models.py | 8 +- core/permissions.py | 4 +- core/serializers.py | 52 +++---- core/tests/test_user.py | 100 ++++++------ core/urls.py | 8 +- core/views.py | 42 +++-- investments/apps.py | 4 +- investments/models.py | 26 ++-- investments/permissions.py | 4 +- investments/serializers.py | 79 +++++----- investments/services/interest_svc.py | 29 ++-- investments/tests/test_interest.py | 224 +++++++++++++++------------ investments/urls.py | 4 +- investments/utils.py | 2 +- investments/validators.py | 12 +- investments/views.py | 88 ++++++----- poetry.lock | 74 ++++++++- pyproject.toml | 15 +- 20 files changed, 471 insertions(+), 337 deletions(-) diff --git a/conftest.py b/conftest.py index d185bb71a..d7cf00acd 100644 --- a/conftest.py +++ b/conftest.py @@ -3,28 +3,29 @@ from rest_framework.authtoken.models import Token from core.models import User + @pytest.fixture def create_token(): - def wrapper(user): - token = Token.objects.create(user=user).key - return f"Token {token}" + def wrapper(user): + token = Token.objects.create(user=user).key + return f"Token {token}" - return wrapper + return wrapper @pytest.fixture def user_jorge(): - return User.objects.create_user( - username="ains", - email="jorge@outlook.com", - password="jorge123" - ) + return User.objects.create_user( + username="ains", + email="jorge@outlook.com", + password="jorge123", + ) @pytest.fixture def user_ains(): - return User.objects.create_user( - username="ains", - email="ains@nazarick.com", - password="AinsOoalGownRulesItAll" - ) + return User.objects.create_user( + username="ains", + email="ains@nazarick.com", + password="AinsOoalGownRulesItAll", + ) diff --git a/core/apps.py b/core/apps.py index 8115ae60b..c0ce093bd 100644 --- a/core/apps.py +++ b/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/core/models.py b/core/models.py index 8104e7e5e..21fca586a 100644 --- a/core/models.py +++ b/core/models.py @@ -6,8 +6,8 @@ # Create your models here. class User(AbstractUser): - email = models.EmailField(_('email address'), unique=True) + email = models.EmailField(_("email address"), unique=True) - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = [] - ... + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + ... diff --git a/core/permissions.py b/core/permissions.py index bc2e216f0..a735926b7 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -2,5 +2,5 @@ class IsOwnProfile(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.id == request.user.id + def has_object_permission(self, request, view, obj): + return obj.id == request.user.id diff --git a/core/serializers.py b/core/serializers.py index 9a8e0105a..5e160464a 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -4,38 +4,28 @@ class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ('id', 'email', 'password') - extra_kwargs = { - 'email': { - 'validators': [ - UniqueValidator( - queryset=User.objects.all() - ) - ] - }, - 'password': { - 'write_only': True, - 'style': { - 'input_type': 'password' + class Meta: + model = User + fields = ("id", "email", "password") + extra_kwargs = { + "email": {"validators": [UniqueValidator(queryset=User.objects.all())]}, + "password": { + "write_only": True, + "style": {"input_type": "password"}, + }, } - } - } + def create(self, validated_data): + user = User.objects.create_user( + email=validated_data["email"], + password=validated_data["password"], + username=validated_data["email"], + ) - def create(self, validated_data): - user = User.objects.create_user( - email=validated_data['email'], - password=validated_data['password'], - username=validated_data['email'] - ) + return user - return user - - - def update(self, instance, validated_data): - if 'password' in validated_data: - password = validated_data.pop('password') - instance.set_password(password) - return super().update(instance, validated_data) + def update(self, instance, validated_data): + if "password" in validated_data: + password = validated_data.pop("password") + instance.set_password(password) + return super().update(instance, validated_data) diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 4739ad1ce..2c6a9a912 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -5,75 +5,83 @@ def test_create(db, client): - data = { 'email': 'jorge@gmail.com', 'password': 'jorge' } - - response = client.post('/user/', data=json.dumps(data), content_type='application/json') - response_data = response.json() - - assert response.status_code == HTTPStatus.CREATED - assert response_data['email'] == data['email'] + data = {"email": "jorge@gmail.com", "password": "jorge"} + + response = client.post( + "/user/", + data=json.dumps(data), + content_type="application/json", + ) + response_data = response.json() + + assert response.status_code == HTTPStatus.CREATED + assert response_data["email"] == data["email"] def test_create_duplicate(db, client): - data = { 'email': 'robson@gmail.com', 'password': 'jorge' } - - User.objects.create_user(**data, username=data['email']) + data = {"email": "robson@gmail.com", "password": "jorge"} - response = client.post('/user/', data=json.dumps(data), content_type='application/json') + User.objects.create_user(**data, username=data["email"]) - print(User.objects.all()) + response = client.post( + "/user/", + data=json.dumps(data), + content_type="application/json", + ) - assert response.status_code == HTTPStatus.BAD_REQUEST + print(User.objects.all()) + + assert response.status_code == HTTPStatus.BAD_REQUEST def test_update(db, client, create_token_for_user): - user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') - token = 'Token ' + create_token_for_user(user_jorge).key - data = { 'email': 'jorge@gmail.com' } + user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") + token = "Token " + create_token_for_user(user_jorge).key + data = {"email": "jorge@gmail.com"} - response = client.patch( - '/user/1/', - data=json.dumps(data), - HTTP_AUTHORIZATION=token, - content_type='application/json' - ) - response_data = response.json() + response = client.patch( + "/user/1/", + data=json.dumps(data), + HTTP_AUTHORIZATION=token, + content_type="application/json", + ) + response_data = response.json() - assert response.status_code == HTTPStatus.OK - assert response_data['email'] == data['email'] + assert response.status_code == HTTPStatus.OK + assert response_data["email"] == data["email"] def test_update_other_user(db, client, create_token_for_user): - user_jorge = baker.make('core.User', id=1, email='jorge@gmail.com') - baker.make('core.User', id=2, email='marcio@marcio.com') - token_jorge = 'Token ' + create_token_for_user(user_jorge).key - data = { 'email': 'marcio@gmail.com' } + user_jorge = baker.make("core.User", id=1, email="jorge@gmail.com") + baker.make("core.User", id=2, email="marcio@marcio.com") + token_jorge = "Token " + create_token_for_user(user_jorge).key + data = {"email": "marcio@gmail.com"} - response = client.patch( - '/user/2/', - data=json.dumps(data), - HTTP_AUTHORIZATION=token_jorge, - content_type='application/json' - ) + response = client.patch( + "/user/2/", + data=json.dumps(data), + HTTP_AUTHORIZATION=token_jorge, + content_type="application/json", + ) - assert response.status_code == HTTPStatus.FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN def test_delete(db, client, create_token_for_user): - user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') - token = 'Token ' + create_token_for_user(user_jorge).key + user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") + token = "Token " + create_token_for_user(user_jorge).key - response = client.delete('/user/1/', HTTP_AUTHORIZATION=token) + response = client.delete("/user/1/", HTTP_AUTHORIZATION=token) - assert response.status_code == HTTPStatus.NO_CONTENT + assert response.status_code == HTTPStatus.NO_CONTENT def test_delete_other_user(db, client, create_token_for_user): - user_jorge = baker.make('core.User', id=1, email='jorge@jorge.com') - baker.make('core.User', id=2, email='marcio@marcio.com') - - token = 'Token ' + create_token_for_user(user_jorge).key + user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") + baker.make("core.User", id=2, email="marcio@marcio.com") + + token = "Token " + create_token_for_user(user_jorge).key - response = client.delete('/user/2/', HTTP_AUTHORIZATION=token) + response = client.delete("/user/2/", HTTP_AUTHORIZATION=token) - assert response.status_code == HTTPStatus.FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/core/urls.py b/core/urls.py index 1a2ff435d..9b2b202e8 100644 --- a/core/urls.py +++ b/core/urls.py @@ -5,9 +5,9 @@ router = SimpleRouter() -router.register('users', UserViewSet) +router.register("users", UserViewSet) urlpatterns = [ - path(r'login/', obtain_auth_token), - path('', include(router.urls)) -] \ No newline at end of file + path(r"login/", obtain_auth_token), + path("", include(router.urls)), +] diff --git a/core/views.py b/core/views.py index 516d98090..aed1906d5 100644 --- a/core/views.py +++ b/core/views.py @@ -1,6 +1,10 @@ from rest_framework.viewsets import GenericViewSet -from rest_framework.mixins import (CreateModelMixin, UpdateModelMixin, - DestroyModelMixin, RetrieveModelMixin) +from rest_framework.mixins import ( + CreateModelMixin, + UpdateModelMixin, + DestroyModelMixin, + RetrieveModelMixin, +) from rest_framework.decorators import action from rest_framework.response import Response @@ -9,20 +13,24 @@ from .models import User # Create your views here. -class UserViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, - GenericViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - permission_classes = (IsOwnProfile,) +class UserViewSet( + CreateModelMixin, + UpdateModelMixin, + DestroyModelMixin, + GenericViewSet, +): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = (IsOwnProfile,) - @action(methods=['get'], detail=False) - def whoami(self, request): - if not request.user.id: - return Response() - serializer = self.get_serializer(data=request.user) - serializer.is_valid(raise_exception=True) - return Response(serializer.data) + @action(methods=["get"], detail=False) + def whoami(self, request): + if not request.user.id: + return Response() + serializer = self.get_serializer(data=request.user) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) - def get_queryset(self): - qs = super().get_queryset() - return qs.filter(pk=self.request.user.id) + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(pk=self.request.user.id) diff --git a/investments/apps.py b/investments/apps.py index bec2521a0..310f067bc 100644 --- a/investments/apps.py +++ b/investments/apps.py @@ -2,5 +2,5 @@ class InvestmentsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'investments' + default_auto_field = "django.db.models.BigAutoField" + name = "investments" diff --git a/investments/models.py b/investments/models.py index abe9aa6d7..c42311467 100644 --- a/investments/models.py +++ b/investments/models.py @@ -7,19 +7,19 @@ # Create your models here. class Investment(models.Model): - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - amount = models.FloatField() - active = models.BooleanField(default=True, null=False) - created_at = models.DateTimeField() - withdrawn_at = models.DateTimeField(null=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + amount = models.FloatField() + active = models.BooleanField(default=True, null=False) + created_at = models.DateTimeField() + withdrawn_at = models.DateTimeField(null=True) - @property - def balance(self): - start_date = self.withdrawn_at if self.withdrawn_at else now() - age = diff_month(start_date, self.created_at) - gains = interest_svc.gain_formula(self.amount, age) + @property + def balance(self): + start_date = self.withdrawn_at if self.withdrawn_at else now() + age = diff_month(start_date, self.created_at) + gains = interest_svc.gain_formula(self.amount, age) - if self.active: - return gains + if self.active: + return gains - return interest_svc.calculate_tax(gains-self.amount, age) + return interest_svc.calculate_tax(gains - self.amount, age) diff --git a/investments/permissions.py b/investments/permissions.py index 5294fe3ab..a0b304580 100644 --- a/investments/permissions.py +++ b/investments/permissions.py @@ -2,5 +2,5 @@ class IsOwnInvestment(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.owner.id == request.user.id \ No newline at end of file + def has_object_permission(self, request, view, obj): + return obj.owner.id == request.user.id diff --git a/investments/serializers.py b/investments/serializers.py index edb54765f..754fe25b7 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -5,47 +5,44 @@ class InvestmentSerializer(serializers.ModelSerializer): - balance = serializers.ReadOnlyField() - class Meta: - model = Investment - fields = '__all__' - read_only_fields = ('active', 'owner', 'withdrawn_at') - extra_kwargs = { - 'created_at': { - 'validators': [ - NotFutureDateValidator() - ] - } - } - - def create(self, validated_data): - investment = Investment.objects.create( - amount=validated_data['amount'], - created_at=validated_data['created_at'], - owner=self.context.get("request").user - ) - return investment + balance = serializers.ReadOnlyField() + + class Meta: + model = Investment + fields = "__all__" + read_only_fields = ("active", "owner", "withdrawn_at") + extra_kwargs = {"created_at": {"validators": [NotFutureDateValidator()]}} + + def create(self, validated_data): + investment = Investment.objects.create( + amount=validated_data["amount"], + created_at=validated_data["created_at"], + owner=self.context.get("request").user, + ) + return investment class WithdrawalSerializer(serializers.ModelSerializer): - balance = serializers.ReadOnlyField() - class Meta: - model = Investment - fields = '__all__' - read_only_fields = ('id', 'owner', 'amount', 'balance', 'active', 'created_at') - extra_kwargs = { - 'withdrawn_at': { - 'validators': [ - FutureDateValidator() - ] - }, - } - - - def save(self, **kwargs): - self.instance.active = False - return super().save(**kwargs) - - - def to_representation(self, instance): - return InvestmentSerializer().to_representation(instance) + balance = serializers.ReadOnlyField() + + class Meta: + model = Investment + fields = "__all__" + read_only_fields = ( + "id", + "owner", + "amount", + "balance", + "active", + "created_at", + ) + extra_kwargs = { + "withdrawn_at": {"validators": [FutureDateValidator()]}, + } + + def save(self, **kwargs): + self.instance.active = False + return super().save(**kwargs) + + def to_representation(self, instance): + return InvestmentSerializer().to_representation(instance) diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index 3998d9881..429b68930 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,6 +1,13 @@ import math -from django.db.models import (ExpressionWrapper, FloatField, DateField, - F, Case, When, Q) +from django.db.models import ( + ExpressionWrapper, + FloatField, + DateField, + F, + Case, + When, + Q, +) from django.db.models.lookups import GreaterThan, LessThan from django.db.models.functions import ExtractDay, Now from django.conf import settings @@ -10,18 +17,18 @@ def gain_formula(amount, months, total=False): - return amount * math.pow((1+INTEREST), months) + return amount * math.pow((1 + INTEREST), months) def calculate_tax(gains, age): - if age < 12: - return _apply_tax(gains, 22.5) - - if age < 24: - return _apply_tax(gains, 18.5) - - return _apply_tax(gains, 15) + if age < 12: + return _apply_tax(gains, 22.5) + + if age < 24: + return _apply_tax(gains, 18.5) + + return _apply_tax(gains, 15) def _apply_tax(gains, tax): - return gains - (gains*tax/100) \ No newline at end of file + return gains - (gains * tax / 100) diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py index 28bf06d93..e17cedcbe 100644 --- a/investments/tests/test_interest.py +++ b/investments/tests/test_interest.py @@ -8,136 +8,168 @@ def test_create_investment(db, client, create_token, user_ains): - data = {'amount': 100, 'created_at': now().isoformat()} - token = create_token(user_ains) + data = {"amount": 100, "created_at": now().isoformat()} + token = create_token(user_ains) - response = client.post('/investments/', - data=json.dumps(data), - content_type='application/json', - HTTP_AUTHORIZATION=token) - response_data = response.json() + response = client.post( + "/investments/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) + response_data = response.json() - assert response.status_code == status.HTTP_201_CREATED - assert response_data['amount'] == data['amount'] + assert response.status_code == status.HTTP_201_CREATED + assert response_data["amount"] == data["amount"] def test_create_investment_future_date(db, client, create_token, user_ains): - created_at = now() + timedelta(days=5) - data = {'amount': 100, 'created_at': created_at.isoformat()} - token = create_token(user_ains) + created_at = now() + timedelta(days=5) + data = {"amount": 100, "created_at": created_at.isoformat()} + token = create_token(user_ains) - response = client.post('/investments/', - data=json.dumps(data), - content_type='application/json', - HTTP_AUTHORIZATION=token) + response = client.post( + "/investments/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_create_investment_past_date(db, client, create_token, user_ains): - created_at = now() - timedelta(days=5) - data = {'amount': 100, 'created_at': created_at.isoformat()} - token = create_token(user_ains) + created_at = now() - timedelta(days=5) + data = {"amount": 100, "created_at": created_at.isoformat()} + token = create_token(user_ains) - response = client.post('/investments/', - data=json.dumps(data), - content_type='application/json', - HTTP_AUTHORIZATION=token) - response_data = response.json() + response = client.post( + "/investments/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) + response_data = response.json() - assert response.status_code == status.HTTP_201_CREATED - assert response_data['amount'] == data['amount'] + assert response.status_code == status.HTTP_201_CREATED + assert response_data["amount"] == data["amount"] def test_withdrawn_investment(db, client, create_token, user_ains): - baker.make('investments.Investment', pk=1, amount=100, - created_at=now(), owner=user_ains) - withdrawn_at = now() + timedelta(days=365) # 12 meses - data = {'withdrawn_at': withdrawn_at.isoformat()} - token = create_token(user_ains) - - response = client.post('/investments/1/withdrawn/', - data=json.dumps(data), - content_type='application/json', - HTTP_AUTHORIZATION=token) - response_data = response.json() - - expected_gain = interest_svc.gain_formula(100, 12) - expected_gain_with_taxes = interest_svc._apply_tax(expected_gain, 18.5) - - assert response.status_code == status.HTTP_202_ACCEPTED - assert response_data['balance'] == expected_gain_with_taxes + baker.make( + "investments.Investment", + pk=1, + amount=100, + created_at=now(), + owner=user_ains, + ) + withdrawn_at = now() + timedelta(days=365) # 12 meses + data = {"withdrawn_at": withdrawn_at.isoformat()} + token = create_token(user_ains) + + response = client.post( + "/investments/1/withdrawn/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) + response_data = response.json() + + expected_gain = interest_svc.gain_formula(100, 12) + expected_gain_with_taxes = interest_svc._apply_tax(expected_gain, 18.5) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response_data["balance"] == expected_gain_with_taxes def test_withdrawn_investment_past_date(db, client, create_token, user_ains): - baker.make('investments.Investment', pk=1, amount=100, - created_at=now(), owner=user_ains) - withdrawn_at = now() - timedelta(days=60) # 2 meses atrás - data = {'withdrawn_at': withdrawn_at.isoformat()} - token = create_token(user_ains) - - response = client.post('/investments/1/withdrawn/', - data=json.dumps(data), - content_type='application/json', - HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_400_BAD_REQUEST + baker.make( + "investments.Investment", + pk=1, + amount=100, + created_at=now(), + owner=user_ains, + ) + withdrawn_at = now() - timedelta(days=60) # 2 meses atrás + data = {"withdrawn_at": withdrawn_at.isoformat()} + token = create_token(user_ains) + + response = client.post( + "/investments/1/withdrawn/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST def test_get_investment(db, client, create_token, user_ains): - investment = baker.make('investments.Investment', pk=1, amount=100, - created_at=now(), owner=user_ains) - token = create_token(user_ains) - serialized_investment = InvestmentSerializer(investment).data + investment = baker.make( + "investments.Investment", + pk=1, + amount=100, + created_at=now(), + owner=user_ains, + ) + token = create_token(user_ains) + serialized_investment = InvestmentSerializer(investment).data - response = client.get('/investments/1/', HTTP_AUTHORIZATION=token) - response_data = response.json() + response = client.get("/investments/1/", HTTP_AUTHORIZATION=token) + response_data = response.json() - assert response.status_code == status.HTTP_200_OK - assert response_data == serialized_investment + assert response.status_code == status.HTTP_200_OK + assert response_data == serialized_investment def test_get_investment_other_owner(db, client, create_token, user_ains): - investment = baker.make('investments.Investment', pk=1, amount=100, - created_at=now()) - token = create_token(user_ains) - - response = client.get('/investments/1/', HTTP_AUTHORIZATION=token) + investment = baker.make( + "investments.Investment", pk=1, amount=100, created_at=now() + ) + token = create_token(user_ains) + + response = client.get("/investments/1/", HTTP_AUTHORIZATION=token) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_404_NOT_FOUND def test_list_investments(db, client, create_token, user_ains): - investments = baker.make('investments.Investment', amount=100, - created_at=now(), _quantity=10, - owner=user_ains) - token = create_token(user_ains) + investments = baker.make( + "investments.Investment", + amount=100, + created_at=now(), + _quantity=10, + owner=user_ains, + ) + token = create_token(user_ains) - response = client.get('/investments/', HTTP_AUTHORIZATION=token) - response_data = response.json() + response = client.get("/investments/", HTTP_AUTHORIZATION=token) + response_data = response.json() - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK - response_investments_id = [ - investment['id'] - for investment in response_data['results'] - ] + response_investments_id = [ + investment["id"] for investment in response_data["results"] + ] - for investment in investments: - assert investment.id in response_investments_id + for investment in investments: + assert investment.id in response_investments_id def test_list_investments(db, client, create_token, user_ains): - baker.make('investments.Investment', amount=100, - created_at=now(), _quantity=15, - owner=user_ains) - token = create_token(user_ains) - - response = client.get('/investments/', {'offset': 10}, - HTTP_AUTHORIZATION=token) - response_data = response.json() - - assert response.status_code == status.HTTP_200_OK - assert response_data['count'] == 15 - assert response_data['previous'] - assert not response_data['next'] + baker.make( + "investments.Investment", + amount=100, + created_at=now(), + _quantity=15, + owner=user_ains, + ) + token = create_token(user_ains) + + response = client.get("/investments/", {"offset": 10}, HTTP_AUTHORIZATION=token) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data["count"] == 15 + assert response_data["previous"] + assert not response_data["next"] diff --git a/investments/urls.py b/investments/urls.py index acc5c7771..41e1aae40 100644 --- a/investments/urls.py +++ b/investments/urls.py @@ -5,8 +5,8 @@ router = SimpleRouter() -router.register('', InvestmentViewSet) +router.register("", InvestmentViewSet) urlpatterns = [ - path('', include(router.urls)), + path("", include(router.urls)), ] diff --git a/investments/utils.py b/investments/utils.py index 6e646db1a..16c5c32ab 100644 --- a/investments/utils.py +++ b/investments/utils.py @@ -1,2 +1,2 @@ def diff_month(d1, d2): - return (d1.year - d2.year) * 12 + d1.month - d2.month \ No newline at end of file + return (d1.year - d2.year) * 12 + d1.month - d2.month diff --git a/investments/validators.py b/investments/validators.py index a15afdd69..fdc1f9148 100644 --- a/investments/validators.py +++ b/investments/validators.py @@ -3,12 +3,12 @@ class NotFutureDateValidator: - def __call__(self, value): - if value > now(): - raise ValidationError('This field must not contain a future date.') + def __call__(self, value): + if value > now(): + raise ValidationError("This field must not contain a future date.") class FutureDateValidator: - def __call__(self, value): - if value < now(): - raise ValidationError('This field must contain a future date.') + def __call__(self, value): + if value < now(): + raise ValidationError("This field must contain a future date.") diff --git a/investments/views.py b/investments/views.py index 2e16c37a1..0556dd93f 100644 --- a/investments/views.py +++ b/investments/views.py @@ -1,6 +1,9 @@ from rest_framework.viewsets import GenericViewSet -from rest_framework.mixins import (ListModelMixin, RetrieveModelMixin, - CreateModelMixin) +from rest_framework.mixins import ( + ListModelMixin, + RetrieveModelMixin, + CreateModelMixin, +) from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -12,46 +15,53 @@ # Create your views here. -class InvestmentViewSet(ListModelMixin, RetrieveModelMixin, - CreateModelMixin, GenericViewSet): - queryset = Investment.objects.all() - serializer_class = InvestmentSerializer - permission_classes = (IsAuthenticated, IsOwnInvestment) +class InvestmentViewSet( + ListModelMixin, + RetrieveModelMixin, + CreateModelMixin, + GenericViewSet, +): + queryset = Investment.objects.all() + serializer_class = InvestmentSerializer + permission_classes = (IsAuthenticated, IsOwnInvestment) + def create(self, request): + serializer = self.get_serializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) - def create(self, request): - serializer = self.get_serializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - - def get_queryset(self): - qs = super().get_queryset() - return qs.filter(owner=self.request.user) + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(owner=self.request.user) + @action( + methods=["post"], + detail=True, + serializer_class=WithdrawalSerializer, + ) + def withdrawn(self, request, pk): + investment = self.get_queryset().get(pk=pk) - @action( - methods=['post'], - detail=True, - serializer_class=WithdrawalSerializer - ) - def withdrawn(self, request, pk): - investment = self.get_queryset().get(pk=pk) + if not investment.active: + return Response(status=status.HTTP_400_BAD_REQUEST) - if not investment.active: - return Response(status=status.HTTP_400_BAD_REQUEST) + serializer = WithdrawalSerializer( + instance=investment, + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + investment = serializer.save() - serializer = WithdrawalSerializer( - instance=investment, - data=request.data, - context={'request': request} - ) - serializer.is_valid(raise_exception=True) - investment = serializer.save() - - return Response( - WithdrawalSerializer().to_representation(investment), - status=status.HTTP_202_ACCEPTED - ) + return Response( + WithdrawalSerializer().to_representation(investment), + status=status.HTTP_202_ACCEPTED, + ) diff --git a/poetry.lock b/poetry.lock index c9bfec48c..6daf36909 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,6 +31,27 @@ tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900, docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"] dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" version = "2022.6.15" @@ -50,11 +71,22 @@ python-versions = ">=3.6.0" [package.extras] unicode_backport = ["unicodedata2"] +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.5" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -229,6 +261,14 @@ python-versions = "*" [package.dependencies] django = ">=3.2" +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "packaging" version = "21.3" @@ -240,6 +280,26 @@ python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + [[package]] name = "pluggy" version = "1.0.0" @@ -406,7 +466,7 @@ python-versions = ">=3.5" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -442,7 +502,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "ce1059c09834886f869259e27fd850a6595b3cbc95b8b160d5ab3d300fa12ffb" +content-hash = "0d5efc74f7a794f1ee74e6a91e6d21caf5d8ff68344d931650d34d6704d29f7e" [metadata.files] asgiref = [ @@ -451,11 +511,16 @@ asgiref = [ ] atomicwrites = [] attrs = [] +black = [] certifi = [ {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, ] charset-normalizer = [] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, @@ -484,10 +549,13 @@ jinja2 = [] markdown = [] markupsafe = [] model-bakery = [] +mypy-extensions = [] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] +pathspec = [] +platformdirs = [] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, diff --git a/pyproject.toml b/pyproject.toml index 0a933348a..9272d1816 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ psycopg2 = "^2.9.3" djangorestframework = "^3.13.1" Markdown = "^3.4.1" drf-yasg = "^1.21.3" +black = "^22.6.0" [tool.poetry.dev-dependencies] pytest-django = "^4.5.2" @@ -26,4 +27,16 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="coderockr.settings" python_files = ["test_*.py"] -addopts = "--reuse-db" \ No newline at end of file +addopts = "--reuse-db" + +[tool.black] +line-length=88 +target-version=['py310'] +extend-exclude=''' +( + ^/manage.py + | ^/data/ + | ^/coderockr/ + | migrations +) +''' From 328bcf483177ad611c65d0e59dd7e8d353957730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 21:29:21 -0300 Subject: [PATCH 17/28] feat: fixes date validation for withdrawn action fix: removes trailing slash from urls tests: update tests --- coderockr/settings.py | 1 - core/tests/test_user.py | 56 ++++++++++++++---------------- core/urls.py | 2 +- core/views.py | 3 +- investments/serializers.py | 17 +++++++-- investments/tests/test_interest.py | 14 ++++---- investments/urls.py | 2 +- investments/views.py | 4 ++- 8 files changed, 55 insertions(+), 44 deletions(-) diff --git a/coderockr/settings.py b/coderockr/settings.py index 3a6919299..1d6300162 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -149,7 +149,6 @@ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'PAGE_SIZE': 10 } diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 2c6a9a912..238f69056 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -1,6 +1,6 @@ -from http import HTTPStatus import json from model_bakery import baker +from rest_framework import status from ..models import User @@ -8,13 +8,13 @@ def test_create(db, client): data = {"email": "jorge@gmail.com", "password": "jorge"} response = client.post( - "/user/", + "/users", data=json.dumps(data), content_type="application/json", ) response_data = response.json() - assert response.status_code == HTTPStatus.CREATED + assert response.status_code == status.HTTP_201_CREATED assert response_data["email"] == data["email"] @@ -24,64 +24,62 @@ def test_create_duplicate(db, client): User.objects.create_user(**data, username=data["email"]) response = client.post( - "/user/", + "/users", data=json.dumps(data), content_type="application/json", ) - print(User.objects.all()) + assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.status_code == HTTPStatus.BAD_REQUEST - -def test_update(db, client, create_token_for_user): - user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") - token = "Token " + create_token_for_user(user_jorge).key +def test_update(db, client, create_token): + user_jorge = baker.make("core.User", pk=1, email="jorge@jorge.com") + token = create_token(user_jorge) data = {"email": "jorge@gmail.com"} response = client.patch( - "/user/1/", + "/users/1", data=json.dumps(data), HTTP_AUTHORIZATION=token, content_type="application/json", ) response_data = response.json() - assert response.status_code == HTTPStatus.OK + assert response.status_code == status.HTTP_200_OK assert response_data["email"] == data["email"] -def test_update_other_user(db, client, create_token_for_user): - user_jorge = baker.make("core.User", id=1, email="jorge@gmail.com") - baker.make("core.User", id=2, email="marcio@marcio.com") - token_jorge = "Token " + create_token_for_user(user_jorge).key +def test_update_other_user(db, client, create_token): + user_jorge = baker.make("core.User", pk=1, email="jorge@gmail.com") + baker.make("core.User", pk=2, email="marcio@marcio.com") + token_jorge = create_token(user_jorge) data = {"email": "marcio@gmail.com"} response = client.patch( - "/user/2/", + "/users/2", data=json.dumps(data), HTTP_AUTHORIZATION=token_jorge, content_type="application/json", ) - assert response.status_code == HTTPStatus.FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND -def test_delete(db, client, create_token_for_user): - user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") - token = "Token " + create_token_for_user(user_jorge).key +def test_delete(db, client, create_token): + user_jorge = baker.make("core.User", pk=1, email="jorge@jorge.com") + token = create_token(user_jorge) - response = client.delete("/user/1/", HTTP_AUTHORIZATION=token) + response = client.delete("/users/1", HTTP_AUTHORIZATION=token) - assert response.status_code == HTTPStatus.NO_CONTENT + assert response.status_code == status.HTTP_204_NO_CONTENT -def test_delete_other_user(db, client, create_token_for_user): - user_jorge = baker.make("core.User", id=1, email="jorge@jorge.com") - baker.make("core.User", id=2, email="marcio@marcio.com") +def test_delete_other_user(db, client, create_token): + user_jorge = baker.make("core.User", pk=1, email="jorge@jorge.com") + baker.make("core.User", pk=2, email="marcio@marcio.com") - token = "Token " + create_token_for_user(user_jorge).key + token = create_token(user_jorge) - response = client.delete("/user/2/", HTTP_AUTHORIZATION=token) + response = client.delete("/users/2", HTTP_AUTHORIZATION=token) - assert response.status_code == HTTPStatus.FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/core/urls.py b/core/urls.py index 9b2b202e8..32678c913 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,7 +4,7 @@ from .views import UserViewSet -router = SimpleRouter() +router = SimpleRouter(trailing_slash=False) router.register("users", UserViewSet) urlpatterns = [ diff --git a/core/views.py b/core/views.py index aed1906d5..0efaab904 100644 --- a/core/views.py +++ b/core/views.py @@ -27,8 +27,7 @@ class UserViewSet( def whoami(self, request): if not request.user.id: return Response() - serializer = self.get_serializer(data=request.user) - serializer.is_valid(raise_exception=True) + serializer = self.get_serializer(instance=request.user) return Response(serializer.data) def get_queryset(self): diff --git a/investments/serializers.py b/investments/serializers.py index 754fe25b7..ce4419e90 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -1,6 +1,7 @@ from dataclasses import fields from rest_framework import serializers -from .validators import FutureDateValidator, NotFutureDateValidator +from rest_framework.validators import ValidationError +from .validators import NotFutureDateValidator from .models import Investment @@ -37,9 +38,21 @@ class Meta: "created_at", ) extra_kwargs = { - "withdrawn_at": {"validators": [FutureDateValidator()]}, + "withdrawn_at": {"validators": [NotFutureDateValidator()]}, } + def is_valid(self, raise_exception=False): + valid = super().is_valid(raise_exception) + if raise_exception: + if self.instance.created_at > self.validated_data["withdrawn_at"]: + valid = False + raise ValidationError( + { + "detail": "Withdrawn date must not be older than when investment was created." + } + ) + return valid + def save(self, **kwargs): self.instance.active = False return super().save(**kwargs) diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py index e17cedcbe..68269a07e 100644 --- a/investments/tests/test_interest.py +++ b/investments/tests/test_interest.py @@ -60,15 +60,15 @@ def test_withdrawn_investment(db, client, create_token, user_ains): "investments.Investment", pk=1, amount=100, - created_at=now(), + created_at=now() - timedelta(days=365), owner=user_ains, ) - withdrawn_at = now() + timedelta(days=365) # 12 meses + withdrawn_at = now() data = {"withdrawn_at": withdrawn_at.isoformat()} token = create_token(user_ains) response = client.post( - "/investments/1/withdrawn/", + "/investments/1/withdrawn", data=json.dumps(data), content_type="application/json", HTTP_AUTHORIZATION=token, @@ -76,13 +76,13 @@ def test_withdrawn_investment(db, client, create_token, user_ains): response_data = response.json() expected_gain = interest_svc.gain_formula(100, 12) - expected_gain_with_taxes = interest_svc._apply_tax(expected_gain, 18.5) + expected_gain_with_taxes = interest_svc._apply_tax(expected_gain - 100, 18.5) assert response.status_code == status.HTTP_202_ACCEPTED assert response_data["balance"] == expected_gain_with_taxes -def test_withdrawn_investment_past_date(db, client, create_token, user_ains): +def test_withdrawn_investment_before_created_at(db, client, create_token, user_ains): baker.make( "investments.Investment", pk=1, @@ -95,7 +95,7 @@ def test_withdrawn_investment_past_date(db, client, create_token, user_ains): token = create_token(user_ains) response = client.post( - "/investments/1/withdrawn/", + "/investments/1/withdrawn", data=json.dumps(data), content_type="application/json", HTTP_AUTHORIZATION=token, @@ -115,7 +115,7 @@ def test_get_investment(db, client, create_token, user_ains): token = create_token(user_ains) serialized_investment = InvestmentSerializer(investment).data - response = client.get("/investments/1/", HTTP_AUTHORIZATION=token) + response = client.get("/investments/1", HTTP_AUTHORIZATION=token) response_data = response.json() assert response.status_code == status.HTTP_200_OK diff --git a/investments/urls.py b/investments/urls.py index 41e1aae40..f34785046 100644 --- a/investments/urls.py +++ b/investments/urls.py @@ -4,7 +4,7 @@ from .views import InvestmentViewSet -router = SimpleRouter() +router = SimpleRouter(trailing_slash=False) router.register("", InvestmentViewSet) urlpatterns = [ diff --git a/investments/views.py b/investments/views.py index 0556dd93f..d20bad504 100644 --- a/investments/views.py +++ b/investments/views.py @@ -4,10 +4,11 @@ RetrieveModelMixin, CreateModelMixin, ) -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action +from rest_framework.pagination import LimitOffsetPagination from .models import Investment from .serializers import InvestmentSerializer, WithdrawalSerializer @@ -24,6 +25,7 @@ class InvestmentViewSet( queryset = Investment.objects.all() serializer_class = InvestmentSerializer permission_classes = (IsAuthenticated, IsOwnInvestment) + pagination_class = LimitOffsetPagination def create(self, request): serializer = self.get_serializer( From 75327690270eb5d1bccbba9cb15b68d26fa1ac41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 21:44:04 -0300 Subject: [PATCH 18/28] fix: fix compound interest formula --- README.md | 112 ++++----- coderockr/urls.py | 18 +- investments/models.py | 6 +- investments/services/interest_svc.py | 13 +- poetry.lock | 16 +- pyproject.toml | 2 +- swagger.yaml | 328 +++++++++++++++++++++++++++ 7 files changed, 399 insertions(+), 96 deletions(-) create mode 100644 swagger.yaml diff --git a/README.md b/README.md index ea8115e67..5660d64fd 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,62 @@ # Back End Test Project -You should see this challenge as an opportunity to create an application following modern development best practices (given the stack of your choice), but also feel free to use your own architecture preferences (coding standards, code organization, third-party libraries, etc). It’s perfectly fine to use vanilla code or any framework or libraries. +## Build instructions. -## Scope +1. Clone this repository locally. +2. CD into the folder. +3. Run `docker-compose build`. +4. Run `docker-compose up -d`. +5. Access http://localhost:8000/swagger to view the OpenAPI Specification. +6. Create a user in the users_create route. +7. Create and copy the token in the login_create route. +8. Click in the "Authorize 🔒" button. +9. Enter "Token " + your token and then in "Authorize" button. +10. Now you're ready to interact with the API. -In this challenge you should build an API for an application that stores and manages investments, it should have the following features: +### Extra instructions. + +* Run `docker-compose exec app pytest` to run the integration tests. +* Run `docker-compose exec app black .` to run the black linter. + +## Dependencies and theirs "why's". + +1. Django - Python "batteries-included" web-framework, was one of the requirements for the role. +2. psycopg2 - Python adapter for PostgreSQL (The best OS database option). +3. django-rest-framework - Django Framework that makes the development of Rest API easier, well structured and standardized. +4. Markdown - It gives DRF a nice interface, a great alternative to the OpenAPI specs. +5. drf-yasg - Is the responsible for the Swagger route, it automatically generates the specs based on the project routes. + +### Development dependencies. + +1. pytest - Python testing lib. +2. pytest-django - Pytest adapter is a plugin for Django. +3. model-bakery - Its a utility function to mock database models. +4. debugpy - Service that allows debugging python remotely. +5. black - Python linter, to enforce coding style, it has a "zero configuration" police, I have chosen it just for being easier and faster to setup, flake8 is better. + +## Requirements. 1. __Creation__ of an investment with an owner, a creation date and an amount. - 1. The creation date of an investment can be today or a date in the past. - 2. An investment should not be or become negative. + 1. [x] The creation date of an investment can be today or a date in the past. + 2. [x] An investment should not be or become negative. 2. __View__ of an investment with its initial amount and expected balance. - 1. Expected balance should be the sum of the invested amount and the [gains][]. - 2. If an investment was already withdrawn then the balance must reflect the gains of that investment + 1. [x] Expected balance should be the sum of the invested amount and the [gains][]. + 2. [x] If an investment was already withdrawn then the balance must reflect the gains of that investment 3. __Withdrawal__ of a investment. - 1. The withdraw will always be the sum of the initial amount and its gains, + 1. [x] The withdraw will always be the sum of the initial amount and its gains, partial withdrawn is not supported. - 2. Withdrawals can happen in the past or today, but can't happen before the investment creation or the future. - 3. [Taxes][taxes] need to be applied to the withdrawals before showing the final value. + 2. [x] Withdrawals can happen in the past or today, but can't happen before the investment creation or the future. + 3. [x] [Taxes][taxes] need to be applied to the withdrawals before showing the final value. 4. __List__ of a person's investments - 1. This list should have pagination. - -__NOTE:__ the implementation of an interface will not be evaluated. + 1. [x] This list should have pagination. ### Gain Calculation -The investment will pay 0.52% every month in the same day of the investment creation. - -Given that the gain is paid every month, it should be treated as [compound gain][], which means that every new period (month) the amount gained will become part of the investment balance for the next payment. +Formula used: Final amount = Initial amount * (1 + interest / months) ^ months ### Taxation -When money is withdrawn, tax is triggered. Taxes apply only to the profit/gain portion of the money withdrawn. For example, if the initial investment was 1000.00, the current balance is 1200.00, then the taxes will be applied to the 200.00. - -The tax percentage changes according to the age of the investment: +The tax percentage changes according to the age of the investment and its applied only to the gains: * If it is less than one year old, the percentage will be 22.5% (tax = 45.00). * If it is between one and two years old, the percentage will be 18.5% (tax = 37.00). * If older than two years, the percentage will be 15% (tax = 30.00). - -## Requirements -1. Create project using any technology of your preference. It’s perfectly OK to use vanilla code or any framework or libraries; -2. Although you can use as many dependencies as you want, you should manage them wisely; -3. It is not necessary to send the notification emails, however, the code required for that would be welcome; -4. The API must be documented in some way. - -## Deliverables -The project source code and dependencies should be made available in GitHub. Here are the steps you should follow: -1. Fork this repository to your GitHub account (create an account if you don't have one, you will need it working with us). -2. Create a "development" branch and commit the code to it. Do not push the code to the main branch. -3. Include a README file that describes: - - Special build instructions, if any - - List of third-party libraries used and short description of why/how they were used - - A link to the API documentation. -4. Once the work is complete, create a pull request from "development" into "main" and send us the link. -5. Avoid using huge commits hiding your progress. Feel free to work on a branch and use `git rebase` to adjust your commits before submitting the final version. - -## Coding Standards -When working on the project be as clean and consistent as possible. - -## Project Deadline -Ideally you'd finish the test project in 5 days. It shouldn't take you longer than a entire week. - -## Quality Assurance -Use the following checklist to ensure high quality of the project. - -### General -- First of all, the application should run without errors. -- Are all requirements set above met? -- Is coding style consistent? -- The API is well documented? -- The API has unit tests? - -## Submission -1. A link to the Github repository. -2. Briefly describe how you decided on the tools that you used. - -## Have Fun Coding 🤘 -- This challenge description is intentionally vague in some aspects, but if you need assistance feel free to ask for help. -- If any of the seems out of your current level, you may skip it, but remember to tell us about it in the pull request. - -## Credits - -This coding challenge was inspired on [kinvoapp/kinvo-back-end-test](https://github.com/kinvoapp/kinvo-back-end-test/blob/2f17d713de739e309d17a1a74a82c3fd0e66d128/README.md) - -[gains]: #gain-calculation -[taxes]: #taxation -[interest]: #interest-calculation -[compound gain]: https://www.investopedia.com/terms/g/gain.asp diff --git a/coderockr/urls.py b/coderockr/urls.py index ac4766fc1..aed1c33c4 100644 --- a/coderockr/urls.py +++ b/coderockr/urls.py @@ -18,9 +18,17 @@ from . import openapi urlpatterns = [ - path('admin/', admin.site.urls), - re_path(r'^swagger/$', openapi.schema_view.with_ui('swagger', cache_timeout=0), - name='swagger-ui'), - path('investments/', include('investments.urls')), - path('', include('core.urls')), + path("admin/", admin.site.urls), + re_path( + r"^swagger/$", + openapi.schema_view.with_ui("swagger", cache_timeout=0), + name="swagger-ui", + ), + re_path( + r"^swagger(?P\.json|\.yaml)$", + openapi.schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + path("investments/", include("investments.urls")), + path("", include("core.urls")), ] diff --git a/investments/models.py b/investments/models.py index c42311467..503535135 100644 --- a/investments/models.py +++ b/investments/models.py @@ -17,9 +17,13 @@ class Investment(models.Model): def balance(self): start_date = self.withdrawn_at if self.withdrawn_at else now() age = diff_month(start_date, self.created_at) + + if age == 0: + return self.amount if self.active else 0 + gains = interest_svc.gain_formula(self.amount, age) if self.active: return gains - return interest_svc.calculate_tax(gains - self.amount, age) + return interest_svc.calculate_tax(gains - self.amount, age) if gains else 0 diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index 429b68930..147c33fe9 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,15 +1,4 @@ import math -from django.db.models import ( - ExpressionWrapper, - FloatField, - DateField, - F, - Case, - When, - Q, -) -from django.db.models.lookups import GreaterThan, LessThan -from django.db.models.functions import ExtractDay, Now from django.conf import settings @@ -17,7 +6,7 @@ def gain_formula(amount, months, total=False): - return amount * math.pow((1 + INTEREST), months) + return amount * math.pow((1 + INTEREST/months), months) def calculate_tax(gains, age): diff --git a/poetry.lock b/poetry.lock index 6daf36909..b36d3dda0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -35,7 +35,7 @@ dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "z name = "black" version = "22.6.0" description = "The uncompromising code formatter." -category = "main" +category = "dev" optional = false python-versions = ">=3.6.2" @@ -75,7 +75,7 @@ unicode_backport = ["unicodedata2"] name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -86,7 +86,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.5" description = "Cross-platform colored terminal text." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -265,7 +265,7 @@ django = ">=3.2" name = "mypy-extensions" version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "main" +category = "dev" optional = false python-versions = "*" @@ -284,7 +284,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" name = "pathspec" version = "0.9.0" description = "Utility library for gitignore style pattern matching of file paths." -category = "main" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" @@ -292,7 +292,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" name = "platformdirs" version = "2.5.2" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -466,7 +466,7 @@ python-versions = ">=3.5" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -502,7 +502,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "0d5efc74f7a794f1ee74e6a91e6d21caf5d8ff68344d931650d34d6704d29f7e" +content-hash = "9a025bf3b9aa28408f1731be2c9c7f4eb3026c0bea454e69749cd6a99d1131b0" [metadata.files] asgiref = [ diff --git a/pyproject.toml b/pyproject.toml index 9272d1816..f8f24fe01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ psycopg2 = "^2.9.3" djangorestframework = "^3.13.1" Markdown = "^3.4.1" drf-yasg = "^1.21.3" -black = "^22.6.0" [tool.poetry.dev-dependencies] pytest-django = "^4.5.2" @@ -19,6 +18,7 @@ pytest-xdist = "^2.5.0" model-bakery = "^1.7.0" pytest = "^7.1.2" debugpy = "^1.6.3" +black = "^22.6.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/swagger.yaml b/swagger.yaml new file mode 100644 index 000000000..6f31b143d --- /dev/null +++ b/swagger.yaml @@ -0,0 +1,328 @@ +swagger: '2.0' +info: + title: Investments API + license: + name: MIT License + version: v1 +host: localhost:8000 +schemes: + - http +basePath: / +consumes: + - application/json +produces: + - application/json +securityDefinitions: + Bearer: + type: apiKey + name: Authorization + in: header +security: + - Bearer: [] +paths: + /investments/: + get: + operationId: investments_list + description: '' + parameters: + - name: limit + in: query + description: Number of results to return per page. + required: false + type: integer + - name: offset + in: query + description: The initial index from which to return the results. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/Investment' + tags: + - investments + post: + operationId: investments_create + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Investment' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/Investment' + tags: + - investments + parameters: [] + /investments/{id}: + get: + operationId: investments_read + description: '' + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/Investment' + tags: + - investments + parameters: + - name: id + in: path + description: A unique integer value identifying this investment. + required: true + type: integer + /investments/{id}/withdrawn: + post: + operationId: investments_withdrawn + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Withdrawal' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/Withdrawal' + tags: + - investments + parameters: + - name: id + in: path + description: A unique integer value identifying this investment. + required: true + type: integer + /login/: + post: + operationId: login_create + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthToken' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/AuthToken' + tags: + - login + parameters: [] + /users: + post: + operationId: users_create + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/User' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/User' + tags: + - users + parameters: [] + /users/whoami: + get: + operationId: users_whoami + description: '' + parameters: [] + responses: + '200': + description: '' + schema: + type: array + items: + $ref: '#/definitions/User' + tags: + - users + parameters: [] + /users/{id}: + put: + operationId: users_update + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/User' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/User' + tags: + - users + patch: + operationId: users_partial_update + description: '' + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/User' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/User' + tags: + - users + delete: + operationId: users_delete + description: '' + parameters: [] + responses: + '204': + description: '' + tags: + - users + parameters: + - name: id + in: path + description: A unique integer value identifying this user. + required: true + type: integer +definitions: + Investment: + required: + - amount + - created_at + type: object + properties: + id: + title: ID + type: integer + readOnly: true + balance: + title: Balance + type: string + readOnly: true + amount: + title: Amount + type: number + active: + title: Active + type: boolean + readOnly: true + created_at: + title: Created at + type: string + format: date-time + withdrawn_at: + title: Withdrawn at + type: string + format: date-time + readOnly: true + x-nullable: true + owner: + title: Owner + type: integer + readOnly: true + Withdrawal: + type: object + properties: + id: + title: ID + type: integer + readOnly: true + balance: + title: Balance + type: string + readOnly: true + amount: + title: Amount + type: number + readOnly: true + active: + title: Active + type: boolean + readOnly: true + created_at: + title: Created at + type: string + format: date-time + readOnly: true + withdrawn_at: + title: Withdrawn at + type: string + format: date-time + x-nullable: true + owner: + title: Owner + type: integer + readOnly: true + AuthToken: + required: + - username + - password + type: object + properties: + username: + title: Username + type: string + minLength: 1 + password: + title: Password + type: string + minLength: 1 + token: + title: Token + type: string + readOnly: true + minLength: 1 + User: + required: + - email + - password + type: object + properties: + id: + title: ID + type: integer + readOnly: true + email: + title: Email address + type: string + format: email + maxLength: 254 + minLength: 1 + password: + title: Password + type: string + maxLength: 128 + minLength: 1 From f7f82ff341531da4ec8db992790d6e07a37699b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Tue, 16 Aug 2022 22:15:20 -0300 Subject: [PATCH 19/28] ci: initial config --- .github/workflows/main.yml | 29 ++++++++++++++++++++++++++++ coderockr/settings.py | 4 ++-- investments/models.py | 4 ++-- investments/services/interest_svc.py | 2 +- pyproject.toml | 2 +- 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..66d25490f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,29 @@ +name: Build + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up python 🐍 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Poetry ✒ + uses: snok/install-poetry@v1 + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v2 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies 🚛 + if: steps.cache-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install -n + - name: Test 👨‍🔬 + run: poetry run pytest + - name: Linting 👿 + run: poetry run black --check . diff --git a/coderockr/settings.py b/coderockr/settings.py index 1d6300162..dd08f69bc 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -81,8 +81,8 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('DB_NAME'), + 'ENGINE': os.getenv('DB_DRIVER', 'django.db.backends.sqlite3'), + 'NAME': os.getenv('DB_NAME', 'test'), 'USER': os.getenv('DB_USER'), 'PASSWORD': os.getenv('DB_PASSWORD'), 'HOST': os.getenv('DB_HOST'), diff --git a/investments/models.py b/investments/models.py index 503535135..f5f6b7022 100644 --- a/investments/models.py +++ b/investments/models.py @@ -17,10 +17,10 @@ class Investment(models.Model): def balance(self): start_date = self.withdrawn_at if self.withdrawn_at else now() age = diff_month(start_date, self.created_at) - + if age == 0: return self.amount if self.active else 0 - + gains = interest_svc.gain_formula(self.amount, age) if self.active: diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index 147c33fe9..a7ef5fe43 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -6,7 +6,7 @@ def gain_formula(amount, months, total=False): - return amount * math.pow((1 + INTEREST/months), months) + return amount * math.pow((1 + INTEREST / months), months) def calculate_tax(gains, age): diff --git a/pyproject.toml b/pyproject.toml index f8f24fe01..6537122fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="coderockr.settings" python_files = ["test_*.py"] -addopts = "--reuse-db" +addopts = "--reuse-db -n auto" [tool.black] line-length=88 From f2eb7485ae8bb831897bd72872f4fe9144adca7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Prado?= Date: Tue, 16 Aug 2022 23:44:26 -0300 Subject: [PATCH 20/28] docs: fixes typo and adds pytest-xdist entry docs: updates style and some spelling mistakes --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5660d64fd..650f300ad 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 6. Create a user in the users_create route. 7. Create and copy the token in the login_create route. 8. Click in the "Authorize 🔒" button. -9. Enter "Token " + your token and then in "Authorize" button. +9. Enter "Token {token}" then press the "Authorize" button. 10. Now you're ready to interact with the API. ### Extra instructions. @@ -18,21 +18,22 @@ * Run `docker-compose exec app pytest` to run the integration tests. * Run `docker-compose exec app black .` to run the black linter. -## Dependencies and theirs "why's". +## Dependencies and their "why's". -1. Django - Python "batteries-included" web-framework, was one of the requirements for the role. -2. psycopg2 - Python adapter for PostgreSQL (The best OS database option). -3. django-rest-framework - Django Framework that makes the development of Rest API easier, well structured and standardized. -4. Markdown - It gives DRF a nice interface, a great alternative to the OpenAPI specs. -5. drf-yasg - Is the responsible for the Swagger route, it automatically generates the specs based on the project routes. +1. **Django** - Python "batteries-included" web-framework, was one of the requirements for the role. +2. **psycopg2** - Python adapter for PostgreSQL (The best OS database option). +3. **django-rest-framework** - Django Framework that makes the development of Rest API easier, well structured and standardized. +4. **Markdown** - It gives DRF a nice interface, a great alternative to the OpenAPI specs. +5. **drf-yasg** - Is the responsible for the Swagger route, it automatically generates the specs based on the project routes. ### Development dependencies. -1. pytest - Python testing lib. -2. pytest-django - Pytest adapter is a plugin for Django. -3. model-bakery - Its a utility function to mock database models. -4. debugpy - Service that allows debugging python remotely. -5. black - Python linter, to enforce coding style, it has a "zero configuration" police, I have chosen it just for being easier and faster to setup, flake8 is better. +1. **pytest** - Python testing lib. +2. **pytest-django** - Pytest adapter is a plugin for Django. +3. **model-bakery** - Its a utility function to mock database models. +4. **debugpy** - Service that allows debugging python remotely. +5. **black** - Python linter, to enforce coding style, it has a "zero configuration" police, I have chosen it just for being easier and faster to setup, flake8 is better. +6. **pytest-xdist** - Pytest plugin for multithreading. The `-n auto` option makes it use the available amount. ## Requirements. From d03ce55c936cfbefc59e522e2a44e92390a4c1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 00:07:04 -0300 Subject: [PATCH 21/28] feat: adds db_driver env and removes month division on interest_svc feat: adds wait-for-it instruction to avoid db starting after webserver --- .env.dev | 13 ++++---- docker-entrypoint.sh | 2 +- investments/services/interest_svc.py | 46 ++++++++++++++-------------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.env.dev b/.env.dev index 291817b55..859730d46 100644 --- a/.env.dev +++ b/.env.dev @@ -1,6 +1,7 @@ -SECRET_KEY=cj&61by%3&!7zlb04f4w9c8#@l@=)6h3@*@4n)5xlz3ikdz3k0 -DB_NAME=coderockr -DB_USER=coderockr -DB_PASSWORD=coderockr -DB_HOST=db -DB_PORT=5432 +SECRET_KEY=cj&61by%3&!7zlb04f4w9c8#@l@=)6h3@*@4n)5xlz3ikdz3k0 +DB_DRIVER=django.db.backends.postgresql +DB_NAME=coderockr +DB_USER=coderockr +DB_PASSWORD=coderockr +DB_HOST=db +DB_PORT=5432 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index a253c15fe..312793aaa 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/bash -python -m manage migrate +wait-for-it db:5432 -- python -m manage migrate python -m debugpy --listen 0.0.0.0:5678 -m manage runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index a7ef5fe43..128cde6eb 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,23 +1,23 @@ -import math -from django.conf import settings - - -INTEREST = settings.CODEROCKR_INTEREST - - -def gain_formula(amount, months, total=False): - return amount * math.pow((1 + INTEREST / months), months) - - -def calculate_tax(gains, age): - if age < 12: - return _apply_tax(gains, 22.5) - - if age < 24: - return _apply_tax(gains, 18.5) - - return _apply_tax(gains, 15) - - -def _apply_tax(gains, tax): - return gains - (gains * tax / 100) +import math +from django.conf import settings + + +INTEREST = settings.CODEROCKR_INTEREST + + +def gain_formula(amount, months, total=False): + return amount * math.pow((1 + INTEREST), months) + + +def calculate_tax(gains, age): + if age < 12: + return _apply_tax(gains, 22.5) + + if age < 24: + return _apply_tax(gains, 18.5) + + return _apply_tax(gains, 15) + + +def _apply_tax(gains, tax): + return gains - (gains * tax / 100) From 226c9cec5876070119dc3aa48f1f8718e9ea9403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 01:24:16 -0300 Subject: [PATCH 22/28] feat: configures celery --- .env.dev | 7 +- coderockr/celery.py | 7 + coderockr/settings.py | 5 + docker-compose.yml | 34 +- docker-entrypoint.sh | 2 +- poetry.lock | 1461 ++++++++++++++++++++++++----------------- pyproject.toml | 86 +-- 7 files changed, 946 insertions(+), 656 deletions(-) create mode 100644 coderockr/celery.py diff --git a/.env.dev b/.env.dev index 859730d46..8a42eacc6 100644 --- a/.env.dev +++ b/.env.dev @@ -1,7 +1,12 @@ -SECRET_KEY=cj&61by%3&!7zlb04f4w9c8#@l@=)6h3@*@4n)5xlz3ikdz3k0 +SECRET_KEY=cj&61by%3&!7zlb04f4w9c8&@l@=)6h3@*@4n)5xlz3ikdz3k0 DB_DRIVER=django.db.backends.postgresql DB_NAME=coderockr DB_USER=coderockr DB_PASSWORD=coderockr DB_HOST=db DB_PORT=5432 + +# Celery + +CELERY_BROKER_URL=amqp://coderockr:coderockr@rabbitmq:5672/coderockr +CELERY_RESULT_BACKEND=redis://redis:6379 \ No newline at end of file diff --git a/coderockr/celery.py b/coderockr/celery.py new file mode 100644 index 000000000..52f9308c1 --- /dev/null +++ b/coderockr/celery.py @@ -0,0 +1,7 @@ +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coderockr.settings") +app = Celery("coderockr") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() \ No newline at end of file diff --git a/coderockr/settings.py b/coderockr/settings.py index dd08f69bc..0961c23a0 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -167,6 +167,11 @@ } } +# Celery + +CELERY_BROKER_URL=os.getenv('CELERY_BROKER_URL') +CELERY_RESULT_BACKEND=os.getenv('CELERY_RESULT_BACKEND') + # Domain specific constants CODEROCKR_INTEREST = 0.0052 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c5514e1d8..7ccff2e1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: app: + image: joaop/coderockr build: context: . entrypoint: @@ -13,6 +14,25 @@ services: - 5678:5678 depends_on: - db + celery_worker: + build: + context: + . + command: | + wait-for-it rabbitmq:5672 + -- wait-for-it redis:6379 + -- bash -c + 'python -m debugpy --listen 0.0.0.0:5679 -m celery -A coderockr worker -l INFO' + volumes: + - .:/app + env_file: + - ./.env.dev + ports: + - 5679:5679 + depends_on: + - rabbitmq + - redis + db: image: postgres:12.2-alpine restart: unless-stopped @@ -28,4 +48,16 @@ services: test: 'pg_isready -U coderockr -d coderockr' interval: 10s timeout: 3s - retries: 3 \ No newline at end of file + retries: 3 + rabbitmq: + image: rabbitmq:3.10.7-alpine + volumes: + - ./data/rabbitmq:/var/lib/rabbitmq + ports: + - 5672:5672 + environment: + - RABBITMQ_DEFAULT_USER=coderockr + - RABBITMQ_DEFAULT_PASS=coderockr + - RABBITMQ_DEFAULT_VHOST=coderockr + redis: + image: redis:7.0.4-alpine \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 312793aaa..99a7687bd 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/bash wait-for-it db:5432 -- python -m manage migrate -python -m debugpy --listen 0.0.0.0:5678 -m manage runserver 0.0.0.0:8000 \ No newline at end of file +wait-for-it redis:6379 -- python -m debugpy --listen 0.0.0.0:5678 -m manage runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index b36d3dda0..394e44ae5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,611 +1,850 @@ -[[package]] -name = "asgiref" -version = "3.5.2" -description = "ASGI specs, helper code, and adapters" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] - -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -tests_no_zope = ["cloudpickle", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] -tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] -docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"] -dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] - -[[package]] -name = "black" -version = "22.6.0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2022.6.15" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "charset-normalizer" -version = "2.1.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" -optional = false -python-versions = ">=3.6.0" - -[package.extras] -unicode_backport = ["unicodedata2"] - -[[package]] -name = "click" -version = "8.1.3" -description = "Composable command line interface toolkit" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.5" -description = "Cross-platform colored terminal text." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "coreapi" -version = "2.3.3" -description = "Python client library for Core API." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -coreschema = "*" -itypes = "*" -requests = "*" -uritemplate = "*" - -[[package]] -name = "coreschema" -version = "0.0.4" -description = "Core Schema." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -jinja2 = "*" - -[[package]] -name = "debugpy" -version = "1.6.3" -description = "An implementation of the Debug Adapter Protocol for Python" -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "django" -version = "4.1" -description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -category = "main" -optional = false -python-versions = ">=3.8" - -[package.dependencies] -asgiref = ">=3.5.2,<4" -sqlparse = ">=0.2.2" -tzdata = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -bcrypt = ["bcrypt"] -argon2 = ["argon2-cffi (>=19.1.0)"] - -[[package]] -name = "djangorestframework" -version = "3.13.1" -description = "Web APIs for Django, made easy." -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -django = ">=2.2" -pytz = "*" - -[[package]] -name = "drf-yasg" -version = "1.21.3" -description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -coreapi = ">=2.3.3" -coreschema = ">=0.0.4" -django = ">=2.2.16" -djangorestframework = ">=3.10.3" -inflection = ">=0.3.1" -packaging = ">=21.0" -pytz = ">=2021.1" -"ruamel.yaml" = ">=0.16.13" -uritemplate = ">=3.0.0" - -[package.extras] -validation = ["swagger-spec-validator (>=2.1.0)"] - -[[package]] -name = "execnet" -version = "1.9.0" -description = "execnet: rapid multi-Python deployment" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -testing = ["pre-commit"] - -[[package]] -name = "idna" -version = "3.3" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "inflection" -version = "0.5.1" -description = "A port of Ruby on Rails inflector to Python" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "itypes" -version = "1.2.0" -description = "Simple immutable types for python." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "markdown" -version = "3.4.1" -description = "Python implementation of Markdown." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -testing = ["pyyaml", "coverage"] - -[[package]] -name = "markupsafe" -version = "2.1.1" -description = "Safely add untrusted strings to HTML/XML markup." -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "model-bakery" -version = "1.7.0" -description = "Smart object creation facility for Django." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -django = ">=3.2" - -[[package]] -name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "packaging" -version = "21.3" -description = "Core utilities for Python packages" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" - -[[package]] -name = "pathspec" -version = "0.9.0" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[[package]] -name = "platformdirs" -version = "2.5.2" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] - -[[package]] -name = "pluggy" -version = "1.0.0" -description = "plugin and hook calling mechanisms for python" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "psycopg2" -version = "2.9.3" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "pyparsing" -version = "3.0.9" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "main" -optional = false -python-versions = ">=3.6.8" - -[package.extras] -diagrams = ["railroad-diagrams", "jinja2"] - -[[package]] -name = "pytest" -version = "7.1.2" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" - -[package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] - -[[package]] -name = "pytest-django" -version = "4.5.2" -description = "A Django plugin for pytest." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -pytest = ">=5.4.0" - -[package.extras] -docs = ["sphinx", "sphinx-rtd-theme"] -testing = ["django", "django-configurations (>=2.0)"] - -[[package]] -name = "pytest-forked" -version = "1.4.0" -description = "run tests in isolated forked subprocesses" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -py = "*" -pytest = ">=3.10" - -[[package]] -name = "pytest-xdist" -version = "2.5.0" -description = "pytest xdist plugin for distributed testing and loop-on-failing modes" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -execnet = ">=1.1" -pytest = ">=6.2.0" -pytest-forked = "*" - -[package.extras] -testing = ["filelock"] -setproctitle = ["setproctitle"] -psutil = ["psutil (>=3.0)"] - -[[package]] -name = "pytz" -version = "2022.2.1" -description = "World timezone definitions, modern and historical" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "requests" -version = "2.28.1" -description = "Python HTTP for Humans." -category = "main" -optional = false -python-versions = ">=3.7, <4" - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruamel.yaml" -version = "0.17.21" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -category = "main" -optional = false -python-versions = ">=3" - -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} - -[package.extras] -docs = ["ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel.yaml.clib" -version = "0.2.6" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "sqlparse" -version = "0.4.2" -description = "A non-validating SQL parser." -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "tzdata" -version = "2022.2" -description = "Provider of IANA time zone data" -category = "main" -optional = false -python-versions = ">=2" - -[[package]] -name = "uritemplate" -version = "4.1.1" -description = "Implementation of RFC 6570 URI Templates" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "urllib3" -version = "1.26.11" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" - -[package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - -[metadata] -lock-version = "1.1" -python-versions = "^3.10" -content-hash = "9a025bf3b9aa28408f1731be2c9c7f4eb3026c0bea454e69749cd6a99d1131b0" - -[metadata.files] -asgiref = [ - {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, - {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, -] -atomicwrites = [] -attrs = [] -black = [] -certifi = [ - {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, - {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, -] -charset-normalizer = [] -click = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] -colorama = [ - {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, - {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, -] -coreapi = [] -coreschema = [] -debugpy = [] -django = [] -djangorestframework = [ - {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, - {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, -] -drf-yasg = [] -execnet = [] -idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, -] -inflection = [] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -itypes = [] -jinja2 = [] -markdown = [] -markupsafe = [] -model-bakery = [] -mypy-extensions = [] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] -pathspec = [] -platformdirs = [] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -psycopg2 = [ - {file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"}, - {file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"}, - {file = "psycopg2-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56"}, - {file = "psycopg2-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305"}, - {file = "psycopg2-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2"}, - {file = "psycopg2-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461"}, - {file = "psycopg2-2.9.3-cp38-cp38-win32.whl", hash = "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7"}, - {file = "psycopg2-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf"}, - {file = "psycopg2-2.9.3-cp39-cp39-win32.whl", hash = "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126"}, - {file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"}, - {file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] -pyparsing = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, -] -pytest = [ - {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, - {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, -] -pytest-django = [] -pytest-forked = [] -pytest-xdist = [] -pytz = [] -requests = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, -] -"ruamel.yaml" = [] -"ruamel.yaml.clib" = [] -sqlparse = [ - {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, - {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -tzdata = [] -uritemplate = [ - {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, - {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, -] -urllib3 = [] +[[package]] +name = "amqp" +version = "5.1.1" +description = "Low-level AMQP client for Python (fork of amqplib)." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +vine = ">=5.0.0" + +[[package]] +name = "asgiref" +version = "3.5.2" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +tests_no_zope = ["cloudpickle", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] +docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"] +dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"] + +[[package]] +name = "billiard" +version = "3.6.4.0" +description = "Python multiprocessing fork with improvements and bugfixes" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "celery" +version = "5.2.7" +description = "Distributed Task Queue." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +billiard = ">=3.6.4.0,<4.0" +click = ">=8.0.3,<9.0" +click-didyoumean = ">=0.0.3" +click-plugins = ">=1.1.1" +click-repl = ">=0.2.0" +kombu = ">=5.2.3,<6.0" +pytz = ">=2021.3" +vine = ">=5.0.0,<6.0" + +[package.extras] +arangodb = ["pyArango (>=1.3.2)"] +auth = ["cryptography"] +azureblockblob = ["azure-storage-blob (==12.9.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver (<3.21.0)"] +consul = ["python-consul2"] +cosmosdbsql = ["pydocumentdb (==2.3.2)"] +couchbase = ["couchbase (>=3.0.0)"] +couchdb = ["pycouchdb"] +django = ["Django (>=1.11)"] +dynamodb = ["boto3 (>=1.9.178)"] +elasticsearch = ["elasticsearch"] +eventlet = ["eventlet (>=0.32.0)"] +gevent = ["gevent (>=1.5.0)"] +librabbitmq = ["librabbitmq (>=1.5.0)"] +memcache = ["pylibmc"] +mongodb = ["pymongo[srv] (>=3.11.1)"] +msgpack = ["msgpack"] +pymemcache = ["python-memcached"] +pyro = ["pyro4"] +pytest = ["pytest-celery"] +redis = ["redis (>=3.4.1,!=4.0.0,!=4.0.1)"] +s3 = ["boto3 (>=1.9.125)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem"] +sqlalchemy = ["sqlalchemy"] +sqs = ["kombu"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard"] + +[[package]] +name = "certifi" +version = "2022.6.15" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-didyoumean" +version = "0.3.0" +description = "Enables git-like *did-you-mean* feature in click" +category = "main" +optional = false +python-versions = ">=3.6.2,<4.0.0" + +[package.dependencies] +click = ">=7" + +[[package]] +name = "click-plugins" +version = "1.1.1" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "wheel", "pytest-cov", "pytest (>=3.6)"] + +[[package]] +name = "click-repl" +version = "0.2.0" +description = "REPL plugin for Click" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" +prompt-toolkit = "*" +six = "*" + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +coreschema = "*" +itypes = "*" +requests = "*" +uritemplate = "*" + +[[package]] +name = "coreschema" +version = "0.0.4" +description = "Core Schema." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +jinja2 = "*" + +[[package]] +name = "debugpy" +version = "1.6.3" +description = "An implementation of the Debug Adapter Protocol for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "deprecated" +version = "1.2.13" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] + +[[package]] +name = "django" +version = "4.1" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +asgiref = ">=3.5.2,<4" +sqlparse = ">=0.2.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +bcrypt = ["bcrypt"] +argon2 = ["argon2-cffi (>=19.1.0)"] + +[[package]] +name = "djangorestframework" +version = "3.13.1" +description = "Web APIs for Django, made easy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = ">=2.2" +pytz = "*" + +[[package]] +name = "drf-yasg" +version = "1.21.3" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coreapi = ">=2.3.3" +coreschema = ">=0.0.4" +django = ">=2.2.16" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +packaging = ">=21.0" +pytz = ">=2021.1" +"ruamel.yaml" = ">=0.16.13" +uritemplate = ">=3.0.0" + +[package.extras] +validation = ["swagger-spec-validator (>=2.1.0)"] + +[[package]] +name = "execnet" +version = "1.9.0" +description = "execnet: rapid multi-Python deployment" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +testing = ["pre-commit"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "itypes" +version = "1.2.0" +description = "Simple immutable types for python." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "kombu" +version = "5.2.4" +description = "Messaging library for Python." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +amqp = ">=5.0.9,<6.0.0" +vine = "*" + +[package.extras] +azureservicebus = ["azure-servicebus (>=7.0.0)"] +azurestoragequeues = ["azure-storage-queue"] +consul = ["python-consul (>=0.6.0)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +mongodb = ["pymongo (>=3.3.0,<3.12.1)"] +msgpack = ["msgpack"] +pyro = ["pyro4"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=3.4.1,!=4.0.0,!=4.0.1)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.9.12)", "pycurl (>=7.44.1,<7.45.0)", "urllib3 (>=1.26.7)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] + +[[package]] +name = "markdown" +version = "3.4.1" +description = "Python implementation of Markdown." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +testing = ["pyyaml", "coverage"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "model-bakery" +version = "1.7.0" +description = "Smart object creation facility for Django." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +django = ">=3.2" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.30" +description = "Library for building powerful interactive command lines in Python" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psycopg2" +version = "2.9.3" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-forked" +version = "1.4.0" +description = "run tests in isolated forked subprocesses" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +name = "pytest-xdist" +version = "2.5.0" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" +pytest-forked = "*" + +[package.extras] +testing = ["filelock"] +setproctitle = ["setproctitle"] +psutil = ["psutil (>=3.0)"] + +[[package]] +name = "pytz" +version = "2022.2.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "redis" +version = "4.3.4" +description = "Python client for Redis database and key-value store" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = ">=4.0.2" +deprecated = ">=1.2.3" +packaging = ">=20.4" + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruamel.yaml" +version = "0.17.21" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "main" +optional = false +python-versions = ">=3" + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.6" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sqlparse" +version = "0.4.2" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tzdata" +version = "2022.2" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" + +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.11" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "vine" +version = "5.0.0" +description = "Promises, promises, promises." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "b8a0673b8f6d6c70b498b053610a1fb4fc4a037e48c2a6f6270320f4e7263e76" + +[metadata.files] +amqp = [] +asgiref = [ + {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, + {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, +] +async-timeout = [] +atomicwrites = [] +attrs = [] +billiard = [] +black = [] +celery = [] +certifi = [ + {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, + {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, +] +charset-normalizer = [] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +click-didyoumean = [] +click-plugins = [] +click-repl = [] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] +coreapi = [] +coreschema = [] +debugpy = [] +deprecated = [] +django = [] +djangorestframework = [ + {file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"}, + {file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"}, +] +drf-yasg = [] +execnet = [] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +inflection = [] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +itypes = [] +jinja2 = [] +kombu = [] +markdown = [] +markupsafe = [] +model-bakery = [] +mypy-extensions = [] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [] +platformdirs = [] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +prompt-toolkit = [] +psycopg2 = [ + {file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"}, + {file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"}, + {file = "psycopg2-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56"}, + {file = "psycopg2-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305"}, + {file = "psycopg2-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2"}, + {file = "psycopg2-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461"}, + {file = "psycopg2-2.9.3-cp38-cp38-win32.whl", hash = "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7"}, + {file = "psycopg2-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf"}, + {file = "psycopg2-2.9.3-cp39-cp39-win32.whl", hash = "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126"}, + {file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"}, + {file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +pytest-django = [] +pytest-forked = [] +pytest-xdist = [] +pytz = [] +redis = [] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +"ruamel.yaml" = [] +"ruamel.yaml.clib" = [] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sqlparse = [ + {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, + {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +tzdata = [] +uritemplate = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] +urllib3 = [] +vine = [] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +wrapt = [] diff --git a/pyproject.toml b/pyproject.toml index 6537122fc..b7308038e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,44 @@ -[tool.poetry] -name = "backend-test-coderockr" -version = "0.1.0" -description = "" -authors = ["João Prado "] - -[tool.poetry.dependencies] -python = "^3.10" -Django = "^4.1" -psycopg2 = "^2.9.3" -djangorestframework = "^3.13.1" -Markdown = "^3.4.1" -drf-yasg = "^1.21.3" - -[tool.poetry.dev-dependencies] -pytest-django = "^4.5.2" -pytest-xdist = "^2.5.0" -model-bakery = "^1.7.0" -pytest = "^7.1.2" -debugpy = "^1.6.3" -black = "^22.6.0" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" - -[tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE="coderockr.settings" -python_files = ["test_*.py"] -addopts = "--reuse-db -n auto" - -[tool.black] -line-length=88 -target-version=['py310'] -extend-exclude=''' -( - ^/manage.py - | ^/data/ - | ^/coderockr/ - | migrations -) -''' +[tool.poetry] +name = "backend-test-coderockr" +version = "0.1.0" +description = "" +authors = ["João Prado "] + +[tool.poetry.dependencies] +python = "^3.10" +Django = "^4.1" +psycopg2 = "^2.9.3" +djangorestframework = "^3.13.1" +Markdown = "^3.4.1" +drf-yasg = "^1.21.3" +celery = "^5.2.7" +redis = "^4.3.4" + +[tool.poetry.dev-dependencies] +pytest-django = "^4.5.2" +pytest-xdist = "^2.5.0" +model-bakery = "^1.7.0" +pytest = "^7.1.2" +debugpy = "^1.6.3" +black = "^22.6.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE="coderockr.settings" +python_files = ["test_*.py"] +addopts = "--reuse-db -n auto" + +[tool.black] +line-length=88 +target-version=['py310'] +extend-exclude=''' +( + ^/manage.py + | ^/data/ + | ^/coderockr/ + | migrations +) +''' From 2fc72a0b46cc98e2f70f8eff8f9a2031c5c1589d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 02:05:10 -0300 Subject: [PATCH 23/28] fix: fixes querysets that filters by user refact: removes unused files fix: returns with trailing slash (it was breaking the openapi specs, for some reason) feat: adds mailing when doing a withdrawn --- .env.dev | 7 ++++++- coderockr/settings.py | 8 +++++++- core/tests.py | 3 --- core/views.py | 4 ++++ docker-compose.yml | 3 +-- investments/admin.py | 3 --- investments/tasks.py | 12 ++++++++++++ investments/tests.py | 3 --- investments/tests/test_interest.py | 8 ++++---- investments/urls.py | 2 +- investments/views.py | 7 +++++++ 11 files changed, 42 insertions(+), 18 deletions(-) delete mode 100644 core/tests.py delete mode 100644 investments/admin.py create mode 100644 investments/tasks.py delete mode 100644 investments/tests.py diff --git a/.env.dev b/.env.dev index 8a42eacc6..f6ca09e22 100644 --- a/.env.dev +++ b/.env.dev @@ -9,4 +9,9 @@ DB_PORT=5432 # Celery CELERY_BROKER_URL=amqp://coderockr:coderockr@rabbitmq:5672/coderockr -CELERY_RESULT_BACKEND=redis://redis:6379 \ No newline at end of file +CELERY_RESULT_BACKEND=redis://redis:6379 + +# MAIL + +MAIL_HOST=mail +MAIL_PORT=8025 \ No newline at end of file diff --git a/coderockr/settings.py b/coderockr/settings.py index 0961c23a0..20232dc28 100644 --- a/coderockr/settings.py +++ b/coderockr/settings.py @@ -174,4 +174,10 @@ # Domain specific constants -CODEROCKR_INTEREST = 0.0052 \ No newline at end of file +CODEROCKR_INTEREST = 0.0052 + +# EMAIL + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_HOST=os.getenv('MAIL_HOST', 'localhost') +EMAIL_PORT=os.getenv('MAIL_PORT', 25) \ No newline at end of file diff --git a/core/tests.py b/core/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/core/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/core/views.py b/core/views.py index 0efaab904..f0e220816 100644 --- a/core/views.py +++ b/core/views.py @@ -32,4 +32,8 @@ def whoami(self, request): def get_queryset(self): qs = super().get_queryset() + + if not self.request.user.id: + return qs.none() + return qs.filter(pk=self.request.user.id) diff --git a/docker-compose.yml b/docker-compose.yml index 7ccff2e1d..81d664f09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,7 @@ services: - db celery_worker: build: - context: - . + context: . command: | wait-for-it rabbitmq:5672 -- wait-for-it redis:6379 diff --git a/investments/admin.py b/investments/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/investments/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/investments/tasks.py b/investments/tasks.py new file mode 100644 index 000000000..9a02a0ada --- /dev/null +++ b/investments/tasks.py @@ -0,0 +1,12 @@ +from django.core.mail import send_mail +from celery import shared_task + + +@shared_task() +def send_withdrawn_alert_email_task(email_address, amount): + send_mail( + "You have made an withdrawn", + f"An withdrawn of {amount} just occurred on your account!", + "fake@example.com", + [email_address] + ) \ No newline at end of file diff --git a/investments/tests.py b/investments/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/investments/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py index 68269a07e..ab0a9b62f 100644 --- a/investments/tests/test_interest.py +++ b/investments/tests/test_interest.py @@ -68,7 +68,7 @@ def test_withdrawn_investment(db, client, create_token, user_ains): token = create_token(user_ains) response = client.post( - "/investments/1/withdrawn", + "/investments/1/withdrawn/", data=json.dumps(data), content_type="application/json", HTTP_AUTHORIZATION=token, @@ -95,7 +95,7 @@ def test_withdrawn_investment_before_created_at(db, client, create_token, user_a token = create_token(user_ains) response = client.post( - "/investments/1/withdrawn", + "/investments/1/withdrawn/", data=json.dumps(data), content_type="application/json", HTTP_AUTHORIZATION=token, @@ -115,7 +115,7 @@ def test_get_investment(db, client, create_token, user_ains): token = create_token(user_ains) serialized_investment = InvestmentSerializer(investment).data - response = client.get("/investments/1", HTTP_AUTHORIZATION=token) + response = client.get("/investments/1/", HTTP_AUTHORIZATION=token) response_data = response.json() assert response.status_code == status.HTTP_200_OK @@ -123,7 +123,7 @@ def test_get_investment(db, client, create_token, user_ains): def test_get_investment_other_owner(db, client, create_token, user_ains): - investment = baker.make( + baker.make( "investments.Investment", pk=1, amount=100, created_at=now() ) token = create_token(user_ains) diff --git a/investments/urls.py b/investments/urls.py index f34785046..41e1aae40 100644 --- a/investments/urls.py +++ b/investments/urls.py @@ -4,7 +4,7 @@ from .views import InvestmentViewSet -router = SimpleRouter(trailing_slash=False) +router = SimpleRouter() router.register("", InvestmentViewSet) urlpatterns = [ diff --git a/investments/views.py b/investments/views.py index d20bad504..7cb3a4c41 100644 --- a/investments/views.py +++ b/investments/views.py @@ -1,3 +1,4 @@ +from .tasks import send_withdrawn_alert_email_task from rest_framework.viewsets import GenericViewSet from rest_framework.mixins import ( ListModelMixin, @@ -42,6 +43,10 @@ def create(self, request): def get_queryset(self): qs = super().get_queryset() + + if not self.request.user.id: + return qs.none() + return qs.filter(owner=self.request.user) @action( @@ -63,6 +68,8 @@ def withdrawn(self, request, pk): serializer.is_valid(raise_exception=True) investment = serializer.save() + send_withdrawn_alert_email_task.delay(investment.owner.email, investment.balance) + return Response( WithdrawalSerializer().to_representation(investment), status=status.HTTP_202_ACCEPTED, From 8fd8c0414dd2ec5795e9f5b12a73e2ef1295ed93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 02:16:38 -0300 Subject: [PATCH 24/28] test: mocks celery task --- investments/tasks.py | 12 ++++++------ investments/tests/test_interest.py | 18 +++++++++--------- investments/views.py | 4 +++- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/investments/tasks.py b/investments/tasks.py index 9a02a0ada..e8b5d27a2 100644 --- a/investments/tasks.py +++ b/investments/tasks.py @@ -4,9 +4,9 @@ @shared_task() def send_withdrawn_alert_email_task(email_address, amount): - send_mail( - "You have made an withdrawn", - f"An withdrawn of {amount} just occurred on your account!", - "fake@example.com", - [email_address] - ) \ No newline at end of file + send_mail( + "You have made an withdrawn", + f"An withdrawn of {amount} just occurred on your account!", + "fake@example.com", + [email_address], + ) diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py index ab0a9b62f..9e56ddb86 100644 --- a/investments/tests/test_interest.py +++ b/investments/tests/test_interest.py @@ -2,6 +2,7 @@ from django.utils.timezone import now, timedelta from model_bakery import baker from rest_framework import status +from unittest.mock import patch from ..serializers import InvestmentSerializer from ..services import interest_svc @@ -67,12 +68,13 @@ def test_withdrawn_investment(db, client, create_token, user_ains): data = {"withdrawn_at": withdrawn_at.isoformat()} token = create_token(user_ains) - response = client.post( - "/investments/1/withdrawn/", - data=json.dumps(data), - content_type="application/json", - HTTP_AUTHORIZATION=token, - ) + with patch("investments.views.send_withdrawn_alert_email_task.delay") as mock_task: + response = client.post( + "/investments/1/withdrawn/", + data=json.dumps(data), + content_type="application/json", + HTTP_AUTHORIZATION=token, + ) response_data = response.json() expected_gain = interest_svc.gain_formula(100, 12) @@ -123,9 +125,7 @@ def test_get_investment(db, client, create_token, user_ains): def test_get_investment_other_owner(db, client, create_token, user_ains): - baker.make( - "investments.Investment", pk=1, amount=100, created_at=now() - ) + baker.make("investments.Investment", pk=1, amount=100, created_at=now()) token = create_token(user_ains) response = client.get("/investments/1/", HTTP_AUTHORIZATION=token) diff --git a/investments/views.py b/investments/views.py index 7cb3a4c41..e41071399 100644 --- a/investments/views.py +++ b/investments/views.py @@ -68,7 +68,9 @@ def withdrawn(self, request, pk): serializer.is_valid(raise_exception=True) investment = serializer.save() - send_withdrawn_alert_email_task.delay(investment.owner.email, investment.balance) + send_withdrawn_alert_email_task.delay( + investment.owner.email, investment.balance + ) return Response( WithdrawalSerializer().to_representation(investment), From 4389cf9c2bb82d667af1686d0263eb0fa7aca959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 02:38:03 -0300 Subject: [PATCH 25/28] feat: changes amount float field to decimal field as it's more suitable for money --- .../migrations/0003_alter_investment_amount.py | 18 ++++++++++++++++++ investments/models.py | 2 +- investments/serializers.py | 4 ++-- investments/services/interest_svc.py | 7 ++++--- 4 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 investments/migrations/0003_alter_investment_amount.py diff --git a/investments/migrations/0003_alter_investment_amount.py b/investments/migrations/0003_alter_investment_amount.py new file mode 100644 index 000000000..3bb2e80e0 --- /dev/null +++ b/investments/migrations/0003_alter_investment_amount.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1 on 2022-08-17 05:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("investments", "0002_investment_withdrawn_at"), + ] + + operations = [ + migrations.AlterField( + model_name="investment", + name="amount", + field=models.DecimalField(decimal_places=2, max_digits=11), + ), + ] diff --git a/investments/models.py b/investments/models.py index f5f6b7022..c32b022b0 100644 --- a/investments/models.py +++ b/investments/models.py @@ -8,7 +8,7 @@ # Create your models here. class Investment(models.Model): owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - amount = models.FloatField() + amount = models.DecimalField(max_digits=11, decimal_places=2) active = models.BooleanField(default=True, null=False) created_at = models.DateTimeField() withdrawn_at = models.DateTimeField(null=True) diff --git a/investments/serializers.py b/investments/serializers.py index ce4419e90..4ddacbf8c 100644 --- a/investments/serializers.py +++ b/investments/serializers.py @@ -6,7 +6,7 @@ class InvestmentSerializer(serializers.ModelSerializer): - balance = serializers.ReadOnlyField() + balance = serializers.DecimalField(max_digits=11, decimal_places=2, read_only=True) class Meta: model = Investment @@ -24,7 +24,7 @@ def create(self, validated_data): class WithdrawalSerializer(serializers.ModelSerializer): - balance = serializers.ReadOnlyField() + balance = serializers.DecimalField(max_digits=11, decimal_places=2, read_only=True) class Meta: model = Investment diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py index 128cde6eb..f18914543 100644 --- a/investments/services/interest_svc.py +++ b/investments/services/interest_svc.py @@ -1,3 +1,4 @@ +import decimal import math from django.conf import settings @@ -5,8 +6,8 @@ INTEREST = settings.CODEROCKR_INTEREST -def gain_formula(amount, months, total=False): - return amount * math.pow((1 + INTEREST), months) +def gain_formula(amount, months): + return amount * decimal.Decimal(math.pow((1 + INTEREST), months)) def calculate_tax(gains, age): @@ -20,4 +21,4 @@ def calculate_tax(gains, age): def _apply_tax(gains, tax): - return gains - (gains * tax / 100) + return gains - (gains * decimal.Decimal(tax) / 100) From 786d9a0141b0d25700da9febc8adc244b1d7013b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 02:42:39 -0300 Subject: [PATCH 26/28] docs: updates README.MD --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 650f300ad..aa37bcdbf 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ 3. **django-rest-framework** - Django Framework that makes the development of Rest API easier, well structured and standardized. 4. **Markdown** - It gives DRF a nice interface, a great alternative to the OpenAPI specs. 5. **drf-yasg** - Is the responsible for the Swagger route, it automatically generates the specs based on the project routes. +6. **celery** - Is a distributed task queue made to deal with lots of messages, here it is used to send emails without affecting the route response time. +7. **redis** - Python adapter for Redis, needed by celery, who's using redis as a result backend. ### Development dependencies. From ae2684c7911caef77788aea4983e4c508067e2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Prado?= Date: Wed, 17 Aug 2022 02:55:31 -0300 Subject: [PATCH 27/28] docs: updates compound gain formula --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aa37bcdbf..01d128aa5 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ ### Gain Calculation -Formula used: Final amount = Initial amount * (1 + interest / months) ^ months +Formula used: Final amount = Initial amount * (1 + interest) ^ months ### Taxation From 6cc988d9d3915e2c9aeaaca6481431356f673820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Prado?= Date: Wed, 17 Aug 2022 03:05:44 -0300 Subject: [PATCH 28/28] test: updates investment tests --- investments/tests/test_interest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/investments/tests/test_interest.py b/investments/tests/test_interest.py index 9e56ddb86..814a62a3b 100644 --- a/investments/tests/test_interest.py +++ b/investments/tests/test_interest.py @@ -9,7 +9,7 @@ def test_create_investment(db, client, create_token, user_ains): - data = {"amount": 100, "created_at": now().isoformat()} + data = {"amount": "100.00", "created_at": now().isoformat()} token = create_token(user_ains) response = client.post( @@ -26,7 +26,7 @@ def test_create_investment(db, client, create_token, user_ains): def test_create_investment_future_date(db, client, create_token, user_ains): created_at = now() + timedelta(days=5) - data = {"amount": 100, "created_at": created_at.isoformat()} + data = {"amount": "100.00", "created_at": created_at.isoformat()} token = create_token(user_ains) response = client.post( @@ -41,7 +41,7 @@ def test_create_investment_future_date(db, client, create_token, user_ains): def test_create_investment_past_date(db, client, create_token, user_ains): created_at = now() - timedelta(days=5) - data = {"amount": 100, "created_at": created_at.isoformat()} + data = {"amount": "100.00", "created_at": created_at.isoformat()} token = create_token(user_ains) response = client.post( @@ -81,7 +81,7 @@ def test_withdrawn_investment(db, client, create_token, user_ains): expected_gain_with_taxes = interest_svc._apply_tax(expected_gain - 100, 18.5) assert response.status_code == status.HTTP_202_ACCEPTED - assert response_data["balance"] == expected_gain_with_taxes + assert response_data["balance"] == f"{expected_gain_with_taxes:.2f}" def test_withdrawn_investment_before_created_at(db, client, create_token, user_ains):