diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..579bbb7b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +SECRET_KEY= +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c8561f5cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv +__pycache__ +.env +db.sqlite3 +.vscode + diff --git a/README.md b/README.md index ea8115e67..3a4007919 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,52 @@ -# Back End Test Project +# Tenha controle de seus investimentos + +##### Projeto no qual você pode guardar seus investimentos ou de outras pessoas e vê-los crescendo com a quantia que colocou com os juros compostos, você também pode retirá-los, o que ocasionará no retiro total da quantia e tendo ainda um histórico de investimentos em cada conta! + +## Tecnologias Utilizadas + +#### Neste projeto foram usados as seguintes tecnologias: + +``` + - Python + - Django + - Django Rest Framework + - PostgreSQL +``` +## Pré Requisitos: + -Python + -Pip + -PostgreSQL +#### Assim que clonar o repositório entre na pasta com : + - cd backend-test-nomedousuário + - code . + +## Abra o terminal para instalar as dependências no ambiente virtual(venv): + #### Para utilizar o venv se estiver utilizando Linux use o comando: + + source venv/bin/activate + + #### Para utilizar o venv se estiver utilizando bash use o comando: + + source venv/Scripts/activate + #### Para utilizar o venv se estiver utilizando PowerShell use o comando: + + venv/Scripts/activate + + ### Para instalar as dependências use o comando: + pip install -r requirements.txt + + ##### Dentro do arquivo requirements.txt está todos as denpendências que o projeto necessita para o código rodar normalmente. + +## Após instalar pode se iniciar o projeto com + ### Rodando as migrations com + python manage.py migrate + ### Após rodar as migrations pode iniciar o projeto com: + python manage.py runserver + # Para acessar a documentação, após iniciar o servidor, possui a rota +Nesta rota acessando pelo localhost:****/api/docs terá acesso a documentação da api! +``` +/api/docs +```` -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. -## Scope -In this challenge you should build an API for an application that stores and manages investments, it should have the following features: - -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. -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 -3. __Withdrawal__ of a investment. - 1. 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. -4. __List__ of a person's investments - 1. This list should have pagination. - -__NOTE:__ the implementation of an interface will not be evaluated. - -### 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. - -### 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: -* 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/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..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/baker_recipes.py b/investments/baker_recipes.py new file mode 100644 index 000000000..0670c591f --- /dev/null +++ b/investments/baker_recipes.py @@ -0,0 +1,8 @@ +from model_bakery.recipe import Recipe +from investments.models import Investment + +new_investment = Recipe( + Investment, + amount= 100, + created_at= "2022-10-06" +) \ No newline at end of file diff --git a/investments/migrations/0001_initial.py b/investments/migrations/0001_initial.py new file mode 100644 index 000000000..2217a933f --- /dev/null +++ b/investments/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 4.1.3 on 2022-12-06 21:57 + +from django.db import migrations, models +import investments.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Investment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "initial_amount", + models.DecimalField(decimal_places=2, max_digits=15, null=True), + ), + ( + "amount", + models.DecimalField( + decimal_places=2, + max_digits=10, + validators=[investments.models.validate_amount], + ), + ), + ( + "created_at", + models.DateField(validators=[investments.models.validate_date]), + ), + ( + "gains", + models.DecimalField( + decimal_places=2, default=0, max_digits=5, null=True + ), + ), + ( + "withdrawn_date", + models.DateField( + null=True, + validators=[investments.models.validate_withdrawn_date], + ), + ), + ( + "withdrew_amount", + models.DecimalField(decimal_places=2, max_digits=15, null=True), + ), + ( + "expected_balance", + models.DecimalField( + decimal_places=2, default=0, max_digits=15, null=True + ), + ), + ("isActive", models.BooleanField(default=True)), + ], + ), + ] diff --git a/investments/migrations/0002_initial.py b/investments/migrations/0002_initial.py new file mode 100644 index 000000000..3037c3834 --- /dev/null +++ b/investments/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.3 on 2022-12-06 21:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("investments", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="investment", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="investments", + 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..cdc7a11e1 --- /dev/null +++ b/investments/models.py @@ -0,0 +1,103 @@ +from django.db import models +from django.core.exceptions import ValidationError +from datetime import date +import ipdb +import uuid +from rest_framework.exceptions import APIException +from rest_framework.views import status + +class CustomValidation(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = "A server error occured." + + def __init__(self, detail, status_code): + if status_code is not None:self.status_code=status_code + if detail is not None: + self.detail={"detail": detail} + +def validate_amount(value): + if value < 0 : + raise ValidationError( + (f'{value} is a invalid number, please only positive numbers!') + ) + +def validate_date(date_req): + today = date.today() + + day = str(today).split("-")[2] + month = str(today).split("-")[1] + + if int(day) > 31: + raise ValidationError( + (f'{day} is a invalid day, please insert a valid day!') + ) + + if int(month) > 12: + raise ValidationError( + (f'{month} is a invalid month, please insert a valid month!') + ) + + today_formated = today.strftime("%d/%m/%Y") + + formated_date = date_req.strftime("%d/%m/%Y") + + if today_formated.split('/')[2] < formated_date.split('/')[2]: + raise CustomValidation("Invalid Date!", 400) + + if today_formated.split('/')[2] == formated_date.split('/')[2]: + if today_formated.split('/')[1] < formated_date.split('/')[1]: + raise CustomValidation("Invalid Date!", 400) + + + if today_formated.split('/')[2] == formated_date.split('/')[2]: + if today_formated.split('/')[1] == formated_date.split('/')[1]: + if today_formated.split('/')[0] < formated_date.split('/')[0]: + raise CustomValidation("Invalid Date!", 400) + +def validate_withdrawn_date(date_req): + today = date.today() + + if int(str(today).split("-")[2]) > 31: + ipdb.set_trace() + raise CustomValidation("Invalid Date!",400) + + if int(str(today).split("-")[1]) > 12: + ipdb.set_trace() + + raise CustomValidation("Invalid Date!",400) + + today_formated = str(today) + + formated_date = str(date_req) + + if today_formated.split('-')[0] < formated_date.split('-')[0]: + raise CustomValidation("Invalid Date!",400) + + if today_formated.split('-')[0] == formated_date.split('-')[0]: + if today_formated.split('-')[1] < formated_date.split('-')[1]: + raise CustomValidation("Invalid Date!",400) + + + if today_formated.split('-')[0] == formated_date.split('-')[0]: + if today_formated.split('-')[1] == formated_date.split('-')[1]: + if today_formated.split('-')[2] < formated_date.split('-')[2]: + raise CustomValidation("Invalid Date!",400) + + +class Investment(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + initial_amount = models.DecimalField(decimal_places=2,max_digits=15, null=True) + amount = models.DecimalField(decimal_places=2,max_digits=10,validators=[validate_amount]) + created_at = models.DateField(validators=[validate_date]) + gains = models.DecimalField(decimal_places=2,max_digits=5, null=True, default=0) + withdrawn_date = models.DateField(null=True, validators=[validate_withdrawn_date]) + withdrew_amount = models.DecimalField(decimal_places=2,max_digits=15, null=True) + expected_balance = models.DecimalField(decimal_places=2,max_digits=15, null=True, default=0) + isActive = models.BooleanField(default=True) + + owner = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + related_name="investments" + ) + diff --git a/investments/serializers.py b/investments/serializers.py new file mode 100644 index 000000000..cc0830b15 --- /dev/null +++ b/investments/serializers.py @@ -0,0 +1,231 @@ +from decimal import Decimal +from django.shortcuts import get_object_or_404 +from rest_framework import serializers + +from investments.models import Investment +import ipdb +from rest_framework.exceptions import APIException +from rest_framework.views import status + +from users.models import User + +class CustomValidation(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = "A server error occured." + + def __init__(self, detail, status_code): + if status_code is not None:self.status_code=status_code + if detail is not None: + self.detail={"detail": detail} + +def validate_dates_and_tax(investment, today_date, investment_date): + + today_separeted_date = str(today_date).split("/") + + separeted_date = str(investment_date).split("/") + + if int(today_separeted_date[2]) == int(separeted_date[2]): + if int(today_separeted_date[1]) > int(separeted_date[1]): + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(22.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + if int(today_separeted_date[2]) - int(separeted_date[2]) == 1 : + + if int(today_separeted_date[1]) == int(separeted_date[1]): + + if int(today_separeted_date[0]) == int(separeted_date[0]): + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + if ( + int(today_separeted_date[2]) - int(separeted_date[2]) == 1 + or + int(today_separeted_date[2]) - int(separeted_date[2]) == 2): + + if int(today_separeted_date[1]) == int(separeted_date[1]): + if int(today_separeted_date[0]) == int(separeted_date[0]): + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + + if int(today_separeted_date[1]) > int(separeted_date[1]): + + if int(today_separeted_date[0]) < int(separeted_date[0]): + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + if int(today_separeted_date[2]) - int(separeted_date[2]) > 2: + + if int(today_separeted_date[1]) == int(separeted_date[1]): + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + if int(today_separeted_date[1]) > int(separeted_date[1]): + if int(today_separeted_date[0]) < int(separeted_date[0]): + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + + result = investment.amount - investment.initial_amount + + tax = result * Decimal(18.5/100) + + charged = investment.amount - Decimal(tax) + + return round(charged, 2) + +class InvestmentSerializer(serializers.ModelSerializer): + class Meta: + model = Investment + + fields =[ + "id", + "amount", + "created_at", + "gains", + "owner_id", + "isActive", + "initial_amount", + "withdrawn_date" + ] + + read_only_fields=[ + "id", + "owner_id", + "gains", + "isActive", + "initial_amount", + "withdrawn_date" + ] + +class InvestmentDetailSerializer(serializers.ModelSerializer): + + class Meta: + model = Investment + + fields = [ + "id", + "amount", + "created_at", + "gains", + "owner", + "expected_balance", + "initial_amount", + ] + + read_only_fields=[ + "id", + "owner", + "gains", + "expected_balance", + "initial_amount", + ] + + def create(self, validated_data: dict) -> Investment: + owner_id = self.context['view'].kwargs['owner_id'] + + owner = get_object_or_404(User, pk=owner_id) + + validated_data['initial_amount'] = validated_data['amount'] + + return Investment.objects.create(**validated_data, owner=owner) + +class InvestmentWithdrawnDetailSerializer(serializers.ModelSerializer): + + class Meta: + model = Investment + + fields = [ + "id", + "amount", + "created_at", + "gains", + "owner", + "expected_balance", + "initial_amount", + "withdrawn_date", + "withdrew_amount" + ] + + read_only_fields=[ + "id", + "amount", + "created_at", + "gains", + "owner", + "expected_balance", + "isActive", + "initial_amount", + "withdrew_amount" + ] + + def create(self, validated_data: dict) -> Investment: + investment_id = self.context['view'].kwargs['investment_id'] + + investment = get_object_or_404(Investment, pk=investment_id) + + if investment.withdrawn_date != None: + raise CustomValidation(409, "Investment has already been withdrew!") + + date = validated_data["withdrawn_date"] + + today_date = date.today() + + today_date_formated = today_date.strftime("%d/%m/%Y") + + formated_date = investment.created_at.strftime("%d/%m/%Y") + + result = validate_dates_and_tax(investment, today_date_formated, formated_date) + + investment.initial_amount = investment.amount + investment.withdrew_amount = result + investment.withdrawn_date = date + investment.amount = 0 + investment.expected_balance = 0 + investment.gains = 0 + + investment.save() + + return investment \ No newline at end of file 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_models.py b/investments/tests/test_models.py new file mode 100644 index 000000000..3b85acb7b --- /dev/null +++ b/investments/tests/test_models.py @@ -0,0 +1,30 @@ +from django.test import TestCase +from model_bakery import baker +import ipdb + +class InvestmentModelTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.investment_create = baker.make_recipe('investments.new_investment') + + def test_name_max_legth(self): + + max_length = self.investment_create._meta.get_field('amount').max_digits + + self.assertEqual(max_length, 10) + + def test_has_correct_fields(self): + self.assertEqual(self.investment_create.amount, 100) + self.assertEqual(self.investment_create.created_at, "2022-10-06") + self.assertEqual(self.investment_create.gains, 0) + self.assertEqual(self.investment_create.withdrawn_date, None) + self.assertEqual(self.investment_create.expected_balance, 0) + self.assertEqual(self.investment_create.initial_amount, None) + self.assertTrue(self.investment_create.isActive) + self.assertTrue(self.investment_create.owner_id) + + def test_amount_number_is_positive(self): + self.assertTrue(self.investment_create.amount > 0) + + def test_episode_has_owner_id(self): + self.assertTrue(self.investment_create.owner) diff --git a/investments/tests/test_views.py b/investments/tests/test_views.py new file mode 100644 index 000000000..82cbf0869 --- /dev/null +++ b/investments/tests/test_views.py @@ -0,0 +1,119 @@ +from rest_framework.test import APITestCase + +from model_bakery import baker +import ipdb + +from investments.models import Investment +from investments.serializers import InvestmentDetailSerializer + +class InvestmentTestViews(APITestCase): + + @classmethod + def setUpTestData(cls): + + cls.user_create = baker.make_recipe('users.new_user') + + cls.create_investment_uri = f'/api/investments/{cls.user_create.id}/' + + cls.get_investments_uri = '/api/investments/' + + cls.valid_investment = { + "amount": 1000, + "created_at": "2022-10-06" + } + + cls.invalid_investment_1 = { + "amount": -100, + "created_at": "2022-10-06" + } + + cls.invalid_investment_2 = { + "amount": 100, + "created_at": "06/07/2022" + } + + cls.invalid_investment_3 = { + "amount": 100, + "created_at": "2024-07-07" + } + + cls.valid_investment_1 = { + "amount": 1000, + "created_at": "2022-12-06" + } + + cls.response_post_create_investment = { + "amount": "1000.00", + "created_at": "2022-12-06", + "gains": "0.00", + "owner": cls.user_create.id, + "expected_balance": "0.00", + "initial_amount": "1000.00" + } + + cls.valid_withdrawn_date = { + "withdrawn_date": "2022-12-06" + } + + cls.invalid_withdrawn_date = { + "withdrawn_date": "2024-12-06" + } + + cls.valid_investment = Investment.objects.create(**cls.valid_investment, owner_id=cls.user_create.id) + + cls.get_investment_uri = f'/api/investment/{cls.valid_investment.id}/' + + cls.withdrawn_investment_uri = f'/api/investment/{cls.valid_investment.id}/withdrawn/' + + cls.delete_investment_uri = f'/api/investment/{cls.valid_investment.id}/' + + def test_can_register_investment(self): + response_post = self.client.post(self.create_investment_uri, data=self.valid_investment_1, format="json") + del response_post.data['id'] + self.assertEqual(self.response_post_create_investment, response_post.data) + self.assertEqual(response_post.status_code, 201) + + def test_cannot_register_negative_investment(self): + response_post = self.client.post(self.create_investment_uri, self.invalid_investment_1, format="json") + self.assertEqual(response_post.status_code, 400) + for key, value in response_post.data.items(): + self.assertEqual(value[0][:], f"{self.invalid_investment_1['amount']}.00 is a invalid number, please only positive numbers!") + + def test_cannot_register_investment_invalid_formate_date_1(self): + response_post = self.client.post(self.create_investment_uri, self.invalid_investment_2, format="json") + self.assertEqual(response_post.status_code, 400) + for key, value in response_post.data.items(): + self.assertEqual(value[0][:], "Date has wrong format. Use one of these formats instead: YYYY-MM-DD.") + + def test_cannot_register_investment_invalid_formate_date_2(self): + + response_post = self.client.post(self.create_investment_uri, self.invalid_investment_3, format="json") + self.assertEqual(response_post.status_code, 400) + + self.assertEqual(response_post.data, {"detail": "Invalid Date!"}) + + def test_cannot_register_empty_investment(self): + response_post = self.client.post(self.create_investment_uri, {}, format="json") + self.assertEqual(response_post.status_code, 400) + for key, value in response_post.data.items(): + self.assertEqual(value[0][:], "This field is required.") + + def test_can_list_investments(self): + response = self.client.get(self.get_investments_uri) + self.assertEqual(200, response.status_code) + self.assertEqual(response.data['count'], 1) + + def test_can_list_one_investment(self): + response = self.client.get(self.get_investment_uri) + self.assertEqual(200, response.status_code) + + def test_can_withdrawn_investment(self): + self.client.get(self.get_investment_uri) + response_withdrawn = self.client.post(self.withdrawn_investment_uri, self.valid_withdrawn_date, format="json") + self.assertEqual(response_withdrawn.status_code, 201) + self.assertEqual(response_withdrawn.data['withdrawn_date'], self.valid_withdrawn_date['withdrawn_date']) + + def test_can_safe_delete_investment(self): + response_delete = self.client.delete(self.delete_investment_uri) + self.assertEqual(response_delete.status_code, 204) + diff --git a/investments/urls.py b/investments/urls.py new file mode 100644 index 000000000..c760faa23 --- /dev/null +++ b/investments/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from investments.views import CreateInvestmentView, ListInvestmentView, ListOneUserInvestmentsView, RetrieveUpdateDestroyInvestmentDetailView, WithdrawnInvestmentView + + +urlpatterns=[ + path('investments/', ListInvestmentView.as_view()), + path('investments//', CreateInvestmentView.as_view()), + path('investments//management/', ListOneUserInvestmentsView.as_view()), + path('investment//', RetrieveUpdateDestroyInvestmentDetailView.as_view()), + path('investment//withdrawn/', WithdrawnInvestmentView.as_view()), + +] diff --git a/investments/views.py b/investments/views.py new file mode 100644 index 000000000..c6ff9d550 --- /dev/null +++ b/investments/views.py @@ -0,0 +1,235 @@ +from django.forms import model_to_dict +from django.shortcuts import get_object_or_404 +from rest_framework import generics +from rest_framework.views import Response, status +from datetime import date +from decimal import Decimal +from django.core.paginator import Paginator + +import ipdb + +from investments.models import Investment +from investments.serializers import InvestmentDetailSerializer, InvestmentSerializer, InvestmentWithdrawnDetailSerializer + + +def validate_dates(investment, today_date, investment_date): + + today_separeted_date = str(today_date).split("/") + + separeted_date = str(investment_date).split("/") + + if int(today_separeted_date[2]) == int(separeted_date[2]): + if int(today_separeted_date[1]) > int(separeted_date[1]): + + months = int(today_separeted_date[1]) - int(separeted_date[1]) + investment.initial_amount = investment.amount + + for _ in range(months): + new_gains = Decimal(investment.amount) * Decimal(0.52/100) + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + if int(today_separeted_date[2]) - int(separeted_date[2]) == 1 : + + if int(today_separeted_date[1]) == int(separeted_date[1]): + + if int(today_separeted_date[0]) == int(separeted_date[0]): + + new_gains = 12 * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.initial_amount = investment.amount + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + if ( + int(today_separeted_date[2]) - int(separeted_date[2]) == 1 + or + int(today_separeted_date[2]) - int(separeted_date[2]) == 2): + + years = int(today_separeted_date[2]) - int(separeted_date[2]) + + if int(today_separeted_date[1]) == int(separeted_date[1]): + if int(today_separeted_date[0]) == int(separeted_date[0]): + new_gains = (years * 12) * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.initial_amount = investment.amount + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + + if int(today_separeted_date[1]) > int(separeted_date[1]): + + months = int(today_separeted_date[1]) - int(separeted_date[1]) + + if int(today_separeted_date[0]) < int(separeted_date[0]): + + months = int(separeted_date[1]) - int(today_separeted_date[1]) + investment.initial_amount = investment.amount + + for _ in range(months - 1): + + new_gains = (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + investment.save() + + return investment + + years = int(today_separeted_date[2]) - int(separeted_date[2]) + + for _ in range(months): + + new_gains = (years * 12) * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + if int(today_separeted_date[2]) - int(separeted_date[2]) > 2: + + if int(today_separeted_date[1]) == int(separeted_date[1]): + + years = int(today_separeted_date[2]) - int(separeted_date[2]) + + new_gains = (years * 12) * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + if int(today_separeted_date[1]) > int(separeted_date[1]): + months = int(today_separeted_date[1]) - int(separeted_date[1]) + years = int(today_separeted_date[2]) - int(separeted_date[2]) + + if int(today_separeted_date[0]) < int(separeted_date[0]): + investment.initial_amount = investment.amount + + for _ in range(months - 1): + + new_gains = (years * 12) * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + + for _ in range(months): + + new_gains = (years * 12) * (Decimal(investment.amount) * Decimal((0.52/100))) + + expected_balance = (new_gains + Decimal(investment.amount)) + + investment.gains = round(new_gains,2) + investment.amount = expected_balance + investment.expected_balance = round(expected_balance + new_gains , 2) + + investment.save() + + return investment + +class ListInvestmentView(generics.ListAPIView): + queryset = Investment.objects.all() + serializer_class = InvestmentSerializer + +class ListOneUserInvestmentsView(generics.ListAPIView): + queryset = Investment.objects.all() + serializer_class = InvestmentSerializer + + lookup_url_kwarg = 'owner_id' + + def get_queryset(self): + return self.queryset.filter(owner_id=self.kwargs['owner_id']) + +class CreateInvestmentView(generics.CreateAPIView): + + queryset = Investment.objects.all() + serializer_class = InvestmentDetailSerializer + + lookup_url_kwarg = 'owner_id' + +class RetrieveUpdateDestroyInvestmentDetailView( + generics.RetrieveDestroyAPIView + ): + + queryset = Investment.objects.all() + serializer_class = InvestmentDetailSerializer + + lookup_url_kwarg = 'investment_id' + + def retrieve(self, request, *args, **kwargs): + investment_id = self.kwargs['investment_id'] + + investment = get_object_or_404(Investment, pk=investment_id) + + today_date = date.today() + + today_date_formated = today_date.strftime("%d/%m/%Y") + + formated_date = investment.created_at.strftime("%d/%m/%Y") + + result = validate_dates(investment, today_date_formated, formated_date) + + return super().retrieve(result) + + + def destroy(self, request, *args, **kwargs): + investment_id = self.kwargs['investment_id'] + + investment = get_object_or_404(Investment, pk=investment_id) + + investment.isActive = False + + investment.save() + + return Response({}, status.HTTP_204_NO_CONTENT) + +class WithdrawnInvestmentView(generics.CreateAPIView): + queryset = Investment.objects.all() + serializer_class = InvestmentWithdrawnDetailSerializer + + lookup_url_kwarg = 'investment_id' + + + \ No newline at end of file diff --git a/investments_management/__init__.py b/investments_management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/investments_management/asgi.py b/investments_management/asgi.py new file mode 100644 index 000000000..2d0efc648 --- /dev/null +++ b/investments_management/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for investments_management 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", "investments_management.settings") + +application = get_asgi_application() diff --git a/investments_management/settings.py b/investments_management/settings.py new file mode 100644 index 000000000..12fb59544 --- /dev/null +++ b/investments_management/settings.py @@ -0,0 +1,159 @@ +""" +Django settings for investments_management project. + +Generated by 'django-admin startproject' using Django 4.1.3. + +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 +import os +import dotenv + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +dotenv.load_dotenv() + +# 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 = os.getenv('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +DJANGO_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +THIRD_PARTY_APPS = [ + "rest_framework", + "drf_spectacular", +] + +MY_APPS = [ + "investments", + "users" +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + MY_APPS + +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 = "investments_management.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 = "investments_management.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB"), + "USER": os.getenv("POSTGRES_USER"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD"), + "HOST": "127.0.0.1", + "PORT": 5432, + } +} + + +# 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" + +AUTH_USER_MODEL = "users.User" + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Test Back End - Coderockr', + 'DESCRIPTION': 'Coderockr Backend recruitment test ', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + # OTHER SETTINGS +} \ No newline at end of file diff --git a/investments_management/urls.py b/investments_management/urls.py new file mode 100644 index 000000000..c59bf5a36 --- /dev/null +++ b/investments_management/urls.py @@ -0,0 +1,23 @@ +"""investments_management 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, include + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("users.urls")), + path("api/", include("investments.urls")) +] diff --git a/investments_management/wsgi.py b/investments_management/wsgi.py new file mode 100644 index 000000000..8e57935c1 --- /dev/null +++ b/investments_management/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for investments_management 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", "investments_management.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 000000000..adc87f117 --- /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", "investments_management.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/requirements.txt b/requirements.txt new file mode 100644 index 000000000..15f694a9f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +asgiref==3.5.2 +asttokens==2.2.0 +attrs==22.1.0 +backcall==0.2.0 +black==22.10.0 +click==8.1.3 +colorama==0.4.6 +coverage==6.5.0 +decorator==5.1.1 +Django==4.1.3 +djangorestframework==3.14.0 +drf-spectacular==0.24.2 +executing==1.2.0 +inflection==0.5.1 +ipdb==0.13.9 +ipython==8.7.0 +jedi==0.18.2 +jsonschema==4.17.3 +matplotlib-inline==0.1.6 +model-bakery==1.9.0 +mypy-extensions==0.4.3 +parso==0.8.3 +pathspec==0.10.2 +pickleshare==0.7.5 +platformdirs==2.5.4 +prompt-toolkit==3.0.33 +psycopg2-binary==2.9.5 +pure-eval==0.2.2 +pycodestyle==2.10.0 +Pygments==2.13.0 +pyrsistent==0.19.2 +python-dotenv==0.21.0 +pytz==2022.6 +PyYAML==6.0 +six==1.16.0 +sqlparse==0.4.3 +stack-data==0.6.2 +toml==0.10.2 +tomli==2.0.1 +traitlets==5.6.0 +tzdata==2022.7 +uritemplate==4.1.1 +wcwidth==0.2.5 diff --git a/schema.yml b/schema.yml new file mode 100644 index 000000000..cce3a3261 --- /dev/null +++ b/schema.yml @@ -0,0 +1,543 @@ +openapi: 3.0.3 +info: + title: Test Back End - Coderockr + version: 1.0.0 + description: 'Coderockr Backend recruitment test ' +paths: + /api/accounts/: + get: + operationId: api_accounts_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedUserList' + description: '' + post: + operationId: api_accounts_create + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + multipart/form-data: + schema: + $ref: '#/components/schemas/User' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + /api/accounts/{id}/: + get: + operationId: api_accounts_retrieve + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetail' + description: '' + put: + operationId: api_accounts_update + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetail' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserDetail' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserDetail' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetail' + description: '' + patch: + operationId: api_accounts_partial_update + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUserDetail' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUserDetail' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUserDetail' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetail' + description: '' + delete: + operationId: api_accounts_destroy + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body + /api/investment/{investment_id}/: + get: + operationId: api_investment_retrieve + parameters: + - in: path + name: investment_id + schema: + type: string + required: true + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InvestmentDetail' + description: '' + delete: + operationId: api_investment_destroy + parameters: + - in: path + name: investment_id + schema: + type: string + required: true + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '204': + description: No response body + /api/investment/{investment_id}/withdrawn/: + post: + operationId: api_investment_withdrawn_create + parameters: + - in: path + name: investment_id + schema: + type: string + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvestmentWithdrawnDetail' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/InvestmentWithdrawnDetail' + multipart/form-data: + schema: + $ref: '#/components/schemas/InvestmentWithdrawnDetail' + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/InvestmentWithdrawnDetail' + description: '' + /api/investments/: + get: + operationId: api_investments_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + tags: + - api + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedInvestmentList' + description: '' + /api/investments/{owner_id}/: + post: + operationId: api_investments_create + parameters: + - in: path + name: owner_id + schema: + type: string + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvestmentDetail' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/InvestmentDetail' + multipart/form-data: + schema: + $ref: '#/components/schemas/InvestmentDetail' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/InvestmentDetail' + description: '' +components: + schemas: + Investment: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + created_at: + type: string + format: date + gains: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + owner_id: + type: string + format: uuid + readOnly: true + isActive: + type: boolean + readOnly: true + initial_amount: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + withdrawn_date: + type: string + format: date + readOnly: true + nullable: true + required: + - amount + - created_at + - gains + - id + - initial_amount + - isActive + - owner_id + - withdrawn_date + InvestmentDetail: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + created_at: + type: string + format: date + gains: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + owner: + type: string + format: uuid + readOnly: true + expected_balance: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + initial_amount: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + required: + - amount + - created_at + - expected_balance + - gains + - id + - initial_amount + - owner + InvestmentWithdrawnDetail: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + readOnly: true + created_at: + type: string + format: date + readOnly: true + gains: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + owner: + type: string + format: uuid + readOnly: true + expected_balance: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + initial_amount: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + withdrawn_date: + type: string + format: date + nullable: true + withdrew_amount: + type: string + format: decimal + pattern: ^-?\d{0,13}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + required: + - amount + - created_at + - expected_balance + - gains + - id + - initial_amount + - owner + - withdrew_amount + PaginatedInvestmentList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Investment' + PaginatedUserList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/User' + PatchedUserDetail: + type: object + properties: + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + email: + type: string + maxLength: 127 + investments: + type: array + items: + $ref: '#/components/schemas/Investment' + User: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + email: + type: string + maxLength: 127 + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + required: + - email + - id + - username + UserDetail: + type: object + properties: + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + email: + type: string + maxLength: 127 + investments: + type: array + items: + $ref: '#/components/schemas/Investment' + required: + - email + - investments + - username + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 000000000..88f7b1798 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/baker_recipes.py b/users/baker_recipes.py new file mode 100644 index 000000000..78c96985c --- /dev/null +++ b/users/baker_recipes.py @@ -0,0 +1,8 @@ +from model_bakery.recipe import Recipe +from users.models import User + +new_user = Recipe( + User, + email= "kenzinho@gmail.com", + username= "kenzinho" +) \ No newline at end of file diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 000000000..583211bca --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,128 @@ +# Generated by Django 4.1.3 on 2022-12-06 21:57 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("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" + ), + ), + ( + "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" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("email", models.CharField(max_length=127)), + ( + "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/users/migrations/0002_user_isactive.py b/users/migrations/0002_user_isactive.py new file mode 100644 index 000000000..a616a2b52 --- /dev/null +++ b/users/migrations/0002_user_isactive.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-12-07 11:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="isActive", + field=models.BooleanField(default=True), + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/users/models.py b/users/models.py new file mode 100644 index 000000000..eb07a63a0 --- /dev/null +++ b/users/models.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +import uuid + +class User(AbstractUser): + id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False) + email = models.CharField(max_length=127) + isActive = models.BooleanField(default=True) + diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 000000000..7df21a861 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers +import ipdb + +from investments.serializers import InvestmentSerializer + +from users.models import User +from rest_framework.exceptions import APIException +from rest_framework.views import status + +class CustomValidation(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = "A server error occured." + + def __init__(self, detail, status_code): + if status_code is not None:self.status_code=status_code + if detail is not None: + self.detail={"detail": detail} + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + + fields =[ + "id", + "email", + "username", + "isActive" + ] + + read_only_fields=["id", "isActive"] + + def create(self, validated_data): + if User.objects.filter(email=validated_data["email"]).exists(): + raise CustomValidation("Email aready in use!", 409) + return User.objects.create_user(**validated_data) + + +class UserDetailSerializer(serializers.ModelSerializer): + investments = InvestmentSerializer(many=True) + class Meta: + model = User + + fields = [ "username", "email", "investments", "isActive"] \ No newline at end of file diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/users/tests/test_models.py b/users/tests/test_models.py new file mode 100644 index 000000000..2f5f71ade --- /dev/null +++ b/users/tests/test_models.py @@ -0,0 +1,22 @@ +from django.test import TestCase + +from users.models import User + +class UserTestModel(TestCase): + @classmethod + def setUp(self): + self.user_data = { + "email":"teste@gmail.com", + "username":"teste" + } + self.user = User.objects.create(**self.user_data) + + def test_username_max_length(self): + max_length = self.user._meta.get_field('email').max_length + + self.assertEqual(max_length, 127) + + def test_email_can_be_null(self): + nullable = self.user._meta.get_field("email").null + + self.assertFalse(nullable) \ No newline at end of file diff --git a/users/tests/test_views.py b/users/tests/test_views.py new file mode 100644 index 000000000..97fa37d22 --- /dev/null +++ b/users/tests/test_views.py @@ -0,0 +1,52 @@ +from rest_framework.test import APITestCase + +import ipdb + +from users.serializers import UserDetailSerializer, UserSerializer +from users.models import User + + +class UserTestViews(APITestCase): + + @classmethod + def setUpTestData(cls): + + cls.create_user_uri = "/api/accounts/" + + cls.valid_user = { + "username": "kenzinho", + "email": "kenzinho@kenzinho.com" + } + + cls.valid_user_1 = { + "username": "kenzinho1", + "email": "kenzinho@kenzinho1.com" + } + + cls.invalid_user = { + "username": "", + "email": "" + } + cls.owner_1 = User.objects.create_user(**cls.valid_user_1) + + + + def test_can_register_user(self): + response_post = self.client.post(self.create_user_uri, self.valid_user, format="json") + self.assertEqual(UserSerializer(instance=response_post.data).data, response_post.data) + self.assertEqual(response_post.status_code, 201) + + def test_cannot_register_user(self): + response_post = self.client.post(self.create_user_uri, {}, format="json") + self.assertEqual(response_post.status_code, 400) + for key, value in response_post.data.items(): + self.assertEqual(value[0][:], "This field is required.") + + def test_can_edit_user(self): + response_patch = self.client.patch(f"/api/accounts/{self.owner_1.id}/", {"username": "KenzinhoUpdated"}, format="json") + self.assertEqual(UserDetailSerializer(instance=response_patch.data).data, response_patch.data) + self.assertEqual(response_patch.status_code, 200) + + def test_can_safe_delete_user(self): + response_delete = self.client.delete(f"/api/accounts/{self.owner_1.id}/") + self.assertEqual(response_delete.status_code, 204) diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 000000000..6b407ec51 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView + +from users.views import CreateUserView, ListUpdateDeleteDetailUserView + +urlpatterns=[ + path('accounts/', CreateUserView.as_view()), + path('accounts//', ListUpdateDeleteDetailUserView.as_view()), + path('schema/', SpectacularAPIView.as_view(), name='schema'), + path('docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('schema/', SpectacularAPIView.as_view(), name='schema'), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 000000000..46d1a0872 --- /dev/null +++ b/users/views.py @@ -0,0 +1,29 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import generics +from rest_framework.views import Response, status +import ipdb + +from users.models import User +from users.serializers import UserDetailSerializer, UserSerializer + +class CreateUserView(generics.ListCreateAPIView): + queryset= User.objects.all() + serializer_class= UserSerializer + +class ListUpdateDeleteDetailUserView(generics.RetrieveUpdateDestroyAPIView): + + queryset = User.objects.all() + serializer_class = UserDetailSerializer + + lookup_field = "id" + + def destroy(self, request, *args, **kwargs): + user_id = self.kwargs['id'] + + user = get_object_or_404(User, pk=user_id) + + user.isActive = False + + user.save() + + return Response({}, status.HTTP_204_NO_CONTENT) \ No newline at end of file