From 99e515dd1cba910b65659730864ea764a3a176ab Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Fri, 29 Dec 2023 16:17:06 +0100 Subject: [PATCH 01/12] Balance report --- assets/templates/reports/balance.html | 76 +++++++++++++ assets/templates/reports/base.html | 6 ++ assets/templates/reports/index.html | 14 +++ creditmanagement/models.py | 58 +++++++++- reports/__init__.py | 0 reports/apps.py | 6 ++ reports/urls.py | 10 ++ reports/views.py | 149 ++++++++++++++++++++++++++ scaladining/urls.py | 1 + 9 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 assets/templates/reports/balance.html create mode 100644 assets/templates/reports/base.html create mode 100644 assets/templates/reports/index.html create mode 100644 reports/__init__.py create mode 100644 reports/apps.py create mode 100644 reports/urls.py create mode 100644 reports/views.py diff --git a/assets/templates/reports/balance.html b/assets/templates/reports/balance.html new file mode 100644 index 00000000..fb78691b --- /dev/null +++ b/assets/templates/reports/balance.html @@ -0,0 +1,76 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

+ Balance report + Period: {{ year }} +

+ +
+ + + Previous year + + + + Next year + +
+ +

+ Bookkeeping accounts are shown in blue. + Association accounts are gray. + All individual user balances are summed together and shown as a single row at the + bottom. + Accounts for which no transactions took place are omitted. +

+ + + {% for period, bookkeeping, association, user_pile in report_display %} +

{{ period|date:"F" }}

+ + + + + + + + + + + + {% for account, statement in bookkeeping %} + + + + + + + + + {% endfor %} + {% for account, statement in association %} + + + + + + + + {% endfor %} + {% if user_pile %} + + + + + + + + {% endif %} + +
AccountStart balanceIncreaseReductionEnd balance
{{ account.get_special_display }}{{ statement.start_balance }}{{ statement.increase }}{{ statement.reduction }}{{ statement.end_balance }}
{{ account.association.name }}{{ statement.start_balance }}{{ statement.increase }}{{ statement.reduction }}{{ statement.end_balance }}
All user accounts combined{{ user_pile.start_balance }}{{ user_pile.increase }}{{ user_pile.reduction }}{{ user_pile.end_balance }}
+ {% endfor %} + +{% endblock %} diff --git a/assets/templates/reports/base.html b/assets/templates/reports/base.html new file mode 100644 index 00000000..f3992a21 --- /dev/null +++ b/assets/templates/reports/base.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} + +{% block content %} + {% block nav %}← View all reports{% endblock %} + {% block report %}{% endblock %} +{% endblock %} diff --git a/assets/templates/reports/index.html b/assets/templates/reports/index.html new file mode 100644 index 00000000..dc7c1f5b --- /dev/null +++ b/assets/templates/reports/index.html @@ -0,0 +1,14 @@ +{% extends 'reports/base.html' %} + +{% block content %} +

Reports

+
+ +
+{% endblock %} diff --git a/creditmanagement/models.py b/creditmanagement/models.py index bd187cba..8d63c872 100644 --- a/creditmanagement/models.py +++ b/creditmanagement/models.py @@ -4,7 +4,7 @@ from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Q, QuerySet, Sum +from django.db.models import Q, QuerySet, Sum, Case, When from django.utils import timezone from userdetails.models import Association, User @@ -122,6 +122,59 @@ def filter_account(self, account: Account): """Filters transactions that have the given account as source or target.""" return self.filter(Q(source=account) | Q(target=account)) + def sum_by_account(self, group_users=False): + """Sums the amounts in this QuerySet, grouped by account. + + Computes for each account that occurs in the QuerySet, the total + balance increase and decrease sum, over all transactions in this + QuerySet. + + Args: + group_users: If True, user accounts are grouped together and given + key 'None'. + + Returns: + A dictionary with as key the account id or None when the account is + for a user and group_users is True. The value is a tuple with the + increase and reduce sum (possibly 0). + """ + # Annotate the grouping key + if group_users: + source_qs = self.annotate( + account=Case( + # Set the group key to NULL for all user accounts + When(source__user__isnull=False, then=None), + default="source", + ) + ) + + target_qs = self.annotate( + account=Case( + When(target__user__isnull=False, then=None), default="target" + ) + ) + else: + source_qs = self.annotate(account="source") + target_qs = self.annotate(account="target") + + # Group by account and aggregate + reduction = source_qs.values("account").annotate(sum=Sum("amount")) + increase = target_qs.values("account").annotate(sum=Sum("amount")) + + # Combine on account key + combined = {e["account"]: {"increase_sum": e["sum"]} for e in increase} + for e in reduction: + combined.setdefault(e["account"], {})["reduction_sum"] = e["sum"] + + # Convert to tuple + return { + account: ( + val.get("increase_sum", Decimal("0.00")), + val.get("reduction_sum", Decimal("0.00")), + ) + for account, val in combined.items() + } + class Transaction(models.Model): # We do not enforce that source != target because those rows are not harmful. @@ -153,3 +206,6 @@ def reversal(self, reverted_by: User): description=f'Refund "{self.description}"', created_by=reverted_by, ) + + def __str__(self): + return f"{self.source} -> {self.target} - {self.amount}" diff --git a/reports/__init__.py b/reports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/reports/apps.py b/reports/apps.py new file mode 100644 index 00000000..072c6441 --- /dev/null +++ b/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "reports" diff --git a/reports/urls.py b/reports/urls.py new file mode 100644 index 00000000..38afa8f5 --- /dev/null +++ b/reports/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from reports import views + +app_name = "reports" + +urlpatterns = [ + path("", views.ReportsView.as_view(), name="index"), + path("balance/", views.BalanceReportView.as_view(), name="balance"), +] diff --git a/reports/views.py b/reports/views.py new file mode 100644 index 00000000..0c659d32 --- /dev/null +++ b/reports/views.py @@ -0,0 +1,149 @@ +from datetime import datetime +from decimal import Decimal +from itertools import pairwise + +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import BadRequest +from django.utils.timezone import localdate, make_aware +from django.views.generic import TemplateView + +from creditmanagement.models import Transaction, Account +from userdetails.models import Association + + +class ReportAccessMixin(UserPassesTestMixin): + def test_func(self): + # TODO rewrite to self.request.user.has_site_stats_access() when PR #276 is merged + boards = Association.objects.filter(user=self.request.user) + return True in (b.has_site_stats_access for b in boards) + + +class ReportsView(ReportAccessMixin, TemplateView): + template_name = "reports/index.html" + + +class BalanceReportView(ReportAccessMixin, TemplateView): + """Periodical reports of the balance of all credit accounts. + + User accounts are aggregated as one large pile. Association and bookkeeping + accounts are shown individually. + + The period is monthly. For each account the opening balance is given, the + credit and debit amount in the given period, and the final balance. + """ + + template_name = "reports/balance.html" + + def get_year(self): + try: + return int(self.request.GET.get("year", localdate().year)) + except ValueError: + raise BadRequest + + def period_boundaries(self): + """Yields tuples with the start and end date of each period.""" + # Start of each month + boundaries = [make_aware(datetime(self.get_year(), m, 1)) for m in range(1, 13)] + # End of last month + boundaries += [make_aware(datetime(self.get_year() + 1, 1, 1))] + + return pairwise(boundaries) + + def get_report(self): + """Computes the report values.""" + tx = Transaction.objects.all() + + boundaries = list(self.period_boundaries()) + + # Compute opening balance + running_balance = { + account: increase - reduction + for account, (increase, reduction) in tx.filter(moment__lt=boundaries[0][0]) + .sum_by_account(group_users=True) + .items() + } + + report = [] + for left, right in boundaries: + # Compute credit and debit sum in the period + mutation = tx.filter(moment__gte=left, moment__lt=right).sum_by_account( + group_users=True + ) + + # Compile report for this period + statements = { + account: { + "start_balance": running_balance.get(account, Decimal("0.00")), + "increase": increase, + "reduction": reduction, + "end_balance": running_balance.get(account, Decimal("0.00")) + + increase - reduction, + } + for account, (increase, reduction) in mutation.items() + } + + # Update running balance + running_balance.update( + (account, val["end_balance"]) for account, val in statements.items() + ) + + report.append((left, statements)) + return report + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # For the template: retrieve each Account instance, regroup by type and sort + report = self.get_report() + report_display = [] + account = {} # Cache for Account lookups + + for period, statements in report: + # Retrieve Accounts from database + for pk in statements: + if pk is not None and pk not in account: + account[pk] = Account.objects.get(pk=pk) + + # Split by type + bookkeeping = [] + association = [] + user_pile = None + for pk, statement in statements.items(): + if pk is None: + user_pile = statement + elif account[pk].special: + bookkeeping.append((account[pk], statement)) + elif account[pk].association: + association.append((account[pk], statement)) + else: + raise RuntimeError # Unreachable + + # Sort bookkeeping and association by name + bookkeeping.sort(key=lambda e: e[0].special) + association.sort(key=lambda e: e[0].association.name) + + report_display.append((period, bookkeeping, association, user_pile)) + + context.update( + { + "report_display": report_display, + "year": self.get_year(), + } + ) + return context + + +class CashFlowReportView(TemplateView): + """Periodical reports of money entering and leaving a specific account. + + For a selected account, shows the flow of money to and from other accounts + in a certain period. + """ + + pass + + +class DinerReportView(TemplateView): + """Reports on diner counts.""" + + pass diff --git a/scaladining/urls.py b/scaladining/urls.py index 80388a28..9ac7ea63 100644 --- a/scaladining/urls.py +++ b/scaladining/urls.py @@ -7,6 +7,7 @@ path("admin/", admin.site.urls), path("credit/", include("creditmanagement.urls")), path("site/", include("general.urls")), + path("reports/", include("reports.urls")), path("", include("dining.urls")), path("accounts/", include("userdetails.urls")), path("accounts/", include("allauth.urls")), From b3c8e75ec9c58b403c1716a7c2ee267b96fc4ca5 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Fri, 29 Dec 2023 16:46:06 +0100 Subject: [PATCH 02/12] Make changing report period easier --- assets/templates/reports/balance.html | 4 ++-- reports/views.py | 23 ++++++++++++----------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/assets/templates/reports/balance.html b/assets/templates/reports/balance.html index fb78691b..c0235e3e 100644 --- a/assets/templates/reports/balance.html +++ b/assets/templates/reports/balance.html @@ -28,8 +28,8 @@

- {% for period, bookkeeping, association, user_pile in report_display %} -

{{ period|date:"F" }}

+ {% for period, bookkeeping, association, user_pile, period_name in report_display %} +

{{ period_name }}

diff --git a/reports/views.py b/reports/views.py index 0c659d32..bea971f2 100644 --- a/reports/views.py +++ b/reports/views.py @@ -27,9 +27,6 @@ class BalanceReportView(ReportAccessMixin, TemplateView): User accounts are aggregated as one large pile. Association and bookkeeping accounts are shown individually. - - The period is monthly. For each account the opening balance is given, the - credit and debit amount in the given period, and the final balance. """ template_name = "reports/balance.html" @@ -41,13 +38,17 @@ def get_year(self): raise BadRequest def period_boundaries(self): - """Yields tuples with the start and end date of each period.""" - # Start of each month + """Get periods. + + Yields: + 3-tuples with start of period, end of period, and display name. + """ + # Start of each period boundaries = [make_aware(datetime(self.get_year(), m, 1)) for m in range(1, 13)] - # End of last month + # End of last period boundaries += [make_aware(datetime(self.get_year() + 1, 1, 1))] - return pairwise(boundaries) + return ((a, b, a.strftime("%B")) for a, b in pairwise(boundaries)) def get_report(self): """Computes the report values.""" @@ -64,7 +65,7 @@ def get_report(self): } report = [] - for left, right in boundaries: + for left, right, period_name in boundaries: # Compute credit and debit sum in the period mutation = tx.filter(moment__gte=left, moment__lt=right).sum_by_account( group_users=True @@ -87,7 +88,7 @@ def get_report(self): (account, val["end_balance"]) for account, val in statements.items() ) - report.append((left, statements)) + report.append((left, statements, period_name)) return report def get_context_data(self, **kwargs): @@ -98,7 +99,7 @@ def get_context_data(self, **kwargs): report_display = [] account = {} # Cache for Account lookups - for period, statements in report: + for period, statements, period_name in report: # Retrieve Accounts from database for pk in statements: if pk is not None and pk not in account: @@ -122,7 +123,7 @@ def get_context_data(self, **kwargs): bookkeeping.sort(key=lambda e: e[0].special) association.sort(key=lambda e: e[0].association.name) - report_display.append((period, bookkeeping, association, user_pile)) + report_display.append((period, bookkeeping, association, user_pile, period_name)) context.update( { From 77651c143a8cd82a18ccb1f9d30c98ec978542e9 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Fri, 29 Dec 2023 17:01:58 +0100 Subject: [PATCH 03/12] Quarterly report instead of monthly --- reports/views.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/reports/views.py b/reports/views.py index bea971f2..1ba0a21d 100644 --- a/reports/views.py +++ b/reports/views.py @@ -40,9 +40,15 @@ def get_year(self): def period_boundaries(self): """Get periods. + Period boundaries *must* be touching, i.e. start of next period is the + same as the end of the current period. + Yields: 3-tuples with start of period, end of period, and display name. """ + return self.period_boundaries_quarterly() + + def period_boundaries_monthly(self): # Start of each period boundaries = [make_aware(datetime(self.get_year(), m, 1)) for m in range(1, 13)] # End of last period @@ -50,6 +56,19 @@ def period_boundaries(self): return ((a, b, a.strftime("%B")) for a, b in pairwise(boundaries)) + def period_boundaries_quarterly(self): + year = self.get_year() + + def q(quartile): + make_aware(datetime(year, (quartile - 1) * 3 + 1, 1)) + + yield q(1), q(2), "Q1 January, February, March" + yield q(2), q(3), "Q2 April, May, June" + yield q(3), q(4), "Q3 July, August, September" + yield q(4), make_aware( + datetime(year + 1, 1, 1) + ), "Q4 October, November, December" + def get_report(self): """Computes the report values.""" tx = Transaction.objects.all() @@ -78,7 +97,8 @@ def get_report(self): "increase": increase, "reduction": reduction, "end_balance": running_balance.get(account, Decimal("0.00")) - + increase - reduction, + + increase + - reduction, } for account, (increase, reduction) in mutation.items() } @@ -123,7 +143,9 @@ def get_context_data(self, **kwargs): bookkeeping.sort(key=lambda e: e[0].special) association.sort(key=lambda e: e[0].association.name) - report_display.append((period, bookkeeping, association, user_pile, period_name)) + report_display.append( + (period, bookkeeping, association, user_pile, period_name) + ) context.update( { From 47a22ad02aa13002b076d33f73809c0f96479ecf Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Fri, 29 Dec 2023 17:19:03 +0100 Subject: [PATCH 04/12] Small bugfix --- reports/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reports/views.py b/reports/views.py index 1ba0a21d..f7040017 100644 --- a/reports/views.py +++ b/reports/views.py @@ -60,7 +60,7 @@ def period_boundaries_quarterly(self): year = self.get_year() def q(quartile): - make_aware(datetime(year, (quartile - 1) * 3 + 1, 1)) + return make_aware(datetime(year, (quartile - 1) * 3 + 1, 1)) yield q(1), q(2), "Q1 January, February, March" yield q(2), q(3), "Q2 April, May, June" From 8e77ce4b84c0862c039187a4b628fe7249b0e2bc Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Sat, 30 Dec 2023 13:40:08 +0100 Subject: [PATCH 05/12] Add cash flow and transactions report and minor report changes --- assets/templates/reports/balance.html | 29 +-- assets/templates/reports/cashflow.html | 56 +++++ assets/templates/reports/cashflow_index.html | 33 +++ assets/templates/reports/index.html | 6 + assets/templates/reports/stale.html | 0 assets/templates/reports/transactions.html | 49 +++++ creditmanagement/models.py | 47 ++-- reports/period.py | 83 +++++++ reports/urls.py | 5 +- reports/views.py | 215 ++++++++++++++----- 10 files changed, 438 insertions(+), 85 deletions(-) create mode 100644 assets/templates/reports/cashflow.html create mode 100644 assets/templates/reports/cashflow_index.html create mode 100644 assets/templates/reports/stale.html create mode 100644 assets/templates/reports/transactions.html create mode 100644 reports/period.py diff --git a/assets/templates/reports/balance.html b/assets/templates/reports/balance.html index c0235e3e..39605ce1 100644 --- a/assets/templates/reports/balance.html +++ b/assets/templates/reports/balance.html @@ -3,7 +3,7 @@ {% block report %}

Balance report - Period: {{ year }} + {{ year }}

@@ -20,16 +20,17 @@

- Bookkeeping accounts are shown in blue. - Association accounts are gray. - All individual user balances are summed together and shown as a single row at the - bottom. + Bookkeeping accounts are shown in blue. + Association accounts are gray. + All individual user balances are summed together and shown as + a single row at the bottom. Accounts for which no transactions took place are omitted.

- {% for period, bookkeeping, association, user_pile, period_name in report_display %} -

{{ period_name }}

+ {% for period, bookkeeping, association, user_pile in report_display %} +

{{ period.get_display_name }}

+
@@ -45,8 +46,8 @@

{{ period_name }}

- - + + @@ -55,17 +56,17 @@

{{ period_name }}

- - + + {% endfor %} {% if user_pile %} - + - - + + {% endif %} diff --git a/assets/templates/reports/cashflow.html b/assets/templates/reports/cashflow.html new file mode 100644 index 00000000..a838a825 --- /dev/null +++ b/assets/templates/reports/cashflow.html @@ -0,0 +1,56 @@ +{% extends 'reports/base.html' %} + +{% block nav %} + ← Choose account +{% endblock %} + +{% block report %} +

+ Cash flow + {{ account }} / {{ year }} +

+ +

+ Inflow is in the direction from the opposite account towards + the {{ account }} account. Outflow is in the other direction. +

+ + + + {% for period, statements in report_display %} +

{{ period.get_display_name }}

+
{{ account.get_special_display }} {{ statement.start_balance }}{{ statement.increase }}{{ statement.reduction }}{{ statement.increase|default:"–" }}{{ statement.reduction|default:"–" }} {{ statement.end_balance }}
{{ account.association.name }} {{ statement.start_balance }}{{ statement.increase }}{{ statement.reduction }}{{ statement.increase|default:"–" }}{{ statement.reduction|default:"–" }} {{ statement.end_balance }}
All user accounts combinedUser accounts {{ user_pile.start_balance }}{{ user_pile.increase }}{{ user_pile.reduction }}{{ user_pile.increase|default:"–" }}{{ user_pile.reduction|default:"–" }} {{ user_pile.end_balance }}
+ + + + + + + + + {% for account, inflow, outflow in statements %} + + + + + + {% endfor %} + +
Opposite accountInflowOutflow
+ {% if account %}{{ account }} » + {% else %}User accounts + {% endif %} + {{ inflow|default:"–" }}{{ outflow|default:"–" }}
+ {% endfor %} +{% endblock %} diff --git a/assets/templates/reports/cashflow_index.html b/assets/templates/reports/cashflow_index.html new file mode 100644 index 00000000..1d784af6 --- /dev/null +++ b/assets/templates/reports/cashflow_index.html @@ -0,0 +1,33 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

Cash flow Choose account

+

Bookkeeping accounts

+
+
+
+ {% for account in bookkeeping_accounts %} + + {{ account }} + + {% endfor %} +
+
+
+ +

Association accounts

+
+
+
+ {% for account in association_accounts %} + + {{ account }} + + {% endfor %} +
+
+
+ +{% endblock %} diff --git a/assets/templates/reports/index.html b/assets/templates/reports/index.html index dc7c1f5b..807152bc 100644 --- a/assets/templates/reports/index.html +++ b/assets/templates/reports/index.html @@ -8,6 +8,12 @@

Reports

Balance + + Cash flow + + + Transactions + diff --git a/assets/templates/reports/stale.html b/assets/templates/reports/stale.html new file mode 100644 index 00000000..e69de29b diff --git a/assets/templates/reports/transactions.html b/assets/templates/reports/transactions.html new file mode 100644 index 00000000..8e1e3f7e --- /dev/null +++ b/assets/templates/reports/transactions.html @@ -0,0 +1,49 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

Transactions {{ year }}

+ + + + +

+ Site transactions between any association or bookkeeping account, excluding + transactions to or from any user account. + These excluded transactions are usually deposits, withdrawals or kitchen payments. +

+ + + + + + + + + + + + + {% for tx in transactions %} + + + + + + + + + {% endfor %} + +
Date (dd-mm)SourceDestinationAmountDescriptionCreated by
{{ tx.moment|date:"d-m" }}{{ tx.source }}{{ tx.target }}{{ tx.amount }}{{ tx.description }}{{ tx.created_by }}
+{% endblock %} diff --git a/creditmanagement/models.py b/creditmanagement/models.py index 8d63c872..b7318c11 100644 --- a/creditmanagement/models.py +++ b/creditmanagement/models.py @@ -122,23 +122,18 @@ def filter_account(self, account: Account): """Filters transactions that have the given account as source or target.""" return self.filter(Q(source=account) | Q(target=account)) - def sum_by_account(self, group_users=False): - """Sums the amounts in this QuerySet, grouped by account. - - Computes for each account that occurs in the QuerySet, the total - balance increase and decrease sum, over all transactions in this - QuerySet. + def group_by_account(self, group_users=False): + """Group transactions by source and target account. Args: group_users: If True, user accounts are grouped together and given - key 'None'. + key `None`. Returns: - A dictionary with as key the account id or None when the account is - for a user and group_users is True. The value is a tuple with the - increase and reduce sum (possibly 0). + A Transaction QuerySet tuple with respectively the source and + target grouped with key `account`. """ - # Annotate the grouping key + # Annotate the grouping key `account` if group_users: source_qs = self.annotate( account=Case( @@ -147,7 +142,6 @@ def sum_by_account(self, group_users=False): default="source", ) ) - target_qs = self.annotate( account=Case( When(target__user__isnull=False, then=None), default="target" @@ -157,9 +151,28 @@ def sum_by_account(self, group_users=False): source_qs = self.annotate(account="source") target_qs = self.annotate(account="target") - # Group by account and aggregate - reduction = source_qs.values("account").annotate(sum=Sum("amount")) - increase = target_qs.values("account").annotate(sum=Sum("amount")) + # Group by `account` + return source_qs.values("account"), target_qs.values("account") + + def sum_by_account(self, group_users=False): + """Sums the amounts in the QuerySet, grouped by account. + + Computes for each account that occurs in the QuerySet, the total + balance increase and decrease sum, over all transactions in this + QuerySet. + + Args: + group_users: See `TransactionQuerySet.group_by_account`. + + Returns: + A dictionary with as key the account id or None when the account is + for a user and group_users is True. The value is a tuple with the + increase and reduce sum (possibly 0). + """ + source_qs, target_qs = self.group_by_account(group_users=group_users) + + reduction = source_qs.annotate(sum=Sum("amount")) + increase = target_qs.annotate(sum=Sum("amount")) # Combine on account key combined = {e["account"]: {"increase_sum": e["sum"]} for e in increase} @@ -196,6 +209,10 @@ class Transaction(models.Model): objects = TransactionQuerySet.as_manager() + # This model should not have a default ordering because that probably + # breaks stuff like `sum_by_account`. See + # https://stackoverflow.com/a/1341667/2373688 + def reversal(self, reverted_by: User): """Returns a reversal transaction for this transaction (unsaved).""" return Transaction( diff --git a/reports/period.py b/reports/period.py new file mode 100644 index 00000000..a00ca751 --- /dev/null +++ b/reports/period.py @@ -0,0 +1,83 @@ +from datetime import datetime + +from django.utils.timezone import make_aware + +from creditmanagement.models import Transaction + + +class Period: + def get_period_start(self) -> datetime: + raise NotImplementedError + + def get_period_end(self) -> datetime: + return self.next().get_period_start() + + def get_display_name(self) -> str: + raise NotImplementedError + + def next(self) -> "Period": + """Returns the adjacent period directly after this one.""" + raise NotImplementedError + + def get_transactions(self, tx=None): + """Filter transactions in this period.""" + if tx is None: + tx = Transaction.objects.all() + return tx.filter( + moment__gte=self.get_period_start(), moment__lt=self.get_period_end() + ) + + +class MonthPeriod(Period): + def __init__(self, year: int, month: int): + if month < 1 or month > 12: + raise ValueError + self.year = year + self.month = month + + def get_period_start(self): + return (make_aware(datetime(self.year, self.month, 1)),) + + def next(self) -> "Period": + return ( + MonthPeriod(self.year, self.month + 1) + if self.month < 12 + else MonthPeriod(self.year + 1, 1) + ) + + @classmethod + def for_year(cls, year: int) -> list["MonthPeriod"]: + return [cls(year, m) for m in range(1, 13)] + + def get_display_name(self): + return self.get_period_start().strftime("%B") + + +class QuarterPeriod(Period): + def __init__(self, year: int, quarter: int): + if quarter < 1 or quarter > 4: + raise ValueError + self.year = year + self.quarter = quarter + + def get_period_start(self) -> datetime: + return make_aware(datetime(self.year, (self.quarter - 1) * 3 + 1, 1)) + + def next(self) -> "Period": + return ( + QuarterPeriod(self.year, self.quarter + 1) + if self.quarter < 4 + else QuarterPeriod(self.year + 1, 1) + ) + + @classmethod + def for_year(cls, year: int): + return [cls(year, q) for q in range(1, 5)] + + def get_display_name(self) -> str: + return ( + "Q1 January, February, March", + "Q2 April, May, June", + "Q3 July, August, September", + "Q4 October, November, December", + )[self.quarter - 1] diff --git a/reports/urls.py b/reports/urls.py index 38afa8f5..475e9a24 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -6,5 +6,8 @@ urlpatterns = [ path("", views.ReportsView.as_view(), name="index"), - path("balance/", views.BalanceReportView.as_view(), name="balance"), + path("balance/", views.BalanceView.as_view(), name="balance"), + path("cashflow/", views.CashFlowIndexView.as_view(), name="cashflow_index"), + path("cashflow//", views.CashFlowView.as_view(), name="cashflow"), + path("transactions/", views.TransactionsReportView.as_view(), name="transactions"), ] diff --git a/reports/views.py b/reports/views.py index f7040017..de63eada 100644 --- a/reports/views.py +++ b/reports/views.py @@ -1,13 +1,15 @@ from datetime import datetime from decimal import Decimal -from itertools import pairwise +from itertools import pairwise, chain from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import BadRequest +from django.db.models import Sum, Case, When from django.utils.timezone import localdate, make_aware -from django.views.generic import TemplateView +from django.views.generic import TemplateView, DetailView from creditmanagement.models import Transaction, Account +from reports.period import Period, QuarterPeriod from userdetails.models import Association @@ -21,15 +23,21 @@ def test_func(self): class ReportsView(ReportAccessMixin, TemplateView): template_name = "reports/index.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + "association_accounts": Account.objects.filter( + association__isnull=False + ).order_by("association__name"), + "bookkeeping_accounts": Account.objects.filter(special__isnull=False), + } + ) + return context -class BalanceReportView(ReportAccessMixin, TemplateView): - """Periodical reports of the balance of all credit accounts. - - User accounts are aggregated as one large pile. Association and bookkeeping - accounts are shown individually. - """ - template_name = "reports/balance.html" +class PeriodMixin: + """Mixin for yearly reporting periods.""" def get_year(self): try: @@ -37,58 +45,42 @@ def get_year(self): except ValueError: raise BadRequest - def period_boundaries(self): - """Get periods. - - Period boundaries *must* be touching, i.e. start of next period is the - same as the end of the current period. + def get_periods(self) -> list[Period]: + return QuarterPeriod.for_year(self.get_year()) - Yields: - 3-tuples with start of period, end of period, and display name. - """ - return self.period_boundaries_quarterly() - - def period_boundaries_monthly(self): - # Start of each period - boundaries = [make_aware(datetime(self.get_year(), m, 1)) for m in range(1, 13)] - # End of last period - boundaries += [make_aware(datetime(self.get_year() + 1, 1, 1))] + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["year"] = self.get_year() + return context - return ((a, b, a.strftime("%B")) for a, b in pairwise(boundaries)) - def period_boundaries_quarterly(self): - year = self.get_year() +class BalanceView(ReportAccessMixin, PeriodMixin, TemplateView): + """Periodical reports of the balance of all credit accounts. - def q(quartile): - return make_aware(datetime(year, (quartile - 1) * 3 + 1, 1)) + User accounts are aggregated as one large pile. Association and bookkeeping + accounts are shown individually. + """ - yield q(1), q(2), "Q1 January, February, March" - yield q(2), q(3), "Q2 April, May, June" - yield q(3), q(4), "Q3 July, August, September" - yield q(4), make_aware( - datetime(year + 1, 1, 1) - ), "Q4 October, November, December" + template_name = "reports/balance.html" def get_report(self): """Computes the report values.""" - tx = Transaction.objects.all() - - boundaries = list(self.period_boundaries()) + periods = self.get_periods() # Compute opening balance running_balance = { account: increase - reduction - for account, (increase, reduction) in tx.filter(moment__lt=boundaries[0][0]) + for account, (increase, reduction) in Transaction.objects.filter( + moment__lt=periods[0].get_period_start() + ) .sum_by_account(group_users=True) .items() } report = [] - for left, right, period_name in boundaries: + for period in periods: # Compute credit and debit sum in the period - mutation = tx.filter(moment__gte=left, moment__lt=right).sum_by_account( - group_users=True - ) + mutation = period.get_transactions().sum_by_account(group_users=True) # Compile report for this period statements = { @@ -108,18 +100,19 @@ def get_report(self): (account, val["end_balance"]) for account, val in statements.items() ) - report.append((left, statements, period_name)) + report.append((period, statements)) return report def get_context_data(self, **kwargs): + # This function just regroups and sorts the values for display + context = super().get_context_data(**kwargs) # For the template: retrieve each Account instance, regroup by type and sort - report = self.get_report() report_display = [] account = {} # Cache for Account lookups - for period, statements, period_name in report: + for period, statements in self.get_report(): # Retrieve Accounts from database for pk in statements: if pk is not None and pk not in account: @@ -143,26 +136,138 @@ def get_context_data(self, **kwargs): bookkeeping.sort(key=lambda e: e[0].special) association.sort(key=lambda e: e[0].association.name) - report_display.append( - (period, bookkeeping, association, user_pile, period_name) + report_display.append((period, bookkeeping, association, user_pile)) + + context["report_display"] = report_display + return context + + +class CashFlowView(ReportAccessMixin, PeriodMixin, DetailView): + """Periodical reports of money entering and leaving a specific account. + + For a selected account, shows the flow of money to and from other accounts + in a certain period. + """ + + template_name = "reports/cashflow.html" + model = Account + context_object_name = "account" + + def get_period_statement(self, period): + """Returns a dictionary from Account or None to an income/outgoings tuple.""" + tx = period.get_transactions() + # Aggregate income and outgoings + income = ( + tx.filter(target=self.object) + # Set the group key to NULL for all user accounts + .annotate( + account=Case( + When(source__user__isnull=False, then=None), + default="source", + ) + ) + # Group by source account + .values("account") + # Sum amount for each separate source + .annotate(sum=Sum("amount")) + ) + outgoings = ( + # See above for income + tx.filter(source=self.object) + .annotate( + account=Case( + When(target__user__isnull=False, then=None), + default="target", + ) + ) + .values("account") + .annotate(sum=Sum("amount")) + ) + + # Regroup on account + income = {v["account"]: v["sum"] for v in income} + outgoings = {v["account"]: v["sum"] for v in outgoings} + regroup = { + # Retrieve Account from db + Account.objects.get(pk=account) + if account + else None: ( + income.get(account), + outgoings.get(account), + ) + for account in income.keys() | outgoings.keys() + } + print(regroup) + return regroup + + def get_report(self): + return [(p, self.get_period_statement(p)) for p in self.get_periods()] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + report_display = [] + for period, statements in self.get_report(): + # Convert to list of tuples + statements2 = [ + (account, inc, out) for account, (inc, out) in statements.items() + ] + # Sort in 3 steps to have first the bookkeeping accounts in + # alphabetical order, then the association accounts in order and + # then the user pile. + # + # This works because sort is stable. + statements2.sort( + key=lambda v: v[0].association.name if v[0] and v[0].association else "" + ) + statements2.sort( + key=lambda v: v[0].special if v[0] and v[0].special else "" + ) + statements2.sort( + key=lambda v: 2 if v[0] is None else 1 if v[0].association else 0 ) + report_display.append((period, statements2)) + context["report_display"] = report_display + return context + + +class CashFlowIndexView(ReportAccessMixin, TemplateView): + template_name = "reports/cashflow_index.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) context.update( { - "report_display": report_display, - "year": self.get_year(), + "association_accounts": Account.objects.filter( + association__isnull=False + ).order_by("association__name"), + "bookkeeping_accounts": Account.objects.filter(special__isnull=False), } ) return context -class CashFlowReportView(TemplateView): - """Periodical reports of money entering and leaving a specific account. +class TransactionsReportView(ReportAccessMixin, PeriodMixin, TemplateView): + """Report to view all transactions excluding those involving user accounts.""" + template_name = "reports/transactions.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + "transactions": Transaction.objects.filter( + moment__year=self.get_year(), + source__user__isnull=True, + target__user__isnull=True, + ).order_by("moment") + }) + return context + + +class CashFlowMatrixView(ReportAccessMixin, TemplateView): + pass - For a selected account, shows the flow of money to and from other accounts - in a certain period. - """ +class StaleAccountsView(ReportAccessMixin, TemplateView): pass From a2e4b0f9accadc37fe7b70e192faa905ec3d89db Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Sat, 30 Dec 2023 13:41:51 +0100 Subject: [PATCH 06/12] Formatting only --- creditmanagement/models.py | 2 +- reports/views.py | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/creditmanagement/models.py b/creditmanagement/models.py index b7318c11..94e8bde0 100644 --- a/creditmanagement/models.py +++ b/creditmanagement/models.py @@ -4,7 +4,7 @@ from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Q, QuerySet, Sum, Case, When +from django.db.models import Case, Q, QuerySet, Sum, When from django.utils import timezone from userdetails.models import Association, User diff --git a/reports/views.py b/reports/views.py index de63eada..348193db 100644 --- a/reports/views.py +++ b/reports/views.py @@ -1,14 +1,12 @@ -from datetime import datetime from decimal import Decimal -from itertools import pairwise, chain from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import BadRequest -from django.db.models import Sum, Case, When -from django.utils.timezone import localdate, make_aware -from django.views.generic import TemplateView, DetailView +from django.db.models import Case, Sum, When +from django.utils.timezone import localdate +from django.views.generic import DetailView, TemplateView -from creditmanagement.models import Transaction, Account +from creditmanagement.models import Account, Transaction from reports.period import Period, QuarterPeriod from userdetails.models import Association @@ -249,17 +247,20 @@ def get_context_data(self, **kwargs): class TransactionsReportView(ReportAccessMixin, PeriodMixin, TemplateView): """Report to view all transactions excluding those involving user accounts.""" + template_name = "reports/transactions.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context.update({ - "transactions": Transaction.objects.filter( - moment__year=self.get_year(), - source__user__isnull=True, - target__user__isnull=True, - ).order_by("moment") - }) + context.update( + { + "transactions": Transaction.objects.filter( + moment__year=self.get_year(), + source__user__isnull=True, + target__user__isnull=True, + ).order_by("moment") + } + ) return context From 32c60bbc36ef815da71c132c2e2bd03139e9a7ac Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Sat, 30 Dec 2023 16:28:31 +0100 Subject: [PATCH 07/12] Stale accounts report --- assets/templates/reports/index.html | 3 + assets/templates/reports/stale.html | 42 +++++++++++++ creditmanagement/models.py | 92 ++++++++++++++++++----------- reports/urls.py | 1 + reports/views.py | 51 +++++++++++++++- 5 files changed, 154 insertions(+), 35 deletions(-) diff --git a/assets/templates/reports/index.html b/assets/templates/reports/index.html index 807152bc..e287019d 100644 --- a/assets/templates/reports/index.html +++ b/assets/templates/reports/index.html @@ -14,6 +14,9 @@

Reports

Transactions + + Stale accounts + diff --git a/assets/templates/reports/stale.html b/assets/templates/reports/stale.html index e69de29b..f7a61cd3 100644 --- a/assets/templates/reports/stale.html +++ b/assets/templates/reports/stale.html @@ -0,0 +1,42 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

Stale accounts

+

+ This report groups all accounts on the site based on the latest transaction date of the account. + For instance, if a user did their latest transaction in January 2020, + they are added with their account balance to row 2020 Q1. + Positive and negative account balances are shown separately. +

+ + + + + + + + + + + + + + + + + + + {% for quartile, counts in report_display %} + + + + + + + + + + {% endfor %} + +
Number of accountsSum of account balances
Last transactionPos.Neg.TotalPos.Neg.Total
{{ quartile }}{{ counts.positive_count }}{{ counts.negative_count }}{{ counts.total_count }}{{ counts.positive_sum }}{{ counts.negative_sum }}{{ counts.total_sum }}
+{% endblock %} \ No newline at end of file diff --git a/creditmanagement/models.py b/creditmanagement/models.py index 94e8bde0..cbc8b53e 100644 --- a/creditmanagement/models.py +++ b/creditmanagement/models.py @@ -1,11 +1,11 @@ -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from typing import Optional, Union from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Case, Q, QuerySet, Sum, When -from django.utils import timezone +from django.db.models import Case, F, Max, Q, QuerySet, Sum, When +from django.utils.timezone import now from userdetails.models import Association, User @@ -122,39 +122,39 @@ def filter_account(self, account: Account): """Filters transactions that have the given account as source or target.""" return self.filter(Q(source=account) | Q(target=account)) - def group_by_account(self, group_users=False): + def group_by_account(self, group_users=False, key="account"): """Group transactions by source and target account. Args: group_users: If True, user accounts are grouped together and given - key `None`. + key `None`. + key: The name given to the account ID annotation. Returns: A Transaction QuerySet tuple with respectively the source and - target grouped with key `account`. + target grouped by account. """ - # Annotate the grouping key `account` if group_users: - source_qs = self.annotate( - account=Case( - # Set the group key to NULL for all user accounts - When(source__user__isnull=False, then=None), - default="source", - ) + # Set the group key to NULL for all user accounts + source_query = Case( + When(source__user__isnull=False, then=None), default="source" ) - target_qs = self.annotate( - account=Case( - When(target__user__isnull=False, then=None), default="target" - ) + target_query = Case( + When(target__user__isnull=False, then=None), default="target" ) else: - source_qs = self.annotate(account="source") - target_qs = self.annotate(account="target") + source_query = F("source") + target_query = F("target") - # Group by `account` - return source_qs.values("account"), target_qs.values("account") + # Annotate and group by key + return ( + self.annotate(**{key: source_query}).values(key), + self.annotate(**{key: target_query}).values(key), + ) - def sum_by_account(self, group_users=False): + def sum_by_account( + self, group_users=False, latest=False + ) -> dict[int | None, tuple[Decimal, Decimal] | tuple[Decimal, Decimal, datetime]]: """Sums the amounts in the QuerySet, grouped by account. Computes for each account that occurs in the QuerySet, the total @@ -163,6 +163,7 @@ def sum_by_account(self, group_users=False): Args: group_users: See `TransactionQuerySet.group_by_account`. + latest: When `True`, include the last transaction date in the tuple. Returns: A dictionary with as key the account id or None when the account is @@ -171,23 +172,46 @@ def sum_by_account(self, group_users=False): """ source_qs, target_qs = self.group_by_account(group_users=group_users) - reduction = source_qs.annotate(sum=Sum("amount")) - increase = target_qs.annotate(sum=Sum("amount")) + reduction = source_qs.annotate(reduction=Sum("amount")) + increase = target_qs.annotate(increase=Sum("amount")) + + if latest: + reduction = reduction.annotate(last_source_tx=Max("moment")) + increase = increase.annotate(last_target_tx=Max("moment")) - # Combine on account key - combined = {e["account"]: {"increase_sum": e["sum"]} for e in increase} - for e in reduction: - combined.setdefault(e["account"], {})["reduction_sum"] = e["sum"] + # Merge on account key + merged = {e["account"]: dict(e) for e in reduction} + for e in increase: + merged.setdefault(e["account"], {}).update(e) - # Convert to tuple - return { + # Convert to (increase, reduction) tuple + result = { account: ( - val.get("increase_sum", Decimal("0.00")), - val.get("reduction_sum", Decimal("0.00")), + val.get("increase", Decimal("0.00")), + val.get("reduction", Decimal("0.00")), ) - for account, val in combined.items() + for account, val in merged.items() } + if latest: + # Add latest transaction moment + # + # The tuple becomes (increase, reduction, last_date) + min_date = datetime(1, 1, 1, tzinfo=timezone.utc) + result = { + account: ( + increase, + reduction, + max( + merged[account].get("last_source_tx", min_date), + merged[account].get("last_target_tx", min_date), + ), + ) + for account, (increase, reduction) in result.items() + } + + return result + class Transaction(models.Model): # We do not enforce that source != target because those rows are not harmful. @@ -201,7 +225,7 @@ class Transaction(models.Model): amount = models.DecimalField( decimal_places=2, max_digits=8, validators=[MinValueValidator(Decimal("0.01"))] ) - moment = models.DateTimeField(default=timezone.now) + moment = models.DateTimeField(default=now) description = models.CharField(max_length=1000) created_by = models.ForeignKey( User, on_delete=models.PROTECT, related_name="transaction_set" diff --git a/reports/urls.py b/reports/urls.py index 475e9a24..07a58f24 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -10,4 +10,5 @@ path("cashflow/", views.CashFlowIndexView.as_view(), name="cashflow_index"), path("cashflow//", views.CashFlowView.as_view(), name="cashflow"), path("transactions/", views.TransactionsReportView.as_view(), name="transactions"), + path("stale/", views.StaleAccountsView.as_view(), name="stale"), ] diff --git a/reports/views.py b/reports/views.py index 348193db..29d49ebb 100644 --- a/reports/views.py +++ b/reports/views.py @@ -269,7 +269,56 @@ class CashFlowMatrixView(ReportAccessMixin, TemplateView): class StaleAccountsView(ReportAccessMixin, TemplateView): - pass + """Report on the sum of account balances grouped in a period.""" + + template_name = "reports/stale.html" + + def get_report(self) -> dict[str, dict]: + # Get balance and latest transaction date for all accounts + data = Transaction.objects.sum_by_account(latest=True) + + # Group by quartile and aggregate + report = {} + for increase, reduction, last_date in data.values(): + # If we omit localdate the timezone would be UTC and items might end up in + # a different bucket. + date = localdate(last_date) + bucket = f"{date.year} Q{(date.month - 1) // 3 + 1}" + balance = increase - reduction + + if balance: + init = { + "positive_count": 0, + "positive_sum": Decimal("0.00"), + "negative_count": 0, + "negative_sum": Decimal("0.00"), + } + report.setdefault(bucket, init) + + # Update count and sum + if balance > 0: + report[bucket]["positive_count"] += 1 + report[bucket]["positive_sum"] += balance + elif balance < 0: + report[bucket]["negative_count"] += 1 + report[bucket]["negative_sum"] += balance + + # Add totals + for counts in report.values(): + counts["total_count"] = counts["positive_count"] + counts["negative_count"] + counts["total_sum"] = counts["positive_sum"] + counts["negative_sum"] + + return report + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Format report as a list and sort + report_display = [(q, counts) for q, counts in self.get_report().items()] + report_display.sort(reverse=True) + + context.update({"report_display": report_display}) + return context class DinerReportView(TemplateView): From cc5dc81493b28791ea0622c17925c7914fcc9079 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Sat, 30 Dec 2023 21:51:38 +0100 Subject: [PATCH 08/12] Cash flow matrix report --- assets/templates/reports/cashflow_matrix.html | 83 +++++++++++++ assets/templates/reports/index.html | 10 +- assets/templates/reports/stale.html | 12 +- reports/period.py | 29 ++++- reports/urls.py | 1 + reports/views.py | 114 +++++++++++++++--- 6 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 assets/templates/reports/cashflow_matrix.html diff --git a/assets/templates/reports/cashflow_matrix.html b/assets/templates/reports/cashflow_matrix.html new file mode 100644 index 00000000..dde778a8 --- /dev/null +++ b/assets/templates/reports/cashflow_matrix.html @@ -0,0 +1,83 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

+ Cash flow matrix + {{ year }} +

+ + + + {% for period, table in tables %} +

{{ period }}

+
+ + + + + + + + + {% for account in accounts %} + + {% endfor %} + + + + {% for account_from, row in table %} + + {% if forloop.first %} + + {% endif %} + + {% for account_to, amount in row %} + + {% endfor %} + + {% endfor %} + +
To
+ {% if account.special %} + {{ account.get_special_display|truncatechars:6 }} + {% elif account.association %} + {# TODO: change slug to short_name after #278 is merged #} + {{ account.association.slug|truncatechars:6 }} + {% else %} + Users + {% endif %} +
From + + {% if account_from == account_to %} + × + {% else %} + {{ amount|default:"" }} + {% endif %} +
+
+ {% endfor %} +{% endblock %} diff --git a/assets/templates/reports/index.html b/assets/templates/reports/index.html index e287019d..720aca40 100644 --- a/assets/templates/reports/index.html +++ b/assets/templates/reports/index.html @@ -2,6 +2,7 @@ {% block content %}

Reports

+

Finance

@@ -9,7 +10,14 @@

Reports

Balance - Cash flow + Cash flow
+ + Deprecated: this report has been superseded by the cash flow matrix which + provides the same info. This report will be removed. + +
+ + Cash flow matrix Transactions diff --git a/assets/templates/reports/stale.html b/assets/templates/reports/stale.html index f7a61cd3..36e94e0f 100644 --- a/assets/templates/reports/stale.html +++ b/assets/templates/reports/stale.html @@ -3,10 +3,16 @@ {% block report %}

Stale accounts

- This report groups all accounts on the site based on the latest transaction date of the account. - For instance, if a user did their latest transaction in January 2020, - they are added with their account balance to row 2020 Q1. + This report groups all user accounts on the site based on the + last transaction date of the account. + For instance, if the total number of accounts for 2020 Q1 was 48, + with a total balance of 61.50, it means that there are 48 + users who made their latest transaction in quarter 1 of 2020 + and their balances sum up to 61.50. +

+

Positive and negative account balances are shown separately. + Association and bookkeeping accounts are not included in the report.

diff --git a/reports/period.py b/reports/period.py index a00ca751..5e3a2dbe 100644 --- a/reports/period.py +++ b/reports/period.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from datetime import datetime from django.utils.timezone import make_aware @@ -5,19 +6,22 @@ from creditmanagement.models import Transaction -class Period: +class Period(ABC): + @abstractmethod def get_period_start(self) -> datetime: - raise NotImplementedError + pass def get_period_end(self) -> datetime: return self.next().get_period_start() + @abstractmethod def get_display_name(self) -> str: - raise NotImplementedError + pass + @abstractmethod def next(self) -> "Period": """Returns the adjacent period directly after this one.""" - raise NotImplementedError + pass def get_transactions(self, tx=None): """Filter transactions in this period.""" @@ -27,6 +31,9 @@ def get_transactions(self, tx=None): moment__gte=self.get_period_start(), moment__lt=self.get_period_end() ) + def __str__(self): + return self.get_display_name() + class MonthPeriod(Period): def __init__(self, year: int, month: int): @@ -81,3 +88,17 @@ def get_display_name(self) -> str: "Q3 July, August, September", "Q4 October, November, December", )[self.quarter - 1] + + +class YearPeriod(Period): + def __init__(self, year: int): + self.year = year + + def get_period_start(self) -> datetime: + return make_aware(datetime(self.year, 1, 1)) + + def next(self) -> "Period": + return YearPeriod(self.year + 1) + + def get_display_name(self) -> str: + return "" diff --git a/reports/urls.py b/reports/urls.py index 07a58f24..82c1d9b2 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -9,6 +9,7 @@ path("balance/", views.BalanceView.as_view(), name="balance"), path("cashflow/", views.CashFlowIndexView.as_view(), name="cashflow_index"), path("cashflow//", views.CashFlowView.as_view(), name="cashflow"), + path("cashflow2/", views.CashFlowMatrixView.as_view(), name="cashflow_matrix"), path("transactions/", views.TransactionsReportView.as_view(), name="transactions"), path("stale/", views.StaleAccountsView.as_view(), name="stale"), ] diff --git a/reports/views.py b/reports/views.py index 29d49ebb..70ed8a34 100644 --- a/reports/views.py +++ b/reports/views.py @@ -2,12 +2,12 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import BadRequest -from django.db.models import Case, Sum, When +from django.db.models import Case, Sum, When, Q from django.utils.timezone import localdate from django.views.generic import DetailView, TemplateView from creditmanagement.models import Account, Transaction -from reports.period import Period, QuarterPeriod +from reports.period import Period, QuarterPeriod, YearPeriod from userdetails.models import Association @@ -34,7 +34,7 @@ def get_context_data(self, **kwargs): return context -class PeriodMixin: +class YearMixin: """Mixin for yearly reporting periods.""" def get_year(self): @@ -43,16 +43,13 @@ def get_year(self): except ValueError: raise BadRequest - def get_periods(self) -> list[Period]: - return QuarterPeriod.for_year(self.get_year()) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["year"] = self.get_year() return context -class BalanceView(ReportAccessMixin, PeriodMixin, TemplateView): +class BalanceView(ReportAccessMixin, YearMixin, TemplateView): """Periodical reports of the balance of all credit accounts. User accounts are aggregated as one large pile. Association and bookkeeping @@ -63,7 +60,7 @@ class BalanceView(ReportAccessMixin, PeriodMixin, TemplateView): def get_report(self): """Computes the report values.""" - periods = self.get_periods() + periods = QuarterPeriod.for_year(self.get_year()) # Compute opening balance running_balance = { @@ -140,7 +137,7 @@ def get_context_data(self, **kwargs): return context -class CashFlowView(ReportAccessMixin, PeriodMixin, DetailView): +class CashFlowView(ReportAccessMixin, YearMixin, DetailView): """Periodical reports of money entering and leaving a specific account. For a selected account, shows the flow of money to and from other accounts @@ -195,11 +192,11 @@ def get_period_statement(self, period): ) for account in income.keys() | outgoings.keys() } - print(regroup) return regroup def get_report(self): - return [(p, self.get_period_statement(p)) for p in self.get_periods()] + periods = QuarterPeriod.for_year(self.get_year()) + return [(p, self.get_period_statement(p)) for p in periods] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -245,7 +242,7 @@ def get_context_data(self, **kwargs): return context -class TransactionsReportView(ReportAccessMixin, PeriodMixin, TemplateView): +class TransactionsReportView(ReportAccessMixin, YearMixin, TemplateView): """Report to view all transactions excluding those involving user accounts.""" template_name = "reports/transactions.html" @@ -264,8 +261,83 @@ def get_context_data(self, **kwargs): return context -class CashFlowMatrixView(ReportAccessMixin, TemplateView): - pass +class CashFlowMatrixView(ReportAccessMixin, YearMixin, TemplateView): + template_name = "reports/cashflow_matrix.html" + + def get_matrix(self, period): + """Computes the cash flow matrix and returns a two-dimensional dictionary.""" + tx = period.get_transactions() + + # Group by source/target combinations and sum the amount + qs = ( + # Set the group key to NULL for all user accounts + tx.annotate( + source_key=Case( + When(source__user__isnull=False, then=None), + default="source", + ), + target_key=Case( + When(target__user__isnull=False, then=None), + default="target", + ), + ) + .values("source_key", "target_key") + .annotate(sum=Sum("amount")) + ) + + # Convert to 2D matrix + matrix = {} + for cell in qs: + source = cell["source_key"] + target = cell["target_key"] + # Fetch Account from database + if source: + source = Account.objects.get(pk=source) + if target: + target = Account.objects.get(pk=target) + + # Enter in matrix + matrix.setdefault(source, {})[target] = cell["sum"] + + return matrix + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Accounts to display in the matrix + # TODO: change slug to short_name after #278 is merged + accounts = list( + Account.objects.filter( + Q(association__isnull=False) | Q(special__isnull=False) + ).order_by("special", "association__slug") + ) + # We add a `None` account indicating all user accounts + accounts.append(None) + + # Format the matrices as 2D tables for display in the template + tables = [] + for period in [YearPeriod(self.get_year())]: + matrix = self.get_matrix(period) + table = [ + ( + account_from, + [ + (account_to, matrix.get(account_from, {}).get(account_to)) + for account_to in accounts + ], + ) + for account_from in accounts + ] + tables.append((period, table)) + + context.update( + { + "accounts": accounts, + "tables": tables, + } + ) + + return context class StaleAccountsView(ReportAccessMixin, TemplateView): @@ -274,26 +346,32 @@ class StaleAccountsView(ReportAccessMixin, TemplateView): template_name = "reports/stale.html" def get_report(self) -> dict[str, dict]: + # tx = Transaction.objects.filter() # Get balance and latest transaction date for all accounts data = Transaction.objects.sum_by_account(latest=True) + # Exclude all non-user accounts (i.e. association and bookkeeping accounts) + exclude = set(a.pk for a in Account.objects.exclude(user__isnull=False)) + # Group by quartile and aggregate report = {} - for increase, reduction, last_date in data.values(): + for pk, (increase, reduction, last_date) in data.items(): + if pk in exclude: + continue + # If we omit localdate the timezone would be UTC and items might end up in # a different bucket. date = localdate(last_date) bucket = f"{date.year} Q{(date.month - 1) // 3 + 1}" balance = increase - reduction - if balance: - init = { + if balance and bucket not in report: + report[bucket] = { "positive_count": 0, "positive_sum": Decimal("0.00"), "negative_count": 0, "negative_sum": Decimal("0.00"), } - report.setdefault(bucket, init) # Update count and sum if balance > 0: From 96115a4f6c61208c3fdd77860990b170af3d57a0 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Sun, 31 Dec 2023 17:15:02 +0100 Subject: [PATCH 09/12] Rewrite report period controls, add membership count report --- assets/templates/reports/balance.html | 92 ++---- assets/templates/reports/cashflow.html | 65 ++-- assets/templates/reports/cashflow_matrix.html | 125 ++++---- assets/templates/reports/index.html | 12 +- assets/templates/reports/memberships.html | 32 ++ .../templates/reports/snippets/controls.html | 38 +++ assets/templates/reports/transactions.html | 16 +- reports/period.py | 190 ++++++++++-- reports/urls.py | 1 + reports/views.py | 283 ++++++++++-------- 10 files changed, 507 insertions(+), 347 deletions(-) create mode 100644 assets/templates/reports/memberships.html create mode 100644 assets/templates/reports/snippets/controls.html diff --git a/assets/templates/reports/balance.html b/assets/templates/reports/balance.html index 39605ce1..4854b5b8 100644 --- a/assets/templates/reports/balance.html +++ b/assets/templates/reports/balance.html @@ -1,23 +1,9 @@ {% extends 'reports/base.html' %} {% block report %} -

- Balance report - {{ year }} -

+

Balance report {{ period }}

- + {% url 'reports:balance' as url %}{% include 'reports/snippets/controls.html' %}

Bookkeeping accounts are shown in blue. @@ -27,51 +13,33 @@

Accounts for which no transactions took place are omitted.

- - {% for period, bookkeeping, association, user_pile in report_display %} -

{{ period.get_display_name }}

- -
- - - - - - - +
AccountStart balanceIncreaseReductionEnd balance
+ + + + + + + + + + + {% for account, statement in rows %} + + + + + + - - - {% for account, statement in bookkeeping %} - - - - - - - - - {% endfor %} - {% for account, statement in association %} - - - - - - - - {% endfor %} - {% if user_pile %} - - - - - - - - {% endif %} - -
AccountStart balanceIncreaseReductionEnd balance
+ {% if account.special %} + {{ account.get_special_display }} + {% elif account.association %} + {{ account.association.slug }} + {% else %}User accounts + {% endif %} + {{ statement.start_balance|default_if_none:"" }}{{ statement.increase|default_if_none:"" }}{{ statement.reduction|default_if_none:"" }}{{ statement.end_balance|default_if_none:"" }}
{{ account.get_special_display }}{{ statement.start_balance }}{{ statement.increase|default:"–" }}{{ statement.reduction|default:"–" }}{{ statement.end_balance }}
{{ account.association.name }}{{ statement.start_balance }}{{ statement.increase|default:"–" }}{{ statement.reduction|default:"–" }}{{ statement.end_balance }}
User accounts{{ user_pile.start_balance }}{{ user_pile.increase|default:"–" }}{{ user_pile.reduction|default:"–" }}{{ user_pile.end_balance }}
- {% endfor %} - + {% endfor %} + + {% endblock %} diff --git a/assets/templates/reports/cashflow.html b/assets/templates/reports/cashflow.html index a838a825..1bc0bb76 100644 --- a/assets/templates/reports/cashflow.html +++ b/assets/templates/reports/cashflow.html @@ -7,50 +7,37 @@ {% block report %}

Cash flow - {{ account }} / {{ year }} + {{ account }} / {{ period }}

+ {% url 'reports:cashflow' pk=account.pk as url %}{% include 'reports/snippets/controls.html' %} +

Inflow is in the direction from the opposite account towards - the {{ account }} account. Outflow is in the other direction. + the {{ account }} account. Outflow is in the other direction.

- - - {% for period, statements in report_display %} -

{{ period.get_display_name }}

- - - - - - +
Opposite accountInflowOutflow
+ + + + + + + + + {% for account, inflow, outflow in statements %} + + + + - - - {% for account, inflow, outflow in statements %} - - - - - - {% endfor %} - -
Opposite accountInflowOutflow
+ {% if account %}{{ account }} + » + {% else %}User accounts + {% endif %} + {{ inflow|default:"–" }}{{ outflow|default:"–" }}
- {% if account %}{{ account }} » - {% else %}User accounts - {% endif %} - {{ inflow|default:"–" }}{{ outflow|default:"–" }}
- {% endfor %} + {% endfor %} + + {% endblock %} diff --git a/assets/templates/reports/cashflow_matrix.html b/assets/templates/reports/cashflow_matrix.html index dde778a8..710ce720 100644 --- a/assets/templates/reports/cashflow_matrix.html +++ b/assets/templates/reports/cashflow_matrix.html @@ -3,81 +3,66 @@ {% block report %}

Cash flow matrix - {{ year }} + {{ period }}

- + {% url 'reports:cashflow_matrix' as url %}{% include 'reports/snippets/controls.html' %} - {% for period, table in tables %} -

{{ period }}

-
- - - - - - +
+
To
+ + + + + + + + {% for account in accounts %} + + {% endfor %} + + + + {% for account_from, row in table %} - - {% for account in accounts %} - + {% endif %} + + {% for account_to, amount in row %} + {% if account_from == account_to %} + + {% else %} + + {% endif %} {% endfor %} - - - {% for account_from, row in table %} - - {% if forloop.first %} - - {% endif %} - - {% for account_to, amount in row %} - - {% endfor %} - - {% endfor %} - -
To
+ {% if account.special %} + {{ account.get_special_display|truncatechars:6 }} + {% elif account.association %} + {# TODO: change slug to short_name after #278 is merged #} + {{ account.association.slug|truncatechars:6 }} + {% else %} + Users + {% endif %} +
- {% if account.special %} - {{ account.get_special_display|truncatechars:6 }} - {% elif account.association %} - {# TODO: change slug to short_name after #278 is merged #} - {{ account.association.slug|truncatechars:6 }} - {% else %} - Users - {% endif %} + {% if forloop.first %} + From ×{{ amount|default:"" }}
From - - {% if account_from == account_to %} - × - {% else %} - {{ amount|default:"" }} - {% endif %} -
-
- {% endfor %} + {% endfor %} + + +
{% endblock %} diff --git a/assets/templates/reports/index.html b/assets/templates/reports/index.html index 720aca40..1f8e39b5 100644 --- a/assets/templates/reports/index.html +++ b/assets/templates/reports/index.html @@ -2,9 +2,11 @@ {% block content %}

Reports

-

Finance

+ {% endblock %} diff --git a/assets/templates/reports/memberships.html b/assets/templates/reports/memberships.html new file mode 100644 index 00000000..960f2fc1 --- /dev/null +++ b/assets/templates/reports/memberships.html @@ -0,0 +1,32 @@ +{% extends 'reports/base.html' %} + +{% block report %} +

Membership counts

+ + + + + + + + + + + {% for association, pending, verified, rejected in report %} + + + + + + + {% endfor %} + +
AssociationVerifiedRejectedPending
{{ association }}{{ verified }}{{ rejected }}{{ pending|default:"–" }}
+ +{#

Freewheelers

#} +{#

#} +{# These users have joined dining lists while they were not a member of any association.#} +{# #} +{#

#} + +{% endblock %} \ No newline at end of file diff --git a/assets/templates/reports/snippets/controls.html b/assets/templates/reports/snippets/controls.html new file mode 100644 index 00000000..307b4e1b --- /dev/null +++ b/assets/templates/reports/snippets/controls.html @@ -0,0 +1,38 @@ +{% comment %} +Reporting period controls. + +Context variables: + url: Needs to be set to the current page url. + use_views: When True, the view controls (yearly/quarterly/monthly) will be shown, + else they will be hidden. + year: The current year. + view: The current view. +{% endcomment %} + diff --git a/assets/templates/reports/transactions.html b/assets/templates/reports/transactions.html index 8e1e3f7e..23c13648 100644 --- a/assets/templates/reports/transactions.html +++ b/assets/templates/reports/transactions.html @@ -1,21 +1,9 @@ {% extends 'reports/base.html' %} {% block report %} -

Transactions {{ year }}

- - +

Transactions {{ period }}

+ {% url 'reports:transactions' as url %}{% include 'reports/snippets/controls.html' %}

Site transactions between any association or bookkeeping account, excluding diff --git a/reports/period.py b/reports/period.py index 5e3a2dbe..66b85e49 100644 --- a/reports/period.py +++ b/reports/period.py @@ -1,21 +1,35 @@ +# This is so over-engineered :') from abc import ABC, abstractmethod from datetime import datetime -from django.utils.timezone import make_aware +from django.utils.timezone import localdate, make_aware from creditmanagement.models import Transaction class Period(ABC): + """Abstract class for a reporting period. + + Attributes: + view_name: Name of the period class used in the query string. + """ + + view_name = None + @abstractmethod - def get_period_start(self) -> datetime: + def start(self) -> datetime: + """The aware datetime instance for the start of this period.""" pass - def get_period_end(self) -> datetime: - return self.next().get_period_start() + def end(self) -> datetime: + """The aware datetime instance for the end of this period. + + Must be the same as the start of next period. + """ + return self.next().start() @abstractmethod - def get_display_name(self) -> str: + def display_name(self) -> str: pass @abstractmethod @@ -23,27 +37,84 @@ def next(self) -> "Period": """Returns the adjacent period directly after this one.""" pass + @abstractmethod + def previous(self) -> "Period": + """Returns the adjacent period directly before this one.""" + pass + def get_transactions(self, tx=None): """Filter transactions in this period.""" if tx is None: tx = Transaction.objects.all() - return tx.filter( - moment__gte=self.get_period_start(), moment__lt=self.get_period_end() - ) + return tx.filter(moment__gte=self.start(), moment__lt=self.end()) def __str__(self): - return self.get_display_name() + return self.display_name() + + @staticmethod + def get_class(view_name: str): + """Returns the period class for the given view name.""" + for cls in YearPeriod, QuarterPeriod, MonthPeriod: + if cls.view_name == view_name: + return cls + raise ValueError + + @classmethod + @abstractmethod + def from_url_param(cls, period: str) -> "Period": + """Returns the period instance from the string value. + + Raises: + ValueError: When the parameter cannot be parsed. + """ + pass + + @abstractmethod + def url_param(self) -> str: + """Get the string value of this instance for the URL parameter.""" + pass + + @abstractmethod + def to_year(self) -> "YearPeriod": + pass + + @abstractmethod + def to_quarter(self) -> "QuarterPeriod": + pass + + @abstractmethod + def to_month(self) -> "MonthPeriod": + pass + + @classmethod + @abstractmethod + def current(cls) -> "Period": + """Returns the current period in local time.""" + pass class MonthPeriod(Period): + view_name = "monthly" + def __init__(self, year: int, month: int): if month < 1 or month > 12: raise ValueError self.year = year self.month = month - def get_period_start(self): - return (make_aware(datetime(self.year, self.month, 1)),) + def start(self): + return make_aware(datetime(self.year, self.month, 1)) + + def display_name(self): + return self.start().strftime("%B %Y") + + @classmethod + def from_url_param(cls, period: str) -> "Period": + int_val = int(period) + return cls(int_val // 12, int_val % 12 + 1) + + def url_param(self) -> str: + return str(self.year * 12 + self.month - 1) def next(self) -> "Period": return ( @@ -52,24 +123,43 @@ def next(self) -> "Period": else MonthPeriod(self.year + 1, 1) ) - @classmethod - def for_year(cls, year: int) -> list["MonthPeriod"]: - return [cls(year, m) for m in range(1, 13)] + def previous(self) -> "Period": + return ( + MonthPeriod(self.year, self.month - 1) + if self.month > 1 + else MonthPeriod(self.year - 1, 12) + ) + + def to_year(self) -> "YearPeriod": + return YearPeriod(self.year) + + def to_quarter(self) -> "QuarterPeriod": + return QuarterPeriod(self.year, (self.month - 1) // 3 + 1) - def get_display_name(self): - return self.get_period_start().strftime("%B") + def to_month(self) -> "MonthPeriod": + return self + + @classmethod + def current(cls) -> "Period": + date = localdate() + return MonthPeriod(date.year, date.month) class QuarterPeriod(Period): + view_name = "quarterly" + def __init__(self, year: int, quarter: int): if quarter < 1 or quarter > 4: raise ValueError self.year = year self.quarter = quarter - def get_period_start(self) -> datetime: + def start(self) -> datetime: return make_aware(datetime(self.year, (self.quarter - 1) * 3 + 1, 1)) + def display_name(self) -> str: + return f"{self.year} Q{self.quarter}" + def next(self) -> "Period": return ( QuarterPeriod(self.year, self.quarter + 1) @@ -77,28 +167,70 @@ def next(self) -> "Period": else QuarterPeriod(self.year + 1, 1) ) + def previous(self) -> "Period": + return ( + QuarterPeriod(self.year, self.quarter - 1) + if self.quarter > 1 + else QuarterPeriod(self.year - 1, 4) + ) + @classmethod - def for_year(cls, year: int): - return [cls(year, q) for q in range(1, 5)] + def from_url_param(cls, period: str) -> "Period": + int_val = int(period) + return QuarterPeriod(int_val // 4, int_val % 4 + 1) - def get_display_name(self) -> str: - return ( - "Q1 January, February, March", - "Q2 April, May, June", - "Q3 July, August, September", - "Q4 October, November, December", - )[self.quarter - 1] + def url_param(self) -> str: + return str(self.year * 4 + self.quarter - 1) + + def to_year(self) -> "YearPeriod": + return YearPeriod(self.year) + + def to_quarter(self) -> "QuarterPeriod": + return self + + def to_month(self) -> "MonthPeriod": + return MonthPeriod(self.year, (self.quarter - 1) * 3 + 1) + + @classmethod + def current(cls) -> "Period": + date = localdate() + return QuarterPeriod(date.year, (date.month - 1) // 3 + 1) class YearPeriod(Period): + view_name = "yearly" + def __init__(self, year: int): self.year = year - def get_period_start(self) -> datetime: + def start(self) -> datetime: return make_aware(datetime(self.year, 1, 1)) + def display_name(self) -> str: + return str(self.year) + def next(self) -> "Period": return YearPeriod(self.year + 1) - def get_display_name(self) -> str: - return "" + def previous(self) -> "Period": + return YearPeriod(self.year - 1) + + @classmethod + def from_url_param(cls, period: str) -> "Period": + return YearPeriod(int(period)) + + def url_param(self) -> str: + return str(self.year) + + def to_year(self) -> "YearPeriod": + return self + + def to_quarter(self) -> "QuarterPeriod": + return QuarterPeriod(self.year, 1) + + def to_month(self) -> "MonthPeriod": + return MonthPeriod(self.year, 1) + + @classmethod + def current(cls) -> "Period": + return YearPeriod(localdate().year) diff --git a/reports/urls.py b/reports/urls.py index 82c1d9b2..a300f2cc 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -12,4 +12,5 @@ path("cashflow2/", views.CashFlowMatrixView.as_view(), name="cashflow_matrix"), path("transactions/", views.TransactionsReportView.as_view(), name="transactions"), path("stale/", views.StaleAccountsView.as_view(), name="stale"), + path("memberships/", views.MembershipCountView.as_view(), name="memberships"), ] diff --git a/reports/views.py b/reports/views.py index 70ed8a34..8ae62c55 100644 --- a/reports/views.py +++ b/reports/views.py @@ -2,13 +2,13 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import BadRequest -from django.db.models import Case, Sum, When, Q -from django.utils.timezone import localdate +from django.db.models import Case, Q, Sum, When +from django.utils.timezone import localdate, now from django.views.generic import DetailView, TemplateView from creditmanagement.models import Account, Transaction -from reports.period import Period, QuarterPeriod, YearPeriod -from userdetails.models import Association +from reports.period import Period +from userdetails.models import Association, UserMembership class ReportAccessMixin(UserPassesTestMixin): @@ -34,22 +34,47 @@ def get_context_data(self, **kwargs): return context -class YearMixin: - """Mixin for yearly reporting periods.""" +class PeriodMixin: + """Mixin for yearly reporting periods. + + Attributes: + view_choice: Boolean that enables or disables choosing a different view. + """ + + default_view = "yearly" + view_choice = True + + def get_period(self) -> Period: + view = self.request.GET.get("view", self.default_view) + + # When view_choice is False we can only use the default view + if not self.view_choice and view != self.default_view: + raise BadRequest - def get_year(self): try: - return int(self.request.GET.get("year", localdate().year)) + period_class = Period.get_class(view) + + if "period" in self.request.GET: + return period_class.from_url_param(self.request.GET["period"]) + else: + return period_class.current() except ValueError: raise BadRequest def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["year"] = self.get_year() + context["view_choice"] = self.view_choice + if self.period: + context["period"] = self.period return context + def get(self, request, *args, **kwargs): + # Add period to this instance + self.period = self.get_period() + return super().get(request, *args, **kwargs) + -class BalanceView(ReportAccessMixin, YearMixin, TemplateView): +class BalanceView(ReportAccessMixin, PeriodMixin, TemplateView): """Periodical reports of the balance of all credit accounts. User accounts are aggregated as one large pile. Association and bookkeeping @@ -59,85 +84,64 @@ class BalanceView(ReportAccessMixin, YearMixin, TemplateView): template_name = "reports/balance.html" def get_report(self): - """Computes the report values.""" - periods = QuarterPeriod.for_year(self.get_year()) - - # Compute opening balance - running_balance = { - account: increase - reduction - for account, (increase, reduction) in Transaction.objects.filter( - moment__lt=periods[0].get_period_start() - ) - .sum_by_account(group_users=True) - .items() + """Computes the balance statements.""" + if self.period.start() > now(): + # If the start of period is in the future, we cannot compute the start + # balance and there are no statements. + return {} + + # All transactions before current period + before_tx = Transaction.objects.filter(moment__lt=self.period.start()) + # All transactions in this period + period_tx = self.period.get_transactions() + + # Compute opening balance from all transaction before the current period + opening = before_tx.sum_by_account(group_users=True) # type: dict + # Compute increase and reduction sum in this period + mutation = period_tx.sum_by_account(group_users=True) # type: dict + + # Add opening balances to report + statements = { + account: {"start_balance": increase - reduction} + for account, (increase, reduction) in opening.items() } - report = [] - for period in periods: - # Compute credit and debit sum in the period - mutation = period.get_transactions().sum_by_account(group_users=True) - - # Compile report for this period - statements = { - account: { - "start_balance": running_balance.get(account, Decimal("0.00")), + # Add mutations to report + for account, (increase, reduction) in mutation.items(): + statement = statements.setdefault(account, {"start_balance": None}) + start = statement["start_balance"] or Decimal("0.00") + statement.update( + { "increase": increase, "reduction": reduction, - "end_balance": running_balance.get(account, Decimal("0.00")) - + increase - - reduction, + "end_balance": start + increase - reduction, } - for account, (increase, reduction) in mutation.items() - } - - # Update running balance - running_balance.update( - (account, val["end_balance"]) for account, val in statements.items() ) - report.append((period, statements)) - return report + return statements def get_context_data(self, **kwargs): # This function just regroups and sorts the values for display - context = super().get_context_data(**kwargs) - # For the template: retrieve each Account instance, regroup by type and sort - report_display = [] - account = {} # Cache for Account lookups - - for period, statements in self.get_report(): - # Retrieve Accounts from database - for pk in statements: - if pk is not None and pk not in account: - account[pk] = Account.objects.get(pk=pk) - - # Split by type - bookkeeping = [] - association = [] - user_pile = None - for pk, statement in statements.items(): - if pk is None: - user_pile = statement - elif account[pk].special: - bookkeeping.append((account[pk], statement)) - elif account[pk].association: - association.append((account[pk], statement)) - else: - raise RuntimeError # Unreachable - - # Sort bookkeeping and association by name - bookkeeping.sort(key=lambda e: e[0].special) - association.sort(key=lambda e: e[0].association.name) - - report_display.append((period, bookkeeping, association, user_pile)) - - context["report_display"] = report_display + # The accounts to display in the report + accounts = list( + Account.objects.exclude(user__isnull=False).order_by( + "special", "association__slug" + ) + ) + accounts.append(None) # User pile + + # Add statements to context + statements = self.get_report() + context["rows"] = [ + (account, statements.get(getattr(account, "pk", None), {})) + for account in accounts + ] return context -class CashFlowView(ReportAccessMixin, YearMixin, DetailView): +class CashFlowView(ReportAccessMixin, PeriodMixin, DetailView): """Periodical reports of money entering and leaving a specific account. For a selected account, shows the flow of money to and from other accounts @@ -148,9 +152,9 @@ class CashFlowView(ReportAccessMixin, YearMixin, DetailView): model = Account context_object_name = "account" - def get_period_statement(self, period): + def get_statements(self): """Returns a dictionary from Account or None to an income/outgoings tuple.""" - tx = period.get_transactions() + tx = self.period.get_transactions() # Aggregate income and outgoings income = ( tx.filter(target=self.object) @@ -194,35 +198,27 @@ def get_period_statement(self, period): } return regroup - def get_report(self): - periods = QuarterPeriod.for_year(self.get_year()) - return [(p, self.get_period_statement(p)) for p in periods] - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - report_display = [] - for period, statements in self.get_report(): - # Convert to list of tuples - statements2 = [ - (account, inc, out) for account, (inc, out) in statements.items() - ] - # Sort in 3 steps to have first the bookkeeping accounts in - # alphabetical order, then the association accounts in order and - # then the user pile. - # - # This works because sort is stable. - statements2.sort( - key=lambda v: v[0].association.name if v[0] and v[0].association else "" - ) - statements2.sort( - key=lambda v: v[0].special if v[0] and v[0].special else "" - ) - statements2.sort( - key=lambda v: 2 if v[0] is None else 1 if v[0].association else 0 - ) - report_display.append((period, statements2)) - context["report_display"] = report_display + # Convert to list of tuples + statements = [ + (account, inc, out) for account, (inc, out) in self.get_statements().items() + ] + # Sort in 3 steps to have first the bookkeeping accounts in + # alphabetical order, then the association accounts in order and + # then the user pile. + # + # This works because sort is stable. + statements.sort( + key=lambda v: v[0].association.name if v[0] and v[0].association else "" + ) + statements.sort(key=lambda v: v[0].special if v[0] and v[0].special else "") + statements.sort( + key=lambda v: 2 if v[0] is None else 1 if v[0].association else 0 + ) + + context["statements"] = statements return context @@ -242,31 +238,34 @@ def get_context_data(self, **kwargs): return context -class TransactionsReportView(ReportAccessMixin, YearMixin, TemplateView): +class TransactionsReportView(ReportAccessMixin, PeriodMixin, TemplateView): """Report to view all transactions excluding those involving user accounts.""" template_name = "reports/transactions.html" + default_view = "yearly" + view_choice = False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( { - "transactions": Transaction.objects.filter( - moment__year=self.get_year(), + "transactions": self.period.get_transactions() + .filter( source__user__isnull=True, target__user__isnull=True, - ).order_by("moment") + ) + .order_by("moment") } ) return context -class CashFlowMatrixView(ReportAccessMixin, YearMixin, TemplateView): +class CashFlowMatrixView(ReportAccessMixin, PeriodMixin, TemplateView): template_name = "reports/cashflow_matrix.html" - def get_matrix(self, period): + def get_matrix(self): """Computes the cash flow matrix and returns a two-dimensional dictionary.""" - tx = period.get_transactions() + tx = self.period.get_transactions() # Group by source/target combinations and sum the amount qs = ( @@ -315,28 +314,18 @@ def get_context_data(self, **kwargs): accounts.append(None) # Format the matrices as 2D tables for display in the template - tables = [] - for period in [YearPeriod(self.get_year())]: - matrix = self.get_matrix(period) - table = [ - ( - account_from, - [ - (account_to, matrix.get(account_from, {}).get(account_to)) - for account_to in accounts - ], - ) - for account_from in accounts - ] - tables.append((period, table)) - - context.update( - { - "accounts": accounts, - "tables": tables, - } - ) - + matrix = self.get_matrix() + table = [ + ( + account_from, + [ + (account_to, matrix.get(account_from, {}).get(account_to)) + for account_to in accounts + ], + ) + for account_from in accounts + ] + context.update({"accounts": accounts, "table": table}) return context @@ -399,7 +388,37 @@ def get_context_data(self, **kwargs): return context -class DinerReportView(TemplateView): +class DinerReportView(ReportAccessMixin, TemplateView): """Reports on diner counts.""" pass + + +class MembershipCountView(ReportAccessMixin, TemplateView): + """Simply shows the number of members per association.""" + + template_name = "reports/memberships.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + mem = UserMembership.objects.all() + report = [ + ( + association, + # Pending memberships + mem.filter(association=association, verified_on__isnull=True).count(), + # Verified memberships + mem.filter(association=association, is_verified=True).count(), + # Declined memberships + mem.filter( + association=association, + is_verified=False, + verified_on__isnull=False, + ).count(), + ) + for association in Association.objects.order_by("name") + ] + + context.update({"report": report}) + return context From 8ce957898549eb7e5e38a65c4cb2f97c6ec7af76 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Mon, 1 Jan 2024 17:50:03 +0100 Subject: [PATCH 10/12] Report layout updates --- assets/templates/base.html | 29 +++++++++---------- assets/templates/reports/balance.html | 14 +++------ assets/templates/reports/cashflow_matrix.html | 13 ++++----- assets/templates/reports/memberships.html | 2 +- assets/templates/reports/transactions.html | 10 +++++-- reports/period.py | 8 +++-- reports/views.py | 9 ++---- 7 files changed, 40 insertions(+), 45 deletions(-) diff --git a/assets/templates/base.html b/assets/templates/base.html index 4d11e53a..b176ff79 100644 --- a/assets/templates/base.html +++ b/assets/templates/base.html @@ -40,11 +40,6 @@ Settings - {% if user.has_admin_site_access %} - - Control panel - - {% endif %} {% if user.boards.count > 0 %}

{% endif %} @@ -64,6 +59,19 @@ {% endif %} {% endfor %} + {% if user.has_site_stats_access or user.has_admin_site_access %} + + {% endif %} + {% if user.has_site_stats_access %} + + Reports + + {% endif %} + {% if user.has_admin_site_access %} + + Control panel + + {% endif %}
{% csrf_token %} @@ -97,17 +105,6 @@ ! {% endif %} -{# #} -{# #} -{# Statistics New#} -{# #} -{# #} -{# #} -{# News and updates#} -{# {% if user.requires_information_updates %}#} -{# !#} -{# {% endif %}#} -{# #}
diff --git a/assets/templates/reports/balance.html b/assets/templates/reports/balance.html index 4854b5b8..8255bc9c 100644 --- a/assets/templates/reports/balance.html +++ b/assets/templates/reports/balance.html @@ -5,14 +5,6 @@

Balance report {{ period }}

{% url 'reports:balance' as url %}{% include 'reports/snippets/controls.html' %} -

- Bookkeeping accounts are shown in blue. - Association accounts are gray. - All individual user balances are summed together and shown as - a single row at the bottom. - Accounts for which no transactions took place are omitted. -

- @@ -30,8 +22,10 @@

Balance report {{ period }}

{% if account.special %} {{ account.get_special_display }} {% elif account.association %} - {{ account.association.slug }} - {% else %}User accounts + {{ account.association.get_short_name }} + {% else %} + User accounts
+ Sum of the balances of all user accounts. {% endif %} diff --git a/assets/templates/reports/cashflow_matrix.html b/assets/templates/reports/cashflow_matrix.html index 710ce720..24190144 100644 --- a/assets/templates/reports/cashflow_matrix.html +++ b/assets/templates/reports/cashflow_matrix.html @@ -25,8 +25,7 @@

{% if account.special %} {{ account.get_special_display|truncatechars:6 }} {% elif account.association %} - {# TODO: change slug to short_name after #278 is merged #} - {{ account.association.slug|truncatechars:6 }} + {{ account.association.get_short_name|truncatechars:6 }} {% else %} Users {% endif %} @@ -45,12 +44,12 @@

{% endif %}

{% for account_to, amount in row %} diff --git a/assets/templates/reports/memberships.html b/assets/templates/reports/memberships.html index 960f2fc1..bd315f9b 100644 --- a/assets/templates/reports/memberships.html +++ b/assets/templates/reports/memberships.html @@ -14,7 +14,7 @@

Membership counts

{% for association, pending, verified, rejected in report %} - + diff --git a/assets/templates/reports/transactions.html b/assets/templates/reports/transactions.html index 23c13648..cbb8591c 100644 --- a/assets/templates/reports/transactions.html +++ b/assets/templates/reports/transactions.html @@ -25,8 +25,14 @@

Transactions {{ period }}

{% for tx in transactions %} - - + + diff --git a/reports/period.py b/reports/period.py index 66b85e49..72777d70 100644 --- a/reports/period.py +++ b/reports/period.py @@ -1,6 +1,6 @@ # This is so over-engineered :') from abc import ABC, abstractmethod -from datetime import datetime +from datetime import MAXYEAR, MINYEAR, datetime from django.utils.timezone import localdate, make_aware @@ -97,7 +97,7 @@ class MonthPeriod(Period): view_name = "monthly" def __init__(self, year: int, month: int): - if month < 1 or month > 12: + if year < MINYEAR or year > MAXYEAR or month < 1 or month > 12: raise ValueError self.year = year self.month = month @@ -149,7 +149,7 @@ class QuarterPeriod(Period): view_name = "quarterly" def __init__(self, year: int, quarter: int): - if quarter < 1 or quarter > 4: + if year < MINYEAR or year > MAXYEAR or quarter < 1 or quarter > 4: raise ValueError self.year = year self.quarter = quarter @@ -201,6 +201,8 @@ class YearPeriod(Period): view_name = "yearly" def __init__(self, year: int): + if year < MINYEAR or year > MAXYEAR: + raise ValueError self.year = year def start(self) -> datetime: diff --git a/reports/views.py b/reports/views.py index 8ae62c55..5eb25d18 100644 --- a/reports/views.py +++ b/reports/views.py @@ -13,9 +13,7 @@ class ReportAccessMixin(UserPassesTestMixin): def test_func(self): - # TODO rewrite to self.request.user.has_site_stats_access() when PR #276 is merged - boards = Association.objects.filter(user=self.request.user) - return True in (b.has_site_stats_access for b in boards) + return self.request.user.has_site_stats_access() class ReportsView(ReportAccessMixin, TemplateView): @@ -127,7 +125,7 @@ def get_context_data(self, **kwargs): # The accounts to display in the report accounts = list( Account.objects.exclude(user__isnull=False).order_by( - "special", "association__slug" + "special", "association__short_name" ) ) accounts.append(None) # User pile @@ -304,11 +302,10 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Accounts to display in the matrix - # TODO: change slug to short_name after #278 is merged accounts = list( Account.objects.filter( Q(association__isnull=False) | Q(special__isnull=False) - ).order_by("special", "association__slug") + ).order_by("special", "association__short_name") ) # We add a `None` account indicating all user accounts accounts.append(None) From 0f2c97e3471756b3fe7a24a32ef75c45e8759ff6 Mon Sep 17 00:00:00 2001 From: Maarten Visscher Date: Thu, 4 Jan 2024 17:59:29 +0100 Subject: [PATCH 11/12] Diners and leaderboard reports --- assets/templates/base_no_navbar.html | 3 + assets/templates/reports/diners.html | 160 ++++++++++ assets/templates/reports/index.html | 25 +- assets/templates/reports/leaderboard.html | 77 +++++ assets/templates/reports/memberships.html | 54 ++-- .../templates/reports/snippets/controls.html | 22 +- assets/templates/reports/transactions.html | 2 +- compose.yml | 1 + reports/period.py | 231 +++++++++++--- reports/queries.py | 288 ++++++++++++++++++ reports/urls.py | 2 + reports/views.py | 125 +++++++- scaladining/context_processors.py | 3 +- scaladining/settings.py | 2 + 14 files changed, 893 insertions(+), 102 deletions(-) create mode 100644 assets/templates/reports/diners.html create mode 100644 assets/templates/reports/leaderboard.html create mode 100644 reports/queries.py diff --git a/assets/templates/base_no_navbar.html b/assets/templates/base_no_navbar.html index 776cc44d..444e3a62 100644 --- a/assets/templates/base_no_navbar.html +++ b/assets/templates/base_no_navbar.html @@ -20,6 +20,9 @@ +{% if SITE_BANNER %} +
{{ SITE_BANNER }}
+{% endif %} {% block body %}{% endblock %} {# Bootstrap JS bundle (jQuery is already loaded in the head). #}
{{ statement.start_balance|default_if_none:"" }}
{{ association }}{{ association.get_short_name }} {{ verified }} {{ rejected }} {{ pending|default:"–" }}
{{ tx.moment|date:"d-m" }}{{ tx.source }}{{ tx.target }} + {% if tx.source.association %}{{ tx.source.association.get_short_name }} + {% else %}{{ tx.source }}{% endif %} + + {% if tx.target.association %}{{ tx.target.association.get_short_name }} + {% else %}{{ tx.target }}{% endif %} + {{ tx.amount }} {{ tx.description }} {{ tx.created_by }}