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
This commit is contained in:
Herculino Trotta committed 2026-11-08 14:55:42 -03:00
1 parent c17f03775c
commit 6ed0ba4efa
6 files changed
+153 -27

No files matched your search

+1 -24
View File
@@ -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(
+20
View File
@@ -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")
+32 -1
View File
@@ -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)
@@ -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)
+1
View File
@@ -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)
+18 -2
View File
@@ -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])