feat(insights): category explorer

This commit is contained in:
Herculino Trotta
2025-02-16 13:03:02 -03:00
parent 5521eb20bf
commit 7f231175b2
9 changed files with 415 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ from apps.common.widgets.datepicker import (
AirYearPickerInput,
AirDatePickerInput,
)
from apps.transactions.models import TransactionCategory
class SingleMonthForm(forms.Form):
@@ -108,3 +109,20 @@ class DateRangeForm(forms.Form):
css_class="mb-0",
),
)
class CategoryForm(forms.Form):
category = forms.ModelChoiceField(
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout("category")

View File

@@ -14,4 +14,19 @@ urlpatterns = [
views.sankey_by_currency,
name="insights_sankey_by_currency",
),
path(
"insights/category-explorer/",
views.category_explorer_index,
name="category_explorer_index",
),
path(
"insights/category-explorer/account/",
views.category_sum_by_account,
name="category_sum_by_account",
),
path(
"insights/category-explorer/currency/",
views.category_sum_by_currency,
name="category_sum_by_currency",
),
]

View File

@@ -0,0 +1,101 @@
from django.db.models import Sum, Case, When, F, DecimalField, Value
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
def get_category_sums_by_account(queryset, category):
"""
Returns income/expense sums per account for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__name")
.annotate(
income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__name")
)
return {
"labels": [item["account__name"] for item in sums],
"datasets": [
{
"label": _("Income"),
"data": [float(item["income"]) for item in sums],
},
{
"label": _("Expenses"),
"data": [float(item["expense"]) for item in sums],
},
],
}
def get_category_sums_by_currency(queryset, category):
"""
Returns income/expense sums per currency for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__currency__code")
.annotate(
income=Coalesce(
Sum(
Case(
When(type="IN", then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
expense=Coalesce(
Sum(
Case(
When(type="EX", then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__currency__code")
)
return {
"labels": [item["account__currency__code"] for item in sums],
"datasets": [
{
"label": _("Income"),
"data": [float(item["income"]) for item in sums],
},
{
"label": _("Expenses"),
"data": [float(item["expense"]) for item in sums],
},
],
}

View File

@@ -1,24 +1,28 @@
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from dateutil.relativedelta import relativedelta
from apps.transactions.models import Transaction
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
)
from apps.common.decorators.htmx import only_htmx
from apps.insights.forms import (
SingleMonthForm,
SingleYearForm,
MonthRangeForm,
YearRangeForm,
DateRangeForm,
CategoryForm,
)
from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
)
from apps.common.decorators.htmx import only_htmx
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory
@login_required
@@ -61,7 +65,7 @@ def index(request):
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@require_http_methods(["GET"])
def sankey_by_account(request):
# Get filtered transactions
@@ -79,7 +83,7 @@ def sankey_by_account(request):
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@require_http_methods(["GET"])
def sankey_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
@@ -92,3 +96,66 @@ def sankey_by_currency(request):
"insights/fragments/sankey.html",
{"sankey_data": sankey_data, "type": "currency"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_explorer_index(request):
category_form = CategoryForm()
return render(
request,
"insights/fragments/category_explorer/index.html",
{"category_form": category_form},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_account(request):
# Get filtered transactions
transactions = get_transactions(request)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
account_data = get_category_sums_by_account(transactions, category)
else:
account_data = None
return render(
request,
"insights/fragments/category_explorer/charts/account.html",
{"account_data": account_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
currency_data = get_category_sums_by_currency(transactions, category)
else:
currency_data = None
print(currency_data)
return render(
request,
"insights/fragments/category_explorer/charts/currency.html",
{"currency_data": currency_data},
)