feat(insights): add Categories Overview

Closes #94
This commit is contained in:
Herculino Trotta
2025-02-27 23:33:05 -03:00
parent a6a85ae3a2
commit 35027ee0ae
9 changed files with 338 additions and 24 deletions
+5
View File
@@ -29,6 +29,11 @@ urlpatterns = [
views.category_sum_by_currency,
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
),
path(
"insights/late-transactions/",
views.late_transactions,
@@ -0,0 +1,165 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value, DecimalField
from django.db.models.functions import Coalesce
from apps.transactions.models import Transaction
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
def get_categories_totals(transactions_queryset, ignore_empty=False):
# Get metrics for each category and currency in a single query
category_currency_metrics = (
transactions_queryset.values(
"category",
"category__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_current=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.INCOME, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by("category__name")
)
# Process the results to structure by category
result = {}
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
metric[field] == Decimal("0")
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
]
):
continue
# Calculate derived totals
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
total_income = metric["income_current"] + metric["income_projected"]
total_expense = metric["expense_current"] + metric["expense_projected"]
total_final = total_current + total_projected
category_id = metric["category"]
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {"name": metric["category__name"], "currencies": {}}
# Add currency data
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"expense_current": metric["expense_current"],
"expense_projected": metric["expense_projected"],
"total_expense": total_expense,
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": total_income,
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
currency_data["exchanged"] = exchanged
result[category_id]["currencies"][currency_id] = currency_data
return result
+22
View File
@@ -1,3 +1,6 @@
import decimal
import json
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@@ -23,6 +26,7 @@ from apps.insights.utils.sankey import (
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory, Transaction
from apps.insights.utils.category_overview import get_categories_totals
@login_required
@@ -159,6 +163,24 @@ def category_sum_by_currency(request):
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
total_table = get_categories_totals(
transactions_queryset=transactions, ignore_empty=False
)
return render(
request,
"insights/fragments/category_overview/index.html",
{"total_table": total_table},
)
@only_htmx
@login_required
@require_http_methods(["GET"])