From 55ad2be08b199a502ffe16c78de236fbcaa3ccbf Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 19 Apr 2025 02:36:38 -0300 Subject: [PATCH] feat(insights:category-overview): display tags breakdown alongside categories --- app/apps/insights/utils/category_overview.py | 161 +++++++++++++++++- app/apps/insights/views.py | 15 +- .../fragments/category_overview/index.html | 82 ++++++++- 3 files changed, 248 insertions(+), 10 deletions(-) diff --git a/app/apps/insights/utils/category_overview.py b/app/apps/insights/utils/category_overview.py index c951d1d..2127bb0 100644 --- a/app/apps/insights/utils/category_overview.py +++ b/app/apps/insights/utils/category_overview.py @@ -10,7 +10,7 @@ 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 + # First get the category totals as before category_currency_metrics = ( transactions_queryset.values( "category", @@ -74,9 +74,65 @@ def get_categories_totals(transactions_queryset, ignore_empty=False): .order_by("category__name") ) + # Get tag totals within each category with currency details + 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"), + ), + ) + # Process the results to structure by category result = {} + # Process category totals first for metric in category_currency_metrics: # Skip empty categories if ignore_empty is True if ignore_empty and all( @@ -101,7 +157,11 @@ def get_categories_totals(transactions_queryset, ignore_empty=False): currency_id = metric["account__currency"] if category_id not in result: - result[category_id] = {"name": metric["category__name"], "currencies": {}} + result[category_id] = { + "name": metric["category__name"], + "currencies": {}, + "tags": {}, # Add tags container + } # Add currency data currency_data = { @@ -162,4 +222,101 @@ def get_categories_totals(transactions_queryset, ignore_empty=False): result[category_id]["currencies"][currency_id] = currency_data + # Process tag totals and add them to the result, including untagged + 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": {}, + } + + 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, + } + + # Add exchange currency support for tags + 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 + return result diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index b6ee79b..bd65640 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -168,11 +168,20 @@ def category_sum_by_currency(request): @login_required @require_http_methods(["GET"]) def category_overview(request): - view_type = request.session.get("insights_category_explorer_view_type", "table") - 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 + print(request.GET["show_tags"], show_tags) + else: + show_tags = request.session.get("insights_category_explorer_show_tags", True) + + print(show_tags) # Get filtered transactions transactions = get_transactions(request, include_silent=True) @@ -184,7 +193,7 @@ def category_overview(request): return render( request, "insights/fragments/category_overview/index.html", - {"total_table": total_table, "view_type": view_type}, + {"total_table": total_table, "view_type": view_type, "show_tags": show_tags}, ) diff --git a/app/templates/insights/fragments/category_overview/index.html b/app/templates/insights/fragments/category_overview/index.html index 6fbff4b..6825a34 100644 --- a/app/templates/insights/fragments/category_overview/index.html +++ b/app/templates/insights/fragments/category_overview/index.html @@ -1,9 +1,9 @@ {% load i18n %}
+ hx-include="#picker-form, #picker-type, #view-type, #show-tags">
-
+
{% if total_table %} {% if view_type == "table" %} +
+
+ + + {% spaceless %} + + + {% endspaceless %} +
+
- +
@@ -38,9 +53,10 @@ - + {% for category in total_table.values %} - + + + + + {% if show_tags %} + {% for tag_id, tag in category.tags.items %} + {% if tag.name or not tag.name and category.tags.values|length > 1 %} + + + + + + + {% endif %} + {% endfor %} + {% endif %} {% endfor %}
{% trans 'Category' %}{% trans 'Total' %}
{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %} {% for currency in category.currencies.values %} @@ -85,11 +101,67 @@ {% endfor %}
+ {% if tag.name %}{{ tag.name }}{% else %}{% trans 'Untagged' %}{% endif %} + + {% for currency in tag.currencies.values %} + {% if currency.total_income != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} +
+ {% for currency in tag.currencies.values %} + {% if currency.total_expense != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} +
+ {% for currency in tag.currencies.values %} + {% if currency.total_final != 0 %} + + {% else %} +
-
+ {% endif %} + {% endfor %} +
+ {% elif view_type == "bars" %}