mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-09-13 21:32:08 +02:00
feat(insights): add tags and entities overview
This commit is contained in:
@@ -34,6 +34,16 @@ urlpatterns = [
|
||||
views.category_overview,
|
||||
name="category_overview",
|
||||
),
|
||||
path(
|
||||
"insights/tag-overview/",
|
||||
views.tag_overview,
|
||||
name="tag_overview",
|
||||
),
|
||||
path(
|
||||
"insights/entity-overview/",
|
||||
views.entity_overview,
|
||||
name="entity_overview",
|
||||
),
|
||||
path(
|
||||
"insights/late-transactions/",
|
||||
views.late_transactions,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+94
-44
@@ -4,7 +4,9 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -20,7 +22,7 @@ from apps.insights.utils.category_explorer import (
|
||||
get_category_sums_by_account,
|
||||
get_category_sums_by_currency,
|
||||
)
|
||||
from apps.insights.utils.category_overview import get_categories_totals
|
||||
from apps.insights.utils.overview import get_grouped_totals
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
@@ -31,6 +33,81 @@ from apps.insights.utils.month_by_month import get_month_by_month_data
|
||||
from apps.transactions.models import TransactionCategory, Transaction
|
||||
from apps.transactions.utils.calculations import calculate_currency_totals
|
||||
|
||||
# Labels for the grouping levels an overview can be built from. The overviews
|
||||
# only differ by which level sits at the top, so everything user facing lives
|
||||
# here instead of in three near identical views and templates.
|
||||
OVERVIEW_LEVELS = {
|
||||
"categories": {
|
||||
"label": _("Categories"),
|
||||
"singular": _("Category"),
|
||||
"empty": _("Uncategorized"),
|
||||
"empty_message": _("No categories"),
|
||||
"icon": "fa-solid fa-icons",
|
||||
},
|
||||
"tags": {
|
||||
"label": _("Tags"),
|
||||
"singular": _("Tag"),
|
||||
"empty": _("Untagged"),
|
||||
"empty_message": _("No tags"),
|
||||
"icon": "fa-solid fa-hashtag",
|
||||
},
|
||||
"entities": {
|
||||
"label": _("Entities"),
|
||||
"singular": _("Entity"),
|
||||
"empty": _("No entity"),
|
||||
"empty_message": _("No entities"),
|
||||
"icon": "fa-solid fa-user-group",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _render_overview(request, levels, url_name):
|
||||
"""Render the overview table/chart for ``levels``, top level first."""
|
||||
session_prefix = f"insights_{levels[0]}_overview"
|
||||
|
||||
def setting(name, default, cast=None):
|
||||
key = f"{session_prefix}_{name}"
|
||||
if name in request.GET:
|
||||
value = cast(request.GET[name]) if cast else request.GET[name]
|
||||
request.session[key] = value
|
||||
return value
|
||||
return request.session.get(key, default)
|
||||
|
||||
view_type = setting("view_type", "table")
|
||||
showing = setting("showing", "final")
|
||||
show_level_2 = setting("show_level_2", True, cast=lambda value: value == "on")
|
||||
show_level_3 = setting("show_level_3", False, cast=lambda value: value == "on")
|
||||
|
||||
if show_level_2:
|
||||
depth = 3 if show_level_3 else 2
|
||||
else:
|
||||
depth = 1
|
||||
|
||||
total_table = get_grouped_totals(
|
||||
transactions_queryset=get_transactions(request, include_silent=True),
|
||||
levels=levels,
|
||||
showing=showing,
|
||||
ignore_empty=False,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/overview/index.html",
|
||||
{
|
||||
"total_table": total_table,
|
||||
"refresh_url": reverse(url_name),
|
||||
"view_type": view_type,
|
||||
"showing": showing,
|
||||
"show_level_2": show_level_2,
|
||||
"show_level_3": show_level_3,
|
||||
"level_1": OVERVIEW_LEVELS[levels[0]],
|
||||
"level_2": OVERVIEW_LEVELS[levels[1]],
|
||||
"level_3": OVERVIEW_LEVELS[levels[2]],
|
||||
"empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
@@ -174,51 +251,24 @@ def category_sum_by_currency(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_overview(request):
|
||||
if "view_type" in request.GET:
|
||||
view_type = request.GET["view_type"]
|
||||
request.session["insights_category_explorer_view_type"] = view_type
|
||||
else:
|
||||
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
||||
|
||||
if "show_tags" in request.GET:
|
||||
show_tags = request.GET["show_tags"] == "on"
|
||||
request.session["insights_category_explorer_show_tags"] = show_tags
|
||||
else:
|
||||
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
||||
|
||||
if "show_entities" in request.GET:
|
||||
show_entities = request.GET["show_entities"] == "on"
|
||||
request.session["insights_category_explorer_show_entities"] = show_entities
|
||||
else:
|
||||
show_entities = request.session.get(
|
||||
"insights_category_explorer_show_entities", False
|
||||
)
|
||||
|
||||
if "showing" in request.GET:
|
||||
showing = request.GET["showing"]
|
||||
request.session["insights_category_explorer_showing"] = showing
|
||||
else:
|
||||
showing = request.session.get("insights_category_explorer_showing", "final")
|
||||
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
total_table = get_categories_totals(
|
||||
transactions_queryset=transactions,
|
||||
ignore_empty=False,
|
||||
show_entities=show_entities,
|
||||
return _render_overview(
|
||||
request, ("categories", "tags", "entities"), "category_overview"
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_overview/index.html",
|
||||
{
|
||||
"total_table": total_table,
|
||||
"view_type": view_type,
|
||||
"show_tags": show_tags,
|
||||
"show_entities": show_entities,
|
||||
"showing": showing,
|
||||
},
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tag_overview(request):
|
||||
return _render_overview(request, ("tags", "categories", "entities"), "tag_overview")
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def entity_overview(request):
|
||||
return _render_overview(
|
||||
request, ("entities", "categories", "tags"), "entity_overview"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user