diff --git a/.env.dev b/.env.dev new file mode 100644 index 000000000..f6ca09e22 --- /dev/null +++ b/.env.dev @@ -0,0 +1,17 @@ +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 + +# MAIL + +MAIL_HOST=mail +MAIL_PORT=8025 \ No newline at end of file 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/.gitignore b/.gitignore new file mode 100644 index 000000000..60d8461cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,177 @@ +# 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 + +# 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/README.md b/README.md index ea8115e67..01d128aa5 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,65 @@ # 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 {token}" then press the "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 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. +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. + +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. 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 ### 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/__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/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/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 new file mode 100644 index 000000000..20232dc28 --- /dev/null +++ b/coderockr/settings.py @@ -0,0 +1,183 @@ +""" +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/ +""" + +import os +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', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', + 'core', + 'investments' +] + +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': 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'), + 'PORT': os.getenv('DB_PORT'), + } +} + + +# 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' + +# 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', + ], + 'PAGE_SIZE': 10 +} + +LOGIN_REDIRECT_URL='/' +LOGIN_URL='/login' + +# Swagger + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header' + } + } +} + +# Celery + +CELERY_BROKER_URL=os.getenv('CELERY_BROKER_URL') +CELERY_RESULT_BACKEND=os.getenv('CELERY_RESULT_BACKEND') + +# Domain specific constants + +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/coderockr/urls.py b/coderockr/urls.py new file mode 100644 index 000000000..aed1c33c4 --- /dev/null +++ b/coderockr/urls.py @@ -0,0 +1,34 @@ +"""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, re_path, include +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", + ), + 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/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/conftest.py b/conftest.py new file mode 100644 index 000000000..d7cf00acd --- /dev/null +++ b/conftest.py @@ -0,0 +1,31 @@ +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/__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..917cf16e5 --- /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) diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 000000000..c0ce093bd --- /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/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/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/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/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..21fca586a --- /dev/null +++ b/core/models.py @@ -0,0 +1,13 @@ +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/permissions.py b/core/permissions.py new file mode 100644 index 000000000..a735926b7 --- /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..5e160464a --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,31 @@ +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": {"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"], + ) + + 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/tests/__init__.py b/core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/tests/test_user.py b/core/tests/test_user.py new file mode 100644 index 000000000..238f69056 --- /dev/null +++ b/core/tests/test_user.py @@ -0,0 +1,85 @@ +import json +from model_bakery import baker +from rest_framework import status +from ..models import User + + +def test_create(db, client): + data = {"email": "jorge@gmail.com", "password": "jorge"} + + response = client.post( + "/users", + data=json.dumps(data), + content_type="application/json", + ) + response_data = response.json() + + assert response.status_code == status.HTTP_201_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( + "/users", + data=json.dumps(data), + content_type="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +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( + "/users/1", + data=json.dumps(data), + HTTP_AUTHORIZATION=token, + content_type="application/json", + ) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data["email"] == data["email"] + + +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( + "/users/2", + data=json.dumps(data), + HTTP_AUTHORIZATION=token_jorge, + content_type="application/json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +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("/users/1", HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + +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 = create_token(user_jorge) + + response = client.delete("/users/2", HTTP_AUTHORIZATION=token) + + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 000000000..32678c913 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework.routers import SimpleRouter +from rest_framework.authtoken.views import obtain_auth_token +from .views import UserViewSet + + +router = SimpleRouter(trailing_slash=False) +router.register("users", UserViewSet) + +urlpatterns = [ + path(r"login/", obtain_auth_token), + path("", include(router.urls)), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 000000000..f0e220816 --- /dev/null +++ b/core/views.py @@ -0,0 +1,39 @@ +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( + 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(instance=request.user) + return Response(serializer.data) + + 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 new file mode 100644 index 000000000..81d664f09 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + app: + image: joaop/coderockr + build: + context: . + entrypoint: + - ./docker-entrypoint.sh + env_file: + - ./.env.dev + volumes: + - .:/app + ports: + - 8000:8000 + - 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 + 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 + 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 new file mode 100644 index 000000000..99a7687bd --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +wait-for-it db:5432 -- python -m manage migrate +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/investments/__init__.py b/investments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/investments/apps.py b/investments/apps.py new file mode 100644 index 000000000..310f067bc --- /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/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/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/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..c32b022b0 --- /dev/null +++ b/investments/models.py @@ -0,0 +1,29 @@ +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. +class Investment(models.Model): + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + 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) + + @property + 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) if gains else 0 diff --git a/investments/permissions.py b/investments/permissions.py new file mode 100644 index 000000000..a0b304580 --- /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 diff --git a/investments/serializers.py b/investments/serializers.py new file mode 100644 index 000000000..4ddacbf8c --- /dev/null +++ b/investments/serializers.py @@ -0,0 +1,61 @@ +from dataclasses import fields +from rest_framework import serializers +from rest_framework.validators import ValidationError +from .validators import NotFutureDateValidator +from .models import Investment + + +class InvestmentSerializer(serializers.ModelSerializer): + balance = serializers.DecimalField(max_digits=11, decimal_places=2, read_only=True) + + 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.DecimalField(max_digits=11, decimal_places=2, read_only=True) + + class Meta: + model = Investment + fields = "__all__" + read_only_fields = ( + "id", + "owner", + "amount", + "balance", + "active", + "created_at", + ) + extra_kwargs = { + "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) + + def to_representation(self, instance): + return InvestmentSerializer().to_representation(instance) diff --git a/investments/services/interest_svc.py b/investments/services/interest_svc.py new file mode 100644 index 000000000..f18914543 --- /dev/null +++ b/investments/services/interest_svc.py @@ -0,0 +1,24 @@ +import decimal +import math +from django.conf import settings + + +INTEREST = settings.CODEROCKR_INTEREST + + +def gain_formula(amount, months): + return amount * decimal.Decimal(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 * decimal.Decimal(tax) / 100) diff --git a/investments/tasks.py b/investments/tasks.py new file mode 100644 index 000000000..e8b5d27a2 --- /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], + ) 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..814a62a3b --- /dev/null +++ b/investments/tests/test_interest.py @@ -0,0 +1,175 @@ +import json +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 + + +def test_create_investment(db, client, create_token, user_ains): + data = {"amount": "100.00", "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.00", "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.00", "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() - timedelta(days=365), + owner=user_ains, + ) + withdrawn_at = now() + data = {"withdrawn_at": withdrawn_at.isoformat()} + token = create_token(user_ains) + + 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) + 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"] == f"{expected_gain_with_taxes:.2f}" + + +def test_withdrawn_investment_before_created_at(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): + 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/urls.py b/investments/urls.py new file mode 100644 index 000000000..41e1aae40 --- /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/utils.py b/investments/utils.py new file mode 100644 index 000000000..16c5c32ab --- /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 diff --git a/investments/validators.py b/investments/validators.py new file mode 100644 index 000000000..fdc1f9148 --- /dev/null +++ b/investments/validators.py @@ -0,0 +1,14 @@ +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.") + + +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 new file mode 100644 index 000000000..e41071399 --- /dev/null +++ b/investments/views.py @@ -0,0 +1,78 @@ +from .tasks import send_withdrawn_alert_email_task +from rest_framework.viewsets import GenericViewSet +from rest_framework.mixins import ( + ListModelMixin, + RetrieveModelMixin, + CreateModelMixin, +) +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 +from .permissions import IsOwnInvestment + + +# Create your views here. +class InvestmentViewSet( + ListModelMixin, + RetrieveModelMixin, + CreateModelMixin, + GenericViewSet, +): + queryset = Investment.objects.all() + serializer_class = InvestmentSerializer + permission_classes = (IsAuthenticated, IsOwnInvestment) + pagination_class = LimitOffsetPagination + + 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() + + if not self.request.user.id: + return qs.none() + + 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) + + 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() + + send_withdrawn_alert_email_task.delay( + investment.owner.email, investment.balance + ) + + return Response( + WithdrawalSerializer().to_representation(investment), + status=status.HTTP_202_ACCEPTED, + ) 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..394e44ae5 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,850 @@ +[[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 new file mode 100644 index 000000000..b7308038e --- /dev/null +++ b/pyproject.toml @@ -0,0 +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" +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 +) +''' 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