From 6ed0ba4efaa3241c321c7da263802fd446d5ec15 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 14:55:42 -0300 Subject: [PATCH] feat(api): asset class endpoints - /api/investment-classes/ to list, create, edit and delete asset classes - Accounts return their asset class and take investment_class_id, filterable by investment_class - Unique names and targets up to 100% are checked in the model, shared by the form and the API --- app/apps/accounts/forms.py | 25 +----- app/apps/accounts/models.py | 20 +++++ app/apps/api/serializers/accounts.py | 33 +++++++- app/apps/api/tests/test_investment_classes.py | 81 +++++++++++++++++++ app/apps/api/urls.py | 1 + app/apps/api/views/accounts.py | 20 ++++- 6 files changed, 153 insertions(+), 27 deletions(-) create mode 100644 app/apps/api/tests/test_investment_classes.py diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index fbb3495a..abb3cf8d 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -21,8 +21,7 @@ from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row from django import forms -from django.db.models import Q, Sum -from django.template.defaultfilters import floatformat +from django.db.models import Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -80,28 +79,6 @@ class InvestmentClassForm(forms.ModelForm): ), ) - def clean_name(self): - name = self.cleaned_data["name"] - # owner isn't a form field, so Django skips the (owner, name) check - if InvestmentClass.objects.filter(name=name).exclude(pk=self.instance.pk).exists(): - raise forms.ValidationError(_("You already have an asset class with this name.")) - return name - - def clean_target(self): - target = self.cleaned_data["target"] - if target: - others = ( - InvestmentClass.objects.exclude(pk=self.instance.pk) - .aggregate(total=Sum("target"))["total"] - or 0 - ) - if others + target > 100: - raise forms.ValidationError( - _("Targets can't add up to more than 100%%. Other classes already " - "take %(taken)s%%.") % {"taken": floatformat(others, -2)} - ) - return target - class AccountForm(forms.ModelForm): group = DynamicModelChoiceField( diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index e30d9afd..79d5e383 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.template.defaultfilters import floatformat from django.utils.translation import gettext_lazy as _ from apps.common.middleware.thread_local import get_current_user @@ -49,6 +50,25 @@ class InvestmentClass(OwnedObject): objects = OwnedObjectManager() all_objects = models.Manager() # Unfiltered manager + def clean(self): + # Shared by the form and the API; owner is set on save, so neither can + # rely on Django's (owner, name) unique check. + others = InvestmentClass.all_objects.filter( + owner=self.owner or get_current_user() + ).exclude(pk=self.pk) + errors = {} + if self.name and others.filter(name=self.name).exists(): + errors["name"] = _("You already have an asset class with this name.") + if self.target: + taken = others.aggregate(total=models.Sum("target"))["total"] or 0 + if taken + self.target > 100: + errors["target"] = _( + "Targets can't add up to more than 100%%. Other classes already " + "take %(taken)s%%." + ) % {"taken": floatformat(taken, -2)} + if errors: + raise ValidationError(errors) + class Meta: verbose_name = _("Asset class") verbose_name_plural = _("Asset classes") diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index 51df267d..02082ba1 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -1,11 +1,13 @@ +from copy import copy from decimal import Decimal +from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Q from rest_framework import serializers from rest_framework.permissions import IsAuthenticated +from apps.accounts.models import AccountGroup, Account, InvestmentClass from apps.api.serializers.currencies import CurrencySerializer -from apps.accounts.models import AccountGroup, Account from apps.currencies.models import Currency @@ -17,6 +19,24 @@ class AccountGroupSerializer(serializers.ModelSerializer): fields = "__all__" +class InvestmentClassSerializer(serializers.ModelSerializer): + permission_classes = [IsAuthenticated] + + class Meta: + model = InvestmentClass + fields = ["id", "name", "target"] + + def validate(self, attrs): + instance = copy(self.instance) if self.instance else InvestmentClass() + for name, value in attrs.items(): + setattr(instance, name, value) + try: + instance.clean() # unique name and targets up to 100% + except DjangoValidationError as e: + raise serializers.ValidationError(e.message_dict) + return attrs + + class AccountSerializer(serializers.ModelSerializer): group = AccountGroupSerializer(read_only=True) group_id = serializers.PrimaryKeyRelatedField( @@ -38,6 +58,15 @@ class AccountSerializer(serializers.ModelSerializer): allow_null=True, ) + investment_class = InvestmentClassSerializer(read_only=True) + investment_class_id = serializers.PrimaryKeyRelatedField( + queryset=InvestmentClass.objects.all(), + source="investment_class", + write_only=True, + allow_null=True, + required=False, + ) + permission_classes = [IsAuthenticated] class Meta: @@ -54,6 +83,7 @@ class AccountSerializer(serializers.ModelSerializer): "is_asset", "is_investment", "investment_class", + "investment_class_id", "income_entities", "income_categories", "income_tags", @@ -65,6 +95,7 @@ class AccountSerializer(serializers.ModelSerializer): if request and request.user.is_authenticated: # Reload the queryset to get an updated version with the requesting user self.fields["group_id"].queryset = AccountGroup.objects.all() + self.fields["investment_class_id"].queryset = InvestmentClass.objects.all() def create(self, validated_data): return Account.objects.create(**validated_data) diff --git a/app/apps/api/tests/test_investment_classes.py b/app/apps/api/tests/test_investment_classes.py new file mode 100644 index 00000000..00409afe --- /dev/null +++ b/app/apps/api/tests/test_investment_classes.py @@ -0,0 +1,81 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from apps.accounts.models import Account, InvestmentClass +from apps.currencies.models import Currency + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, + DEMO=False, +) +class InvestmentClassAPITests(TestCase): + def setUp(self): + User = get_user_model() + perms = Permission.objects.filter( + codename__in=[ + "add_investmentclass", + "change_investmentclass", + "delete_investmentclass", + "change_account", + ] + ) + self.user = User.objects.create_user(email="u@test.com", password="pw") + self.other = User.objects.create_user(email="o@test.com", password="pw") + for user in (self.user, self.other): + user.user_permissions.set(perms) + self.api = APIClient() + self.api.force_authenticate(User.objects.get(pk=self.user.pk)) + + def test_crud_and_validation(self): + response = self.api.post( + "/api/investment-classes/", {"name": "Stocks", "target": "60"}, format="json" + ) + self.assertEqual(response.status_code, 201, response.data) + stocks_id = response.data["id"] + + # Same name again, and targets past 100%: rejected, not a server error + response = self.api.post("/api/investment-classes/", {"name": "Stocks"}, format="json") + self.assertEqual(response.status_code, 400) + self.assertIn("name", response.data) + response = self.api.post( + "/api/investment-classes/", {"name": "Bonds", "target": "50"}, format="json" + ) + self.assertEqual(response.status_code, 400) + self.assertIn("target", response.data) + + # Editing keeps its own target out of the sum + response = self.api.patch( + f"/api/investment-classes/{stocks_id}/", {"target": "100"}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + + # Another user's names and targets don't count, and they can't see ours + other = APIClient() + other.force_authenticate(get_user_model().objects.get(pk=self.other.pk)) + response = other.post( + "/api/investment-classes/", {"name": "Stocks", "target": "100"}, format="json" + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(other.get(f"/api/investment-classes/{stocks_id}/").status_code, 404) + + def test_account_takes_a_class(self): + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + stocks = InvestmentClass.all_objects.create(name="Stocks", owner=self.user) + account = Account.all_objects.create(name="PETR4", currency=brl, owner=self.user) + + response = self.api.patch( + f"/api/accounts/{account.id}/", {"investment_class_id": stocks.id}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["investment_class"]["name"], "Stocks") + response = self.api.get("/api/accounts/", {"investment_class": stocks.id}) + self.assertEqual(response.data["count"], 1) diff --git a/app/apps/api/urls.py b/app/apps/api/urls.py index ee2d1fee..40d2b95e 100644 --- a/app/apps/api/urls.py +++ b/app/apps/api/urls.py @@ -12,6 +12,7 @@ router.register(r"installment-plans", views.InstallmentPlanViewSet) router.register(r"recurring-transactions", views.RecurringTransactionViewSet) router.register(r"account-groups", views.AccountGroupViewSet) router.register(r"accounts", views.AccountViewSet) +router.register(r"investment-classes", views.InvestmentClassViewSet) router.register(r"currencies", views.CurrencyViewSet) router.register(r"exchange-rates", views.ExchangeRateViewSet) router.register(r"dca/strategies", views.DCAStrategyViewSet) diff --git a/app/apps/api/views/accounts.py b/app/apps/api/views/accounts.py index a62da741..c30157b9 100644 --- a/app/apps/api/views/accounts.py +++ b/app/apps/api/views/accounts.py @@ -4,11 +4,12 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.accounts.models import AccountGroup, Account +from apps.accounts.models import AccountGroup, Account, InvestmentClass from apps.accounts.services import get_account_balance from apps.api.permissions import SHARED_OBJECT_PERMISSIONS from apps.api.serializers import ( AccountGroupSerializer, + InvestmentClassSerializer, AccountSerializer, AccountBalanceSerializer, InvestmentPerformanceSerializer, @@ -34,6 +35,20 @@ class AccountGroupViewSet(viewsets.ModelViewSet): return AccountGroup.objects.all() +class InvestmentClassViewSet(viewsets.ModelViewSet): + """ViewSet for managing asset classes and their target allocation.""" + + queryset = InvestmentClass.objects.all() + serializer_class = InvestmentClassSerializer + filterset_fields = {"name": ["exact", "icontains"]} + search_fields = ["name"] + ordering_fields = "__all__" + ordering = ["id"] + + def get_queryset(self): + return InvestmentClass.objects.all() + + @extend_schema_view( balance=extend_schema( summary="Get account balance", @@ -55,6 +70,7 @@ class AccountViewSet(viewsets.ModelViewSet): filterset_fields = { "name": ["exact", "icontains"], "group": ["exact", "isnull"], + "investment_class": ["exact", "isnull"], "currency": ["exact"], "exchange_currency": ["exact", "isnull"], "is_asset": ["exact"], @@ -68,7 +84,7 @@ class AccountViewSet(viewsets.ModelViewSet): def get_queryset(self): return Account.objects.all().select_related( - "group", "currency", "exchange_currency" + "group", "currency", "exchange_currency", "investment_class" ) @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])