mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
1 parent
c17f03775c
commit
6ed0ba4efa
6 files changed
+153
-27
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user