feat(insights): add tags and entities overview

This commit is contained in:
Herculino Trotta
2026-09-12 14:31:12 -03:00
parent 5404716a81
commit 3a5e688a90
8 changed files with 689 additions and 1104 deletions
@@ -1,504 +0,0 @@
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, show_entities=False
):
# Step 1: Aggregate transaction data by category and currency.
# This query calculates the total current and projected income/expense for each
# category by grouping transactions and summing up their amounts based on their
# type (income/expense) and payment status (paid/unpaid).
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")
)
# Step 2: Aggregate transaction data by tag, category, and currency.
# This is similar to the category metrics but adds tags to the grouping,
# allowing for a breakdown of totals by tag within each category. It also
# handles untagged transactions, where the 'tags' field is None.
tag_metrics = transactions_queryset.values(
"category",
"tags",
"tags__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"),
),
)
# Step 3: Initialize the main dictionary to structure the final results.
# The data will be organized hierarchically: category -> currency -> tags -> entities.
result = {}
# Step 4: Process the aggregated category metrics to build the initial result structure.
# This loop iterates through each category's metrics and populates the `result` dict.
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": {},
"tags": {}, # Add tags container
}
# 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,
}
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
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
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
for tag_metric in tag_metrics:
category_id = tag_metric["category"]
tag_id = tag_metric["tags"] # Will be None for untagged transactions
if category_id in result:
# Initialize the tag container if not exists
if "tags" not in result[category_id]:
result[category_id]["tags"] = {}
# Determine if this is a tagged or untagged transaction
tag_key = tag_id if tag_id is not None else "untagged"
tag_name = tag_metric["tags__name"] if tag_id is not None else None
if tag_key not in result[category_id]["tags"]:
result[category_id]["tags"][tag_key] = {
"name": tag_name,
"currencies": {},
"entities": {},
}
currency_id = tag_metric["account__currency"]
# Calculate tag totals
tag_total_current = (
tag_metric["income_current"] - tag_metric["expense_current"]
)
tag_total_projected = (
tag_metric["income_projected"] - tag_metric["expense_projected"]
)
tag_total_income = (
tag_metric["income_current"] + tag_metric["income_projected"]
)
tag_total_expense = (
tag_metric["expense_current"] + tag_metric["expense_projected"]
)
tag_total_final = tag_total_current + tag_total_projected
tag_currency_data = {
"currency": {
"code": tag_metric["account__currency__code"],
"name": tag_metric["account__currency__name"],
"decimal_places": tag_metric["account__currency__decimal_places"],
"prefix": tag_metric["account__currency__prefix"],
"suffix": tag_metric["account__currency__suffix"],
},
"expense_current": tag_metric["expense_current"],
"expense_projected": tag_metric["expense_projected"],
"total_expense": tag_total_expense,
"income_current": tag_metric["income_current"],
"income_projected": tag_metric["income_projected"],
"total_income": tag_total_income,
"total_current": tag_total_current,
"total_projected": tag_total_projected,
"total_final": tag_total_final,
}
# Step 5a: Handle currency conversion for tag totals.
if tag_metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=tag_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=tag_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:
tag_currency_data["exchanged"] = exchanged
result[category_id]["tags"][tag_key]["currencies"][
currency_id
] = tag_currency_data
# Step 6: If requested, aggregate and process entity-level data.
if show_entities:
entity_metrics = transactions_queryset.values(
"category",
"tags",
"entities",
"entities__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"),
),
)
for entity_metric in entity_metrics:
category_id = entity_metric["category"]
tag_id = entity_metric["tags"]
entity_id = entity_metric["entities"]
if category_id in result:
tag_key = tag_id if tag_id is not None else "untagged"
if tag_key in result[category_id]["tags"]:
entity_key = entity_id if entity_id is not None else "no_entity"
entity_name = (
entity_metric["entities__name"]
if entity_id is not None
else None
)
if "entities" not in result[category_id]["tags"][tag_key]:
result[category_id]["tags"][tag_key]["entities"] = {}
if (
entity_key
not in result[category_id]["tags"][tag_key]["entities"]
):
result[category_id]["tags"][tag_key]["entities"][entity_key] = {
"name": entity_name,
"currencies": {},
}
currency_id = entity_metric["account__currency"]
entity_total_current = (
entity_metric["income_current"]
- entity_metric["expense_current"]
)
entity_total_projected = (
entity_metric["income_projected"]
- entity_metric["expense_projected"]
)
entity_total_income = (
entity_metric["income_current"]
+ entity_metric["income_projected"]
)
entity_total_expense = (
entity_metric["expense_current"]
+ entity_metric["expense_projected"]
)
entity_total_final = entity_total_current + entity_total_projected
entity_currency_data = {
"currency": {
"code": entity_metric["account__currency__code"],
"name": entity_metric["account__currency__name"],
"decimal_places": entity_metric[
"account__currency__decimal_places"
],
"prefix": entity_metric["account__currency__prefix"],
"suffix": entity_metric["account__currency__suffix"],
},
"expense_current": entity_metric["expense_current"],
"expense_projected": entity_metric["expense_projected"],
"total_expense": entity_total_expense,
"income_current": entity_metric["income_current"],
"income_projected": entity_metric["income_projected"],
"total_income": entity_total_income,
"total_current": entity_total_current,
"total_projected": entity_total_projected,
"total_final": entity_total_final,
}
if entity_metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=entity_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=entity_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:
entity_currency_data["exchanged"] = exchanged
result[category_id]["tags"][tag_key]["entities"][entity_key][
"currencies"
][currency_id] = entity_currency_data
return result
+236
View File
@@ -0,0 +1,236 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
# Grouping levels an overview can be built from. Any ordering of these keys is a
# valid hierarchy, which is what makes the categories, tags and entities
# overviews the same view with a different level order.
LEVELS = {
"categories": {"field": "category", "name": "category__name"},
"tags": {"field": "tags", "name": "tags__name"},
"entities": {"field": "entities", "name": "entities__name"},
}
CURRENCY_FIELDS = (
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
# Aggregated amounts, plus the totals derived from them. Every one of these is
# converted when the account's currency has an exchange currency.
AGGREGATED_FIELDS = (
"expense_current",
"expense_projected",
"income_current",
"income_projected",
)
TOTAL_FIELDS = (
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
)
# Fields picked for display, per "showing" mode. Doing it here keeps the
# templates down to one branch per cell instead of one per mode.
SHOWN_FIELDS = {
"current": ("income_current", "expense_current", "total_current"),
"projected": ("income_projected", "expense_projected", "total_projected"),
"final": ("total_income", "total_expense", "total_final"),
}
# Key used for rows where the grouping field is null (untagged transactions, for
# instance). None is already a meaningful key for the top level, so children use
# a sentinel string to keep the dictionaries JSON serializable.
NO_VALUE = "none"
def _child_key(value):
return NO_VALUE if value is None else value
def _sum_of(transaction_type, is_paid):
return Coalesce(
Sum(
Case(
When(type=transaction_type, is_paid=is_paid, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
)
def _aggregate(transactions_queryset, group_fields, name_field):
"""Sum income/expense per currency, grouped by the given fields."""
return (
transactions_queryset.values(*group_fields, name_field, *CURRENCY_FIELDS)
.annotate(
expense_current=_sum_of(Transaction.Type.EXPENSE, is_paid=True),
expense_projected=_sum_of(Transaction.Type.EXPENSE, is_paid=False),
income_current=_sum_of(Transaction.Type.INCOME, is_paid=True),
income_projected=_sum_of(Transaction.Type.INCOME, is_paid=False),
)
.order_by(name_field)
)
def _build_currency_data(metric, showing):
"""Turn one aggregated row into the currency payload the templates read."""
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
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": metric["expense_current"] + metric["expense_projected"],
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": metric["income_current"] + metric["income_projected"],
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_current + total_projected,
}
income, expense, total = SHOWN_FIELDS.get(showing, SHOWN_FIELDS["final"])
currency_data["shown"] = {
"income": currency_data[income],
"expense": currency_data[expense],
"total": currency_data[total],
}
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=metric["account__currency"])
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in AGGREGATED_FIELDS + TOTAL_FIELDS:
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
return currency_data
def get_grouped_totals(
transactions_queryset, levels, showing="final", ignore_empty=False, depth=1
):
"""
Build a nested income/expense breakdown of ``transactions_queryset``.
``showing`` picks which set of amounts lands in each row's ``shown`` key:
``current`` (paid only), ``projected`` (unpaid only) or ``final`` (both).
``levels`` is an ordered sequence of keys from ``LEVELS`` describing the
hierarchy (e.g. ``("categories", "tags", "entities")``); ``depth`` says how
many of those levels to actually aggregate, so the caller only pays for the
rows it is going to show.
Returns a dict keyed by the first level's id::
{
id: {
"name": str | None,
"search_path": str,
"currencies": {currency_id: {...}},
"children": {id: {"name": ..., "currencies": ..., "children": ...}},
}
}
A null grouping value keeps ``None`` as the key at the top level and uses
``NO_VALUE`` further down; ``name`` is ``None`` in both cases so templates
can render the label that fits the level.
"""
levels = [level for level in levels if level in LEVELS][: max(depth, 1)]
if not levels:
return {}
fields = [LEVELS[level]["field"] for level in levels]
names = [LEVELS[level]["name"] for level in levels]
result = {}
for metric in _aggregate(transactions_queryset, fields[:1], names[0]):
if ignore_empty and all(
metric[field] == Decimal("0") for field in AGGREGATED_FIELDS
):
continue
node = result.setdefault(
metric[fields[0]],
{
"name": metric[names[0]],
"search_path": f"{len(result)}/",
"currencies": {},
"children": {},
},
)
node["currencies"][metric["account__currency"]] = _build_currency_data(
metric, showing
)
# Each extra level is aggregated on its own and grafted onto the node its
# parent ids point at, so a child of a skipped (empty) parent is dropped.
for index in range(1, len(levels)):
for metric in _aggregate(
transactions_queryset, fields[: index + 1], names[index]
):
node = result.get(metric[fields[0]])
for parent_field in fields[1:index]:
if node is None:
break
node = node["children"].get(_child_key(metric[parent_field]))
if node is None:
continue
child = node["children"].setdefault(
_child_key(metric[fields[index]]),
{
"name": metric[names[index]],
"search_path": f"{node['search_path']}{len(node['children'])}/",
"currencies": {},
"children": {},
},
)
child["currencies"][metric["account__currency"]] = _build_currency_data(
metric, showing
)
return result