diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index d2d2d5d..bd17bac 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -49,4 +49,5 @@ urlpatterns = [ path("", include("apps.dca.urls")), path("", include("apps.mini_tools.urls")), path("", include("apps.import_app.urls")), + path("", include("apps.insights.urls")), ] diff --git a/app/apps/insights/urls.py b/app/apps/insights/urls.py new file mode 100644 index 0000000..b8f5c97 --- /dev/null +++ b/app/apps/insights/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("insights/sankey/", views.sankey, name="sankey"), +] diff --git a/app/apps/insights/utils/__init__.py b/app/apps/insights/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/insights/utils/sankey.py b/app/apps/insights/utils/sankey.py new file mode 100644 index 0000000..1d56310 --- /dev/null +++ b/app/apps/insights/utils/sankey.py @@ -0,0 +1,102 @@ +from django.db.models import Sum +from django.utils.translation import gettext_lazy as _ +from decimal import Decimal +from typing import Dict, List, TypedDict + + +class SankeyNode(TypedDict): + name: str + + +class SankeyFlow(TypedDict): + from_node: str + to_node: str + flow: float + currency: Dict + original_amount: float + percentage: float + + +def generate_sankey_data(transactions_queryset): + """ + Generates Sankey diagram data from transaction queryset. + Uses a 1-5 scale for flows based on percentages. + """ + nodes: Dict[str, SankeyNode] = {} + flows: List[SankeyFlow] = [] + + # Aggregate transactions + income_data = {} # {(category, currency, account) -> amount} + expense_data = {} # {(category, currency, account) -> amount} + total_amount = Decimal("0") + + for transaction in transactions_queryset: + currency = transaction.account.currency + account = transaction.account + category = transaction.category or _("Uncategorized") + + key = (category, currency, account) + + if transaction.type == "IN": + income_data[key] = income_data.get(key, Decimal("0")) + transaction.amount + else: + expense_data[key] = expense_data.get(key, Decimal("0")) + transaction.amount + + total_amount += transaction.amount + + # Function to add flow + def add_flow(from_node, to_node, amount, currency): + percentage = (amount / total_amount) * 100 if total_amount else 0 + scaled_flow = 1 + min(percentage / 5, 2) # Scale 1-5, capping at 100% + flows.append( + { + "from_node": from_node, + "to_node": to_node, + "flow": float(scaled_flow), + "currency": { + "code": currency.code, + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimal_places": currency.decimal_places, + }, + "original_amount": float(amount), + "percentage": float(percentage), + } + ) + nodes[from_node] = {"name": from_node} + nodes[to_node] = {"name": to_node} + + # Process income + for (category, currency, account), amount in income_data.items(): + category_name = f"{category} ({currency.code})" + account_name = f"{account.name} ({currency.code})" + add_flow(category_name, account_name, amount, currency) + + # Process expenses + for (category, currency, account), amount in expense_data.items(): + category_name = f"{category} ({currency.code})" + account_name = f"{account.name} ({currency.code})" + add_flow(account_name, category_name, amount, currency) + + # Calculate and add savings flows + savings_data = {} # {(account, currency) -> amount} + + for (category, currency, account), amount in income_data.items(): + key = (account, currency) + savings_data[key] = savings_data.get(key, Decimal("0")) + amount + + for (category, currency, account), amount in expense_data.items(): + key = (account, currency) + savings_data[key] = savings_data.get(key, Decimal("0")) - amount + + for (account, currency), amount in savings_data.items(): + if amount > 0: + account_name = f"{account.name} ({currency.code})" + savings_name = f"{_('Savings')} ({currency.code})" + add_flow(account_name, savings_name, amount, currency) + + return { + "nodes": list(nodes.values()), + "flows": flows, + "total_amount": float(total_amount), + } diff --git a/app/apps/insights/views.py b/app/apps/insights/views.py index 91ea44a..2bb4cfa 100644 --- a/app/apps/insights/views.py +++ b/app/apps/insights/views.py @@ -1,3 +1,17 @@ from django.shortcuts import render -# Create your views here. +from apps.transactions.models import Transaction +from apps.insights.utils.sankey import generate_sankey_data + + +def sankey(request): + # Get filtered transactions + transactions = Transaction.objects.filter(date__year=2025) + + # Generate Sankey data + sankey_data = generate_sankey_data(transactions) + print(sankey_data) + + return render( + request, "insights/fragments/sankey.html", {"sankey_data": sankey_data} + ) diff --git a/app/templates/insights/fragments/sankey.html b/app/templates/insights/fragments/sankey.html new file mode 100644 index 0000000..d71b8a3 --- /dev/null +++ b/app/templates/insights/fragments/sankey.html @@ -0,0 +1,149 @@ +{% extends "layouts/base.html" %} + +{% block content %} + + + +{% endblock %}