diff --git a/app/apps/insights/forms.py b/app/apps/insights/forms.py index 12ce0d2..8724c2d 100644 --- a/app/apps/insights/forms.py +++ b/app/apps/insights/forms.py @@ -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") diff --git a/app/apps/insights/urls.py b/app/apps/insights/urls.py index 66ed5f6..bc0806b 100644 --- a/app/apps/insights/urls.py +++ b/app/apps/insights/urls.py @@ -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", + ), ] diff --git a/app/apps/insights/utils/category_explorer.py b/app/apps/insights/utils/category_explorer.py new file mode 100644 index 0000000..1f74070 --- /dev/null +++ b/app/apps/insights/utils/category_explorer.py @@ -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], + }, + ], + } diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index 40fc42a..3aeec47 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -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}, + ) diff --git a/app/templates/insights/fragments/category_explorer/charts/account.html b/app/templates/insights/fragments/category_explorer/charts/account.html new file mode 100644 index 0000000..4f9c297 --- /dev/null +++ b/app/templates/insights/fragments/category_explorer/charts/account.html @@ -0,0 +1,79 @@ +{% load i18n %} +